/*
 * 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.diskbalancer;

import static org.apache.hadoop.hdds.conf.ConfigTag.DATANODE;

import jakarta.annotation.Nonnull;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.hadoop.hdds.HddsUtils;
import org.apache.hadoop.hdds.conf.Config;
import org.apache.hadoop.hdds.conf.ConfigGroup;
import org.apache.hadoop.hdds.conf.ConfigTag;
import org.apache.hadoop.hdds.conf.ConfigType;
import org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.ContainerDataProto.State;
import org.apache.hadoop.hdds.protocol.proto.HddsProtos;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * This class contains configuration values for the DiskBalancer.
 */
@ConfigGroup(prefix = "hdds.datanode.disk.balancer")
public final class DiskBalancerConfiguration {
  private static final Logger LOG =
      LoggerFactory.getLogger(DiskBalancerConfiguration.class);

  /**
   * Default value for {@code hdds.datanode.disk.balancer.container.states}: {@value}
   * (CLOSED and QUASI_CLOSED containers may be disk-balanced).
   */
  public static final String DEFAULT_CONTAINER_STATES = "CLOSED,QUASI_CLOSED";

  @Config(key = "hdds.datanode.disk.balancer.info.dir", type = ConfigType.STRING,
      defaultValue = "", tags = {ConfigTag.DISKBALANCER},
      description = "The path where datanode diskBalancer's conf is to be " +
          "written to. if this property is not defined, ozone will fall " +
          "back to use metadata directory instead.")
  private String infoDir;

  @Config(key = "hdds.datanode.disk.balancer.volume.density.threshold.percent", type = ConfigType.DOUBLE,
      defaultValue = "10", tags = {ConfigTag.DISKBALANCER},
      description = "Threshold is a percentage in the range of 0 to 100. A " +
          "datanode is considered balanced if for each volume, the " +
          "utilization of the volume(used space to capacity ratio) differs" +
          " from the utilization of the datanode(used space to capacity ratio" +
          " of the entire datanode) no more than the threshold.")
  private double threshold = 10d;

  @Config(key = "hdds.datanode.disk.balancer.max.disk.throughputInMBPerSec", type = ConfigType.LONG,
      defaultValue = "10", tags = {ConfigTag.DISKBALANCER},
      description = "The max balance speed.")
  private long diskBandwidthInMB = 10;

  @Config(key = "hdds.datanode.disk.balancer.parallel.thread", type = ConfigType.INT,
      defaultValue = "5", tags = {ConfigTag.DISKBALANCER},
      description = "The max parallel balance thread count.")
  private int parallelThread = 5;

  @Config(key = "hdds.datanode.disk.balancer.should.run.default",
      defaultValue = "false",
      type = ConfigType.BOOLEAN,
      tags = { DATANODE, ConfigTag.DISKBALANCER},
      description =
          "If DiskBalancer fails to get information from diskbalancer.info, " +
              "it will choose this value to decide if this service should be " +
              "running."
  )
  private boolean diskBalancerShouldRun = false;

  @Config(key = "hdds.datanode.disk.balancer.service.interval",
      defaultValue = "60s",
      type = ConfigType.TIME,
      tags = { DATANODE, ConfigTag.DISKBALANCER},
      description = "Time interval of the Datanode DiskBalancer service. " +
          "The Datanode will check the service periodically and update " +
          "the config and running status for DiskBalancer service. " +
          "Unit could be defined with postfix (ns,ms,s,m,h,d). "
  )
  private long diskBalancerInterval = Duration.ofSeconds(60).toMillis();

  @Config(key = "hdds.datanode.disk.balancer.service.timeout",
      defaultValue = "300s",
      type = ConfigType.TIME,
      tags = { DATANODE, ConfigTag.DISKBALANCER},
      description = "Timeout for the Datanode DiskBalancer service. "
          + "Unit could be defined with postfix (ns,ms,s,m,h,d). "
  )
  private long diskBalancerTimeout = Duration.ofSeconds(300).toMillis();

  public static final String HDDS_DATANODE_DISKBALANCER_CONTAINER_CHOOSING_POLICY =
      "hdds.datanode.disk.balancer.container.choosing.policy";

  @Config(key = HDDS_DATANODE_DISKBALANCER_CONTAINER_CHOOSING_POLICY, type = ConfigType.CLASS,
      defaultValue = "org.apache.hadoop.ozone.container.diskbalancer.policy" +
          ".DefaultContainerChoosingPolicy",
      tags = {ConfigTag.DISKBALANCER},
      description = "The policy for selecting source/destination volumes and " +
          "containers to move for disk balancing.")
  private Class<?> containerChoosingPolicyClass;

  @Config(key = "hdds.datanode.disk.balancer.stop.after.disk.even",
      type = ConfigType.BOOLEAN,
      defaultValue = "true",
      tags = {ConfigTag.DISKBALANCER},
      description = "If true, the DiskBalancer will automatically stop once disks are balanced.")
  private boolean stopAfterDiskEven = true;

  @Config(key = "hdds.datanode.disk.balancer.replica.deletion.delay",
      defaultValue = "5m",
      type = ConfigType.TIME,
      tags = { DATANODE, ConfigTag.DISKBALANCER },
      description = "The delay after a container is successfully moved from source volume to " +
          "destination volume before the source container replica is deleted. " +
          "Unit could be defined with postfix (ns,ms,s,m,h,d).")
  private long replicaDeletionDelay = Duration.ofMinutes(5).toMillis();

  private static final String HDDS_DATANODE_DISK_BALANCER_CONTAINER_STATES =
      "hdds.datanode.disk.balancer.container.states";

  @Config(key = HDDS_DATANODE_DISK_BALANCER_CONTAINER_STATES,
      defaultValue = DEFAULT_CONTAINER_STATES,
      type = ConfigType.STRING,
      tags = { DATANODE, ConfigTag.DISKBALANCER },
      description = "Container lifecycle state names (CLOSED, QUASI_CLOSED) that " +
          "are eligible to be moved between disks on a datanode. " +
          "Names must match the enum exactly (uppercase). The default includes CLOSED and QUASI_CLOSED; " +
          "additional states can be enabled by adding the states here.")
  private String containerStates = DEFAULT_CONTAINER_STATES;

  public DiskBalancerConfiguration(Double threshold,
      Long bandwidthInMB,
      Integer parallelThread,
      Boolean stopAfterDiskEven) {
    if (threshold != null) {
      this.threshold = threshold;
    }
    if (bandwidthInMB != null) {
      this.diskBandwidthInMB = bandwidthInMB;
    }
    if (parallelThread != null) {
      this.parallelThread = parallelThread;
    }
    if (stopAfterDiskEven != null) {
      this.stopAfterDiskEven = stopAfterDiskEven;
    }
  }

  public DiskBalancerConfiguration() {
  }

  public String getDiskBalancerInfoDir() {
    return infoDir;
  }

  public boolean getDiskBalancerShouldRun() {
    return diskBalancerShouldRun;
  }

  public void setDiskBalancerShouldRun(boolean shouldRun) {
    this.diskBalancerShouldRun = shouldRun;
  }

  public Duration getDiskBalancerInterval() {
    return Duration.ofMillis(diskBalancerInterval);
  }

  public void setDiskBalancerInterval(Duration duration) {
    this.diskBalancerInterval = duration.toMillis();
  }

  public Duration getDiskBalancerTimeout() {
    return Duration.ofMillis(diskBalancerTimeout);
  }

  public void setDiskBalancerTimeout(Duration duration) {
    this.diskBalancerTimeout = duration.toMillis();
  }

  public Class<?> getContainerChoosingPolicyClass() {
    return containerChoosingPolicyClass;
  }

  public boolean isStopAfterDiskEven() {
    return stopAfterDiskEven;
  }
  
  public void setStopAfterDiskEven(boolean stopAfterDiskEven) {
    this.stopAfterDiskEven = stopAfterDiskEven;
  }

  /**
   * Gets the replica deletion delay in milliseconds.
   *
   * @return delay in milliseconds before source replica is deleted after move
   */
  public long getReplicaDeletionDelay() {
    return replicaDeletionDelay;
  }

  /**
   * Gets the threshold value for DiskBalancer.
   *
   * @return percentage value in the range 0 to 100
   */
  public double getThreshold() {
    return threshold;
  }

  public double getThresholdAsRatio() {
    return threshold / 100;
  }

  /**
   * Sets the threshold value for Disk Balancer.
   *
   * @param threshold a percentage value in the range (0 to 100) both exclusive
   */
  public void setThreshold(double threshold) {
    if (threshold <= 0d || threshold >= 100d) {
      throw new IllegalArgumentException(
          "Threshold must be a percentage(double) in the range 0 to 100 both exclusive.");
    }
    this.threshold = threshold;
  }

  /**
   * Gets the disk bandwidth value for Disk Balancer.
   *
   * @return max disk bandwidth per second
   */

  public long getDiskBandwidthInMB() {
    return diskBandwidthInMB;
  }

  /**
   * Sets the disk bandwidth value for Disk Balancer.
   *
   * @param diskBandwidthInMB the bandwidth to control balance speed
   */
  public void setDiskBandwidthInMB(long diskBandwidthInMB) {
    if (diskBandwidthInMB <= 0L) {
      throw new IllegalArgumentException(
          "diskBandwidthInMB must be a value larger than 0.");
    }
    this.diskBandwidthInMB = diskBandwidthInMB;
  }

  /**
   * Gets the parallel thread for Disk Balancer.
   *
   * @return parallel thread
   */
  public int getParallelThread() {
    return parallelThread;
  }

  /**
   * Sets the parallel thread for Disk Balancer.
   *
   * @param parallelThread the parallel thread count
   */
  public void setParallelThread(int parallelThread) {
    if (parallelThread <= 0) {
      throw new IllegalArgumentException(
          "parallelThread must be a value larger than 0.");
    }
    this.parallelThread = parallelThread;
  }

  public String getContainerStates() {
    return containerStates;
  }

  /**
   * Sets the comma-separated container state names and validates them immediately.
   * <p>
   * Ozone configuration injection may set the raw container-states string without invoking this
   * setter; {@link #getMovableContainerStates()} still parses the current field value on read.
   *
   * @param containerStates comma-separated {@link State} names (case-sensitive, uppercase);
   *     whitespace allowed around commas and tokens
   * @throws IllegalArgumentException if blank, empty after parsing tokens, or unknown state
   */
  public void setContainerStates(String containerStates) {
    parseMovableContainerStates(containerStates);
    this.containerStates = containerStates;
  }

  /**
   * Container lifecycle states eligible for disk balancing, derived from {@link #getContainerStates()}:
   * same rules as {@link #setContainerStates(String)}.
   *
   * @return unmodifiable non-empty set
   */
  public Set<State> getMovableContainerStates() {
    return parseMovableContainerStates(this.containerStates);
  }

  /**
   * Parses and validates {@code hdds.datanode.disk.balancer.container.states}.
   */
  private static Set<State> parseMovableContainerStates(String raw) {
    if (StringUtils.isBlank(raw)) {
      throw new IllegalArgumentException(HDDS_DATANODE_DISK_BALANCER_CONTAINER_STATES + " must not be empty.");
    }
    Set<State> states = new HashSet<>();
    for (String part : raw.split(",")) {
      String name = part.trim();
      if (name.isEmpty()) {
        continue;
      }
      State state;
      try {
        state = State.valueOf(name);
      } catch (IllegalArgumentException ex) {
        if (wouldMatchContainerStateIfUppercased(name)) {
          throw new IllegalArgumentException(
              "Invalid container state '" + name + "': use uppercase enum names "
                  + "(e.g. CLOSED, QUASI_CLOSED). Valid names are: "
                  + Arrays.toString(State.values()),
              ex);
        }
        throw new IllegalArgumentException(
            "Invalid container state '" + name + "' in " + HDDS_DATANODE_DISK_BALANCER_CONTAINER_STATES + ". "
                + "Valid names are: " + Arrays.toString(State.values()),
            ex);
      }
      if (HddsUtils.isOpenToWriteState(state) || state == State.CLOSING || state == State.DELETED) {
        throw new IllegalArgumentException("State " + name + " is not movable.");
      }
      states.add(State.valueOf(name));
    }
    if (states.isEmpty()) {
      throw new IllegalArgumentException(
          HDDS_DATANODE_DISK_BALANCER_CONTAINER_STATES + " must list at least one valid state.");
    }
    return Collections.unmodifiableSet(states);
  }

  private static boolean wouldMatchContainerStateIfUppercased(String name) {
    String upper = name.toUpperCase(Locale.ROOT);
    if (upper.equals(name)) {
      return false;
    }
    try {
      State.valueOf(upper);
      return true;
    } catch (IllegalArgumentException e) {
      return false;
    }
  }

  @Override
  public String toString() {
    return String.format("Disk Balancer Configuration values:%n" +
            "%-50s %s%n" +
            "%-50s %s%n" +
            "%-50s %s%n" +
            "%-50s %s%n" +
            "%-50s %s%n" +
            "%-50s %s%n",
            "Key", "Value",
        "Threshold", threshold, "Max disk bandwidth", diskBandwidthInMB,
        "Parallel Thread", parallelThread, "Stop After Disk Even", stopAfterDiskEven,
        "Container states", containerStates);
  }

  public HddsProtos.DiskBalancerConfigurationProto.Builder toProtobufBuilder() {
    HddsProtos.DiskBalancerConfigurationProto.Builder builder =
        HddsProtos.DiskBalancerConfigurationProto.newBuilder();

    builder.setThreshold(threshold)
        .setDiskBandwidthInMB(diskBandwidthInMB)
        .setParallelThread(parallelThread)
        .setStopAfterDiskEven(stopAfterDiskEven)
        .setContainerStates(containerStates);
    return builder;
  }

  /**
   * Merges proto configuration into an existing configuration object.
   * Only fields present in the proto will be updated.
   *
   * @param newConfigProto the proto containing fields to update
   * @param existingConfig the existing configuration to merge into
   * @return the updated configuration object
   */
  public static DiskBalancerConfiguration updateFromProtobuf(
      @Nonnull HddsProtos.DiskBalancerConfigurationProto newConfigProto,
      @Nonnull DiskBalancerConfiguration existingConfig) {
    if (newConfigProto.hasThreshold()) {
      existingConfig.setThreshold(newConfigProto.getThreshold());
    }
    if (newConfigProto.hasDiskBandwidthInMB()) {
      existingConfig.setDiskBandwidthInMB(newConfigProto.getDiskBandwidthInMB());
    }
    if (newConfigProto.hasParallelThread()) {
      existingConfig.setParallelThread(newConfigProto.getParallelThread());
    }
    if (newConfigProto.hasStopAfterDiskEven()) {
      existingConfig.setStopAfterDiskEven(newConfigProto.getStopAfterDiskEven());
    }
    if (newConfigProto.hasContainerStates()) {
      existingConfig.setContainerStates(newConfigProto.getContainerStates());
    }
    return existingConfig;
  }
}

