/*
 * RiskScape™ Copyright New Zealand Institute for Earth Science Limited
 * (Earth Sciences New Zealand) is distributed for research purposes only
 * under the terms of AGPLv3.
 *
 * RiskScape™ Copyright 2026 New Zealand Institute for Earth Science
 * Limited (Earth Sciences New Zealand). All rights reserved. Source code
 * available under the AGPLv3.
 * 
 * This program is free software: you can redistribute it and/or modify it under
 *  the terms of the GNU Affero General Public License as published by the Free
 *  Software Foundation, either version 3 of the License, or (at your option) any
 *  later version.
 * 
 * This program is distributed for RESEARCH PURPOSES ONLY, in the hope that it will
 * be useful for research and education initiatives.
 * 
 * If you are not a researcher, or you are a researcher who wishes to use this
 * program on terms other than AGPLv3 (including those who wish to restrict the
 * distribution of any source code created using this program), please contact:
 * https://riskscape.org.nz
 * 
 * This program is distributed WITHOUT ANY WARRANTY; without even the implied
 * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Affero General Public License for more details.  You should have received a copy
 * of the GNU Affero General Public License along with this program.  If not, see
 * <http://www.gnu.org/licenses/>.
 * 
 * By way of summary only, under the AGPLv3:
 *     • Permissions of this strongest copyleft license are conditioned
 *       on making available complete source code of licensed works and
 *       modifications, which include larger works using a licensed work,
 *       under the same license.
 *     • Copyright and license notices must be preserved.
 *     • Contributors provide an express grant of patent rights.
 *     • When a modified version is used to provide a service over a
 *       network, the complete source code of the modified version must be made
 *       available.
 */
package nz.org.riskscape.engine.types;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;

import com.google.common.base.Objects;
import com.google.common.collect.Lists;

import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;

import nz.org.riskscape.engine.typexp.AST;
import nz.org.riskscape.engine.typexp.AST.ComplexType;
import nz.org.riskscape.engine.typexp.ComplexTypeConstructor;
import nz.org.riskscape.engine.typexp.TypeBuilder;

import lombok.Getter;

/**
 * Riskscape type that maps a set of numbers to a set of well-known strings.  A bit like {@link WithinSet}  except
 * that it will coerce any {@link Number} in to a {@link String} within the set, based on the {@link String}'s position
 * within the list
 *
 * ```
 * enumeration = Enumeration.oneBased("cat", "dog", "Bear");
 * enumeration.coerce(1) // gives "cat"
 * enumeration.coerce(4) // raises a coercion exception - outside of range
 * enumeration.coerce("dog") // gives "dog"
 * enumeration.coerce("fox") // raises a coercion exception - fox not in set
 * enumeration.coerce("bear") // gives "BEAR" - comparisons are always lower cased to avoid case consistency issues
 *
 * ```
 *
 */
@RequiredArgsConstructor(access=AccessLevel.PRIVATE)
public class Enumeration implements Type {

  public static final ComplexTypeConstructor TYPE_CONSTRUCTOR = (TypeBuilder typeBuilder, ComplexType type) -> {
    List<AST> args = type.args();
    List<Object> coerced = typeBuilder.expectConstantsOfType(type, Types.TEXT, args, 0);
    return Enumeration.oneBased(coerced.toArray(new String[0]));
  };

  public static final TypeInformation TYPE_INFORMATION = new TypeInformation(
      "enum",
      Enumeration.class,
      String.class,
      TYPE_CONSTRUCTOR
  );

  /**
   * Construct a Riskscape Enumeration from the given array of strings.  The position they are at in the array is that
   * string's ordinal value, starting from zero.  Null values are supported, and are considered gaps in the enumeration.
   * Attempts to coerce numbers that don't map to a non null value will raise a {@link CoercionException}.
   * @throws IllegalArgumentException if there are duplicate (case insensitive) values.
   */
  public static Enumeration zeroBased(String... values) {
    return fromValues(false, values);
  }

  /**
   * Construct a Riskscape Enumeration from the given array of strings.  The position they are at in the array is that
   * string's ordinal value, starting from 1.  Null values are supported, and are considered gaps in the enumeration.
   * Attempts to coerce numbers that don't map to a non null value will raise a {@link CoercionException}.
   * @throws IllegalArgumentException if there are duplicate (case insensitive) values.
   */
  public static Enumeration oneBased(String... values) {
    return fromValues(true, values);
  }

  private static Enumeration fromValues(boolean oneBased, String... values) {
    String[] adjustedValues = new String[values.length];

    for (int i = 0; i < values.length; i++) {
      String allowedValue = values[i];
      // TBD allow gaps?
      if (allowedValue == null) {
        continue;
      }

      String lowered = values[i].toLowerCase();
      for (int j = 0; j < i; j++) {
        String alreadyIn = adjustedValues[j];
        if (alreadyIn != null && alreadyIn.equals(lowered)) {
          throw new IllegalArgumentException("Enum value is in values more than once - " + lowered);
        }
      }

      adjustedValues[i] = lowered;
    }

    return new Enumeration(oneBased, values, adjustedValues);
  }

  @Getter
  private final boolean oneBased;
  @Getter
  private final String[] values;
  private final String[] lowerCasedValues;

  @Override
  public Object coerce(Object value) throws CoercionException {
    checkForNull(value);
    if (value instanceof String) {
      //Some data sources are not typed such as CSV, all columns are text type.
      //So if the value is a String, check if it only contains digits and if so make value an Integer
      String strValue = ((String)value).trim();
      if (strValue.matches("^[0-9]+$")) {
        try {
          value = java.lang.Integer.valueOf(strValue);
        } catch (NumberFormatException nfe) {}
      }
    }

    final int offset = oneBased ? -1 : 0;
    if (value instanceof String) {
      for (int i = 0; i < values.length; i++) {
        if (((String) value).toLowerCase().equals(lowerCasedValues[i])) {
          return values[i];
        }
      }
      StringBuilder formatted = new StringBuilder();
      for (int i = 0; i < values.length; i++) {
        if (formatted.length() > 0) {
          formatted.append(", ");
        }
        String string = values[i];
        if (string == null) {
          continue;
        }
        formatted.append(String.format("%d:%s", i, string));
      }
      throw new CoercionException(
          value,
          this,
          "Given string '%s' does not belong to this enumeration's set of values (%s)",
          value,
          formatted);
    } else if (value instanceof Number) {
      int intValue = ((Number) value).intValue();
      int offsetValue = intValue + offset;
      if (offsetValue < 0 || offsetValue >= values.length) {
        throw new CoercionException(
            value,
            this,
            "%d is out of range (%d <= x < %d)", value, 0 - offset, values.length - offset);
      }

      String lookedUpValue = values[offsetValue];
      if (lookedUpValue == null) {
        throw new CoercionException(value, this, "%d maps to a gap in this enum", value);
      }
      return lookedUpValue;
    } else {
      throw new CoercionException(value, this, "Value '%s' is not a number or a string", this);
    }
  }

  @Override
  public Class<?> internalType() {
    return java.lang.String.class;
  }

  @Override
  public String toString() {
    return String.format("Enumeration(values=%s)", Lists.newArrayList(values));
  }

  @Override
  public int estimateSize(Object entry) {
    return Types.INTEGER.estimateSize(entry);
  }

  @Override
  public void toBytes(DataOutputStream os, Object toWrite) throws IOException {
    if (toWrite instanceof Number) {
      os.writeInt(((Number) toWrite).intValue());
    } else {
      String stringToWrite = (String) toWrite;
      for (int i = 0; i < lowerCasedValues.length; i++) {
        String fromSet = lowerCasedValues[i];
        if (fromSet != null && fromSet.equals(stringToWrite.toLowerCase())) {
          os.writeInt(i + (oneBased ? 1 : 0));
          return;
        }
      }

      throw new CoercionException(stringToWrite, this, "Value is not in enum, can not write");
    }
  }

  @Override
  public Object fromBytes(DataInputStream in) throws IOException {
    return coerce(in.readInt());
  }

  @Override
  public boolean equals(Object obj) {
    if (obj instanceof Enumeration) {
      Enumeration rhs = (Enumeration) obj;
      return rhs.oneBased == this.oneBased && Arrays.equals(rhs.values, this.values);
    } else {
      return false;
    }
  }

  @Override
  public Struct asStruct() {
    return Struct.of("value", this);
  }

  @Override
  public int hashCode() {
    return Objects.hashCode(this.oneBased, this.values);
  }

  @Override
  public <T, U> U visit(TypeVisitor<T, U> tv, T data) {
    return tv.atomicType(this, data);
  }

}
