/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License. You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.apache.hadoop.ozone.container.common.helpers;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.apache.hadoop.hdds.HddsUtils.REDACTED_STRING;
import static org.apache.hadoop.hdds.HddsUtils.processForDebug;
import static org.apache.hadoop.hdds.protocol.MockDatanodeDetails.randomDatanodeDetails;
import static org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.Type.ReadChunk;
import static org.apache.hadoop.hdds.scm.protocolPB.ContainerCommandResponseBuilders.getReadChunkResponse;
import static org.apache.hadoop.ozone.container.ContainerTestHelper.getDummyCommandRequestProto;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.UUID;
import org.apache.commons.lang3.RandomUtils;
import org.apache.hadoop.hdds.HddsConfigKeys;
import org.apache.hadoop.hdds.conf.OzoneConfiguration;
import org.apache.hadoop.hdds.fs.SpaceUsageSource;
import org.apache.hadoop.hdds.protocol.DatanodeDetails;
import org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.ContainerCommandRequestProto;
import org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.ContainerCommandResponseProto;
import org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.Result;
import org.apache.hadoop.hdds.protocol.proto.HddsProtos;
import org.apache.hadoop.hdds.scm.ByteStringConversion;
import org.apache.hadoop.hdds.scm.ScmConfigKeys;
import org.apache.hadoop.hdds.scm.container.common.helpers.StorageContainerException;
import org.apache.hadoop.ozone.common.ChunkBuffer;
import org.apache.hadoop.ozone.container.common.volume.HddsVolume;
import org.apache.hadoop.ozone.container.common.volume.VolumeInfoMetrics;
import org.apache.ratis.thirdparty.com.google.protobuf.TextFormat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.MockedStatic;

/**
 * Test for {@link ContainerUtils}.
 */
public class TestContainerUtils {

  private OzoneConfiguration conf;

  @BeforeEach
  void setup(@TempDir File dir) {
    conf = new OzoneConfiguration();
    conf.set(HddsConfigKeys.OZONE_METADATA_DIRS, dir.toString());
  }

  @Test
  public void redactsDataBuffers() {
    // GIVEN
    final String junk = "junk";
    ContainerCommandRequestProto req = getDummyCommandRequestProto(ReadChunk);
    ChunkBuffer data = ChunkBuffer.wrap(ByteBuffer.wrap(junk.getBytes(UTF_8)));
    ContainerCommandResponseProto resp = getReadChunkResponse(req, data,
        ByteStringConversion::safeWrap);

    final String original = TextFormat.shortDebugString(resp);
    // WHEN
    final String processed = processForDebug(resp);

    // THEN
    final int j = original.indexOf(junk);
    final int r = processed.indexOf(REDACTED_STRING);

    assertEquals(j, r);
    assertEquals(original.substring(0, j), processed.substring(0, r));
    assertEquals(original.substring(j + junk.length()), processed.substring(r + REDACTED_STRING.length()));
  }

  @Test
  public void testTarName() throws IOException {
    long containerId = 100;
    String tarName = ContainerUtils.getContainerTarName(containerId);

    assertEquals(containerId,
        ContainerUtils.retrieveContainerIdFromTarName(tarName));
  }

  @Test
  public void testDatanodeIDPersistent(@TempDir File tempDir) throws Exception {
    // Generate IDs for testing
    DatanodeDetails id1 = randomDatanodeDetails();
    try (MockedStatic<InetAddress> mockedStaticInetAddress = mockStatic(InetAddress.class)) {
      InetAddress mockedInetAddress = mock(InetAddress.class);
      mockedStaticInetAddress.when(() -> InetAddress.getByName(id1.getHostName()))
          .thenReturn(mockedInetAddress);

      // If persisted ip address is different from resolved ip address,
      // DatanodeDetails should return the persisted ip address.
      // Upon validation of the ip address, DatanodeDetails should return the resolved ip address.
      when(mockedInetAddress.getHostAddress())
          .thenReturn("127.0.0.1");
      assertWriteReadWithChangedIpAddress(tempDir, id1);

      when(mockedInetAddress.getHostAddress())
          .thenReturn(id1.getIpAddress());

      id1.setPort(DatanodeDetails.newStandalonePort(1));
      assertWriteRead(tempDir, id1);

      // Add certificate serial  id.
      id1.setCertSerialId(String.valueOf(RandomUtils.secure().randomLong()));
      assertWriteRead(tempDir, id1);

      // Read should return an empty value if file doesn't exist
      File nonExistFile = new File(tempDir, "non_exist.id");
      assertThrows(IOException.class,
          () -> ContainerUtils.readDatanodeDetailsFrom(nonExistFile, conf));

      // Read should fail if the file is malformed
      File malformedFile = new File(tempDir, "malformed.id");
      createMalformedIDFile(malformedFile);
      assertThrows(IOException.class,
          () -> ContainerUtils.readDatanodeDetailsFrom(malformedFile, conf));

      // Test upgrade scenario - protobuf file instead of yaml
      File protoFile = new File(tempDir, "valid-proto.id");
      try (OutputStream out = Files.newOutputStream(protoFile.toPath())) {
        HddsProtos.DatanodeDetailsProto proto = id1.getProtoBufMessage();
        proto.writeTo(out);
      }
      assertDetailsEquals(id1, ContainerUtils.readDatanodeDetailsFrom(protoFile, conf));

      id1.setInitialVersion(1);
      assertWriteRead(tempDir, id1);
    }
  }

  @Test
  public void testDatanodeIdRecovery(@TempDir File tempDir) throws IOException {
    // 1. Setup storage directory and VERSION file
    String datanodeUuid = UUID.randomUUID().toString();
    File storageDir = new File(tempDir, "datanode-storage");
    assertTrue(storageDir.mkdirs());
    conf.set(ScmConfigKeys.HDDS_DATANODE_DIR_KEY, storageDir.getAbsolutePath());

    File hddsSubDir = new File(storageDir, "hdds");
    assertTrue(hddsSubDir.mkdirs());
    File versionFile = new File(hddsSubDir, "VERSION");
    DatanodeVersionFile dnVersionFile = new DatanodeVersionFile(
        "storage-id", "cluster-id", datanodeUuid, System.currentTimeMillis(), 0);
    dnVersionFile.createVersionFile(versionFile);

    // 2. Simulate a corrupted/empty datanode.id file
    File datanodeIdFile = new File(tempDir, "datanode.id");
    assertTrue(datanodeIdFile.createNewFile());

    assertEquals(0, datanodeIdFile.length(), "Datanode ID file should be empty initially");

    // 3. Call readDatanodeDetailsFrom and verify recovery
    DatanodeDetails recoveredDetails =
        ContainerUtils.readDatanodeDetailsFrom(datanodeIdFile, conf);

    // 4. Assertions
    // Recovered UUID matches the one in the VERSION file
    assertEquals(datanodeUuid, recoveredDetails.getUuidString());

    // datanode.id file is recreated and is not empty
    assertTrue(datanodeIdFile.length() > 0, "Datanode ID file should have been recreated with content");

    // The recreated file can be read normally and contains the correct UUID
    DatanodeDetails finalDetails = ContainerUtils.readDatanodeDetailsFrom(datanodeIdFile, conf);
    assertEquals(datanodeUuid, finalDetails.getUuidString());
  }

  private void assertWriteRead(@TempDir File tempDir,
      DatanodeDetails details) throws IOException {
    // Write a single ID to the file and read it out
    File file = new File(tempDir, "valid-values.id");
    ContainerUtils.writeDatanodeDetailsTo(details, file, conf);

    DatanodeDetails read = ContainerUtils.readDatanodeDetailsFrom(file, conf);

    assertDetailsEquals(details, read);
    assertEquals(details.getCurrentVersion(), read.getCurrentVersion());
  }

  private void assertWriteReadWithChangedIpAddress(@TempDir File tempDir,
      DatanodeDetails details) throws IOException {
    // Write a single ID to the file and read it out
    File file = new File(tempDir, "valid-values.id");
    ContainerUtils.writeDatanodeDetailsTo(details, file, conf);
    DatanodeDetails read = ContainerUtils.readDatanodeDetailsFrom(file, conf);
    assertEquals(details.getIpAddress(), read.getIpAddress());
    read.validateDatanodeIpAddress();
    assertEquals("127.0.0.1", read.getIpAddress());
  }

  private void createMalformedIDFile(File malformedFile)
      throws IOException {
    DatanodeDetails id = randomDatanodeDetails();
    ContainerUtils.writeDatanodeDetailsTo(id, malformedFile, conf);

    try (OutputStream out = Files.newOutputStream(malformedFile.toPath())) {
      out.write("malformed".getBytes(StandardCharsets.UTF_8));
    }
  }

  private static void assertDetailsEquals(DatanodeDetails expected,
      DatanodeDetails actual) {
    assertEquals(expected, actual);
    assertEquals(expected.getCertSerialId(), actual.getCertSerialId());
    assertEquals(expected.getProtoBufMessage(), actual.getProtoBufMessage());
    assertEquals(expected.getInitialVersion(), actual.getInitialVersion());
    assertEquals(expected.getIpAddress(), actual.getIpAddress());
  }

  @Test
  public void assertSpaceAvailabilityIncrementsSoftBandWhenBetweenReportedAndHard()
      throws Exception {
    HddsVolume volume = mock(HddsVolume.class);
    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 100L, 900L);
    when(volume.getCurrentUsage()).thenReturn(usage);
    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
    when(volume.getReportedFreeSpaceToSpare(1000L)).thenReturn(100L);
    when(volume.getVolumeInfoStats()).thenReturn(metrics);
    when(volume.toString()).thenReturn("mockVolume");

    ContainerUtils.assertSpaceAvailability(1L, volume, 50);

    verify(metrics).incNumWriteRequestsInSoftBandMinFreeSpace();
    verify(metrics, never()).incNumWriteRequestsRejectedHardMinFreeSpace();
  }

  @Test
  public void assertSpaceAvailabilityIncrementsHardRejectWhenHardLimitViolated() {
    HddsVolume volume = mock(HddsVolume.class);
    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 100L, 900L);
    when(volume.getCurrentUsage()).thenReturn(usage);
    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
    when(volume.getReportedFreeSpaceToSpare(1000L)).thenReturn(100L);
    when(volume.getVolumeInfoStats()).thenReturn(metrics);
    when(volume.toString()).thenReturn("mockVolume");

    StorageContainerException ex = assertThrows(StorageContainerException.class,
        () -> ContainerUtils.assertSpaceAvailability(1L, volume, 80));
    assertEquals(Result.DISK_OUT_OF_SPACE, ex.getResult());

    verify(metrics).incNumWriteRequestsRejectedHardMinFreeSpace();
    verify(metrics, never()).incNumWriteRequestsInSoftBandMinFreeSpace();
  }

  /**
   * available(100) - hardSpare(30) = 70 == sizeRequested(70).
   * The check is strict less-than, so the write passes at the exact boundary.
   * The volume is still inside the soft band (available - softSpare = 0 < 70),
   * so the soft-band metric fires.
   */
  @Test
  public void assertSpaceAvailabilityPassesAtExactHardBoundary() throws Exception {
    HddsVolume volume = mock(HddsVolume.class);
    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 100L, 900L);
    when(volume.getCurrentUsage()).thenReturn(usage);
    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
    when(volume.getReportedFreeSpaceToSpare(1000L)).thenReturn(100L);
    when(volume.getVolumeInfoStats()).thenReturn(metrics);
    when(volume.toString()).thenReturn("mockVolume");

    // available(100) - hardSpare(30) = 70 == sizeRequested → passes (< not <=)
    assertDoesNotThrow(() -> ContainerUtils.assertSpaceAvailability(1L, volume, 70));

    verify(metrics).incNumWriteRequestsInSoftBandMinFreeSpace();
    verify(metrics, never()).incNumWriteRequestsRejectedHardMinFreeSpace();
  }

  /**
   * available(100) - hardSpare(30) = 70 < sizeRequested(71).
   * One byte past the hard boundary; write must be rejected.
   */
  @Test
  public void assertSpaceAvailabilityRejectsOneByteOverHardBoundary() {
    HddsVolume volume = mock(HddsVolume.class);
    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 100L, 900L);
    when(volume.getCurrentUsage()).thenReturn(usage);
    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
    when(volume.getReportedFreeSpaceToSpare(1000L)).thenReturn(100L);
    when(volume.getVolumeInfoStats()).thenReturn(metrics);
    when(volume.toString()).thenReturn("mockVolume");

    // available(100) - hardSpare(30) = 70 < 71 → rejected
    StorageContainerException ex = assertThrows(StorageContainerException.class,
        () -> ContainerUtils.assertSpaceAvailability(1L, volume, 71));
    assertEquals(Result.DISK_OUT_OF_SPACE, ex.getResult());

    verify(metrics).incNumWriteRequestsRejectedHardMinFreeSpace();
    verify(metrics, never()).incNumWriteRequestsInSoftBandMinFreeSpace();
  }

  /**
   * available(200) is well above both soft(100) and hard(30) spares.
   * available - hardSpare = 170 > sizeRequested(50): write passes.
   * available - softSpare = 100 > sizeRequested(50): not in soft band.
   * Neither metric should fire.
   */
  @Test
  public void assertSpaceAvailabilityFiresNoMetricWhenWellAboveBothLimits() throws Exception {
    HddsVolume volume = mock(HddsVolume.class);
    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 200L, 800L);
    when(volume.getCurrentUsage()).thenReturn(usage);
    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
    when(volume.getReportedFreeSpaceToSpare(1000L)).thenReturn(100L);
    when(volume.getVolumeInfoStats()).thenReturn(metrics);
    when(volume.toString()).thenReturn("mockVolume");

    // available(200) - hardSpare(30) = 170 > 50, and 200 - softSpare(100) = 100 > 50
    ContainerUtils.assertSpaceAvailability(1L, volume, 50);


    verify(metrics, never()).incNumWriteRequestsInSoftBandMinFreeSpace();
    verify(metrics, never()).incNumWriteRequestsRejectedHardMinFreeSpace();
  }

  /**
   * When VolumeInfoMetrics is null (volume not yet initialised or metrics disabled),
   * assertSpaceAvailability must not throw NullPointerException on the soft-band path.
   */
  @Test
  public void assertSpaceAvailabilityHandlesNullMetrics() {
    HddsVolume volume = mock(HddsVolume.class);
    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 100L, 900L);
    when(volume.getCurrentUsage()).thenReturn(usage);
    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
    when(volume.getReportedFreeSpaceToSpare(1000L)).thenReturn(100L);
    when(volume.getVolumeInfoStats()).thenReturn(null);
    when(volume.toString()).thenReturn("mockVolume");

    assertDoesNotThrow(() -> ContainerUtils.assertSpaceAvailability(1L, volume, 50));
  }

  /**
   * When VolumeInfoMetrics is null and the hard limit is violated,
   * assertSpaceAvailability must throw DISK_OUT_OF_SPACE without NullPointerException
   * when trying to increment the hard-reject metric.
   */
  @Test
  public void assertSpaceAvailabilityHandlesNullMetricsOnHardReject() {
    HddsVolume volume = mock(HddsVolume.class);
    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 100L, 900L);
    when(volume.getCurrentUsage()).thenReturn(usage);
    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
    when(volume.getVolumeInfoStats()).thenReturn(null);
    when(volume.toString()).thenReturn("mockVolume");

    // available(100) - hardSpare(30) = 70 < 80 → hard reject; null metrics must not NPE
    StorageContainerException ex = assertThrows(StorageContainerException.class,
        () -> ContainerUtils.assertSpaceAvailability(1L, volume, 80));
    assertEquals(Result.DISK_OUT_OF_SPACE, ex.getResult());
  }

  /**
   * Exact soft boundary: available(150) - softSpare(100) == sizeRequested(50).
   * The soft-band check is strict {@code <}, so at equality it must NOT fire.
   * The hard check: 150 - 30 = 120 > 50, so the write passes.
   */
  @Test
  public void assertSpaceAvailabilityNoSoftBandMetricAtExactSoftBoundary() throws Exception {
    HddsVolume volume = mock(HddsVolume.class);
    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 150L, 850L);
    when(volume.getCurrentUsage()).thenReturn(usage);
    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
    when(volume.getReportedFreeSpaceToSpare(1000L)).thenReturn(100L);
    when(volume.getVolumeInfoStats()).thenReturn(metrics);
    when(volume.toString()).thenReturn("mockVolume");

    // available(150) - softSpare(100) = 50 == sizeRequested(50): boundary is exclusive, no soft metric
    ContainerUtils.assertSpaceAvailability(1L, volume, 50);

    verify(metrics, never()).incNumWriteRequestsInSoftBandMinFreeSpace();
    verify(metrics, never()).incNumWriteRequestsRejectedHardMinFreeSpace();
  }

  /**
   * Zero-byte write: sizeRequested == 0 should always pass regardless of available space,
   * and must not fire any metric.
   */
  @Test
  public void assertSpaceAvailabilityNoMetricsForZeroSizeRequest() throws Exception {
    HddsVolume volume = mock(HddsVolume.class);
    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 100L, 900L);
    when(volume.getCurrentUsage()).thenReturn(usage);
    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
    when(volume.getReportedFreeSpaceToSpare(1000L)).thenReturn(100L);
    when(volume.getVolumeInfoStats()).thenReturn(metrics);
    when(volume.toString()).thenReturn("mockVolume");

    ContainerUtils.assertSpaceAvailability(1L, volume, 0);

    verify(metrics, never()).incNumWriteRequestsInSoftBandMinFreeSpace();
    verify(metrics, never()).incNumWriteRequestsRejectedHardMinFreeSpace();
  }

  /**
   * When hard spare == soft spare the soft band is effectively disabled.
   * A write that passes the hard check must not trigger the soft-band metric
   * even though the usable space is tight.
   * available(100) - spare(30) = 70 > 50: passes; soft band width = 0.
   */
  @Test
  public void assertSpaceAvailabilityNoSoftBandWhenHardAndSoftSpareAreEqual() throws Exception {
    HddsVolume volume = mock(HddsVolume.class);
    VolumeInfoMetrics metrics = mock(VolumeInfoMetrics.class);
    SpaceUsageSource.Fixed usage = new SpaceUsageSource.Fixed(1000L, 100L, 900L);
    when(volume.getCurrentUsage()).thenReturn(usage);
    when(volume.getFreeSpaceToSpare(1000L)).thenReturn(30L);
    when(volume.getReportedFreeSpaceToSpare(1000L)).thenReturn(30L);  // same as hard → no soft band
    when(volume.getVolumeInfoStats()).thenReturn(metrics);
    when(volume.toString()).thenReturn("mockVolume");

    ContainerUtils.assertSpaceAvailability(1L, volume, 50);

    verify(metrics, never()).incNumWriteRequestsInSoftBandMinFreeSpace();
    verify(metrics, never()).incNumWriteRequestsRejectedHardMinFreeSpace();
  }
}
