package com.oguerreiro.resilient.activities.inputDataImporter.importers;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;

import org.springframework.context.ApplicationContext;

import com.oguerreiro.resilient.activity.AbstractTask;
import com.oguerreiro.resilient.activity.GenericGuide;
import com.oguerreiro.resilient.activity.GenericResult;
import com.oguerreiro.resilient.activity.WorkResultEnum;
import com.oguerreiro.resilient.domain.InputData;
import com.oguerreiro.resilient.domain.InputDataUpload;
import com.oguerreiro.resilient.domain.InputDataUploadLog;
import com.oguerreiro.resilient.domain.Organization;
import com.oguerreiro.resilient.domain.Unit;
import com.oguerreiro.resilient.domain.UnitConverter;
import com.oguerreiro.resilient.domain.Variable;
import com.oguerreiro.resilient.domain.VariableClass;
import com.oguerreiro.resilient.domain.VariableUnits;
import com.oguerreiro.resilient.domain.enumeration.DataSourceType;
import com.oguerreiro.resilient.domain.enumeration.InputMode;
import com.oguerreiro.resilient.domain.enumeration.UnitValueType;
import com.oguerreiro.resilient.error.ExpressionEvaluationException;
import com.oguerreiro.resilient.error.ImporterOrganizationCodeNotFoundException;
import com.oguerreiro.resilient.error.ImporterOrganizationNotAccessibleException;
import com.oguerreiro.resilient.error.ImporterVariableValueException;
import com.oguerreiro.resilient.error.InventoryImportVariableClassNotFoundException;
import com.oguerreiro.resilient.error.InventoryImportVariableInvalidValueForUnitTypeException;
import com.oguerreiro.resilient.error.InventoryImportVariableNotAllowedForDatafileException;
import com.oguerreiro.resilient.error.InventoryImportVariableNotAllowedForInputException;
import com.oguerreiro.resilient.error.InventoryImportVariableNotFoundException;
import com.oguerreiro.resilient.error.InventoryImportVariableUnitConverterFormulaException;
import com.oguerreiro.resilient.error.InventoryImportVariableUnitConverterNotFoundException;
import com.oguerreiro.resilient.error.InventoryImportVariableUnitNotAllowedException;
import com.oguerreiro.resilient.error.InventoryImportVariableUnitNotFoundException;
import com.oguerreiro.resilient.error.InventoryImportYearOfDataDontMatchRuntimeException;
import com.oguerreiro.resilient.error.ResilientException;
import com.oguerreiro.resilient.error.UnitConverterExpressionEvaluationRuntimeException;
import com.oguerreiro.resilient.expression.ExpressionUtils;
import com.oguerreiro.resilient.expression.UnitConverterExpressionContext;
import com.oguerreiro.resilient.expression.UnitConverterExpressionFunctions;
import com.oguerreiro.resilient.repository.EmissionFactorRepository;
import com.oguerreiro.resilient.repository.InputDataRepository;
import com.oguerreiro.resilient.repository.InputDataUploadLogRepository;
import com.oguerreiro.resilient.repository.OrganizationRepository;
import com.oguerreiro.resilient.repository.UnitRepository;
import com.oguerreiro.resilient.repository.VariableRepository;

public abstract class AbstractGlobalPOIFileImporterTaskImpl extends
    AbstractTask<GenericResult, GenericGuide<InputDataUpload>, InputDataImporterTask> implements InputDataImporterTask {
  private final VariableRepository variableRepository;
  private final InputDataUploadLogRepository inputDataUploadLogRepository;
  private final InputDataRepository inputDataRepository;
  private final UnitRepository unitRepository;
  private final EmissionFactorRepository emissionFactorRepository;
  private final ApplicationContext applicationContext;
  private final OrganizationRepository organizationRepository;

  public AbstractGlobalPOIFileImporterTaskImpl(VariableRepository variableRepository,
      InputDataUploadLogRepository inputDataUploadLogRepository, InputDataRepository inputDataRepository,
      UnitRepository unitRepository, EmissionFactorRepository emissionFactorRepository,
      OrganizationRepository organizationRepository, ApplicationContext applicationContext) {
    this.variableRepository = variableRepository;
    this.inputDataUploadLogRepository = inputDataUploadLogRepository;
    this.inputDataRepository = inputDataRepository;
    this.unitRepository = unitRepository;
    this.emissionFactorRepository = emissionFactorRepository;
    this.applicationContext = applicationContext;
    this.organizationRepository = organizationRepository;
  }

  @Override
  public GenericResult doTask(final GenericGuide<InputDataUpload> guide) {
    InputDataUpload inputDataUpload = guide.getActivityDomain();
    InputStream fileStream = new ByteArrayInputStream(inputDataUpload.getDataFile());

    String currentProgressTask = guide.getActivityProgressDescriptor().getProgressTask();
    Integer currentProgressPercentage = guide.getActivityProgressDescriptor().getProgressPercentage();

    try (GlobalPOIFileImporter importer = new GlobalPOIFileImporter(fileStream, null)) {

      importer.importPOI(new GlobalPOIFileImporterCallback() {

        @Override
        public void callback(String sheetName, int rowNumber, String varCode, String varClassCode,
            String varClassDescription, String varUnit, String varUnitCode, String varValue,
            String varOrganizationCode) {
          //Update progress description
          guide.getActivityProgressDescriptor().updateProgress(currentProgressPercentage,
              currentProgressTask + " [Sheet '" + sheetName + "']");

          try {
            AbstractGlobalPOIFileImporterTaskImpl.this.process(guide, sheetName, rowNumber, varCode, varClassCode,
                varClassDescription, varUnit, varUnitCode, varValue, varOrganizationCode);
          } catch (Throwable t) {
            reportErrorInNewTransaction(inputDataUpload, guide.getUsername(), t);
          }
        }

        @Override
        public void validateCallBack(Integer year) {
          // Validate if year matches begin and end period date
          int beginYear = inputDataUpload.getPeriod().getBeginDate().getYear();
          int endYear = inputDataUpload.getPeriod().getEndDate().getYear();
          if (!(beginYear == endYear && beginYear == year)) {
            RuntimeException t = new InventoryImportYearOfDataDontMatchRuntimeException(endYear, year);
            reportErrorInNewTransaction(inputDataUpload, guide.getUsername(), t);
            throw t;
          }
        }

      });
    } catch (Throwable e) {
      return new GenericResult(WorkResultEnum.FAILED);
    }

    return new GenericResult(WorkResultEnum.COMPLETED);
  }

  private void process(GenericGuide<InputDataUpload> guide, String sheetName, int rowNumber, String varCode,
      String varClassCode, String varClassDescription, String varUnit, String varUnitCode, String varValue,
      String varOrganizationCode) throws ResilientException {

    // Validations ========================

    // Variable exists ?
    Variable variable = variableRepository.findOneByCodeWithToOneRelationships(varCode).orElse(null);
    if (variable == null) {
      throw new InventoryImportVariableNotFoundException(sheetName, rowNumber, varCode);
    }

    // Variable is valid? (Input==TRUE && inputMode IN (ALL; DATAFILE))
    if (!variable.getInput()) {
      throw new InventoryImportVariableNotAllowedForInputException(varCode);
    }

    if (!List.of(InputMode.ANY, InputMode.DATAFILE).contains(variable.getInputMode())) {
      throw new InventoryImportVariableNotAllowedForDatafileException(varCode);
    }

    // Class exists?
    String varClassName = null;
    if (varClassCode != null && varClassCode.length() > 0) {
      if (variable.getVariableClassType() == null)
        throw new InventoryImportVariableClassNotFoundException(sheetName, rowNumber, varCode, varClassCode);

      List<VariableClass> classes = variable.getVariableClassType().getVariableClasses().stream().filter(
          c -> c.getCode().equals(varClassCode)).toList();
      if (classes.isEmpty())
        throw new InventoryImportVariableClassNotFoundException(sheetName, rowNumber, varCode, varClassCode);

      if (classes.size() > 1)
        throw new RuntimeException("More than one class found for code [" + varClassCode + "]");

      VariableClass varClass = classes.get(0);
      varClassName = varClass.getName();
    }

    // Unit exists?
    Unit sourceUnit = unitRepository.findOneByCodeOrSymbol(varUnitCode.toUpperCase(), varUnitCode).orElse(null);
    if (sourceUnit == null) {
      throw new InventoryImportVariableUnitNotFoundException(sheetName, rowNumber, varCode, varUnitCode);
    }

    // And its allowed for this variable?
    if (!variable.isUnitAllowed(sourceUnit)) {
      throw new InventoryImportVariableUnitNotAllowedException(sheetName, rowNumber, varCode, varUnitCode);
    }

    // Value must be of the type of the InputMode
    if (!variable.getBaseUnit().getUnitType().getValueType().isValueValid(varValue)) {
      throw new InventoryImportVariableInvalidValueForUnitTypeException(sheetName, rowNumber, varCode, varValue,
          variable.getBaseUnit().getUnitType().getValueType().toString());
    }

    // Find Organization & validate if the current selected organization has access to it
    Organization owner = this.organizationRepository.findOneByCode(varOrganizationCode).orElse(null);
    if (owner == null) {
      //Organization code not found
      throw new ImporterOrganizationCodeNotFoundException(sheetName, rowNumber, varOrganizationCode);
    }

    //Validate if the owner is accessible by the InputDataUpload.
    if (!owner.equals(guide.getActivityDomain().getOwner()) && !owner.isChildOf(guide.getActivityDomain().getOwner())) {
      //Can't import this value, because the owner it's not accesible by the declared InputDataUpload.owner. Only hierarchical organizations can import data from others
      throw new ImporterOrganizationNotAccessibleException(sheetName, rowNumber, varCode, varOrganizationCode,
          guide.getActivityDomain().getOwner().getCode());
    }

    // Insert InputData value
    createAndInsertInputData(guide, sheetName, rowNumber, variable, varClassCode, varClassName, varClassDescription,
        sourceUnit, varValue, owner);
  }

  private void createAndInsertInputData(GenericGuide<InputDataUpload> guide, String sheetName, int rowNumber,
      Variable variable, String varClassCode, String varClassName, String varClassDescription, Unit sourceUnit,
      String varValue, Organization owner) throws InventoryImportVariableUnitConverterNotFoundException,
      InventoryImportVariableUnitConverterFormulaException, ImporterVariableValueException {
    InputDataUpload inputDataUpload = guide.getActivityDomain();

    // Convert value from a String to target value type
    BigDecimal sourceValue;
    BigDecimal variableValue = null;
    String stringValue = null;
    try {
      if (sourceUnit.getUnitType().getValueType().equals(UnitValueType.STRING)) {
        sourceValue = BigDecimal.ZERO;
        variableValue = BigDecimal.ZERO;
        stringValue = varValue;
      } else if (sourceUnit.getUnitType().getValueType().equals(UnitValueType.DECIMAL)) {
        sourceValue = this.convertToBigDecimal(varValue);
      } else if (sourceUnit.getUnitType().getValueType().equals(UnitValueType.BOOLEAN)) {
        // By convention. 0=FALSE; 1=TRUE
        sourceValue = this.convertToBooleanAsBigDecimal(varValue);
      } else {
        throw new RuntimeException("Unexpected UnitValueType : " + sourceUnit.getUnitType().getValueType().toString());
      }
    } catch (Throwable t) {
      throw new ImporterVariableValueException(sheetName, rowNumber, variable.getCode(),
          sourceUnit.getUnitType().getValueType().toString(), varValue);
    }

    // The value needs conversion ? (only if valurType is NOT = STRING)
    if (!sourceUnit.getUnitType().getValueType().equals(UnitValueType.STRING)) {
      if (sourceUnit.equals(variable.getBaseUnit())) {
        // Its the same. No need to convert value.
        variableValue = sourceValue;
      } else {
        // Conversion is needed. The units are both of the same UniType ?
        if (sourceUnit.getUnitType().equals(variable.getBaseUnit().getUnitType())) {
          // Same UnitType, the conversion is just a scale conversion
          // (sourceValue*CR(target unit)/CR(source unit))
          variableValue = sourceValue.multiply(
              variable.getBaseUnit().getConvertionRate().divide(sourceUnit.getConvertionRate()));
        } else {
          // This is a more complex conversion. Needs a UnitConverter
          Optional<VariableUnits> result = variable.getVariableUnits().stream().filter(
              varUnit -> varUnit.getUnit().equals(sourceUnit)).findFirst();

          if (!result.isPresent()) {
            throw new InventoryImportVariableUnitConverterNotFoundException(sheetName, rowNumber, variable.getCode(),
                sourceUnit.getCode(), variable.getBaseUnit().getCode());
          }

          // Evaluate formula to convert the value
          UnitConverterExpressionFunctions expressionFunctions = new UnitConverterExpressionFunctions(
              inputDataUpload.getPeriod());
          applicationContext.getAutowireCapableBeanFactory().autowireBean(expressionFunctions); //This is CRUCIAL, Without this there will be no dependency injection

          UnitConverterExpressionContext context = UnitConverterExpressionContext.create(emissionFactorRepository,
              inputDataUpload.getPeriod().getBeginDate().getYear(), sourceValue, sourceUnit, variable.getBaseUnit(),
              variable, varClassCode);
          context.setExpressionFunctions(expressionFunctions);

          VariableUnits variableUnit = result.orElseThrow(() -> new RuntimeException("Value not found"));
          UnitConverter unitConverter = variableUnit.getUnitConverter();
          String convertionFormula = unitConverter.getConvertionFormula();

          try {
            Object convertedValue = ExpressionUtils.evalExpression(convertionFormula, context);
            variableValue = this.toBigDecimal(convertedValue);
          } catch (ExpressionEvaluationException e) {
            throw new UnitConverterExpressionEvaluationRuntimeException(variable.getCode(), sourceUnit.getCode(),
                variable.getBaseUnit().getCode(), sourceValue.toString(), e);
          }
        }
      }
    }

    InputData inputData = new InputData();
    inputData.owner(owner).period(inputDataUpload.getPeriod()).variable(variable).sourceType(
        DataSourceType.FILE).sourceInputDataUpload(inputDataUpload).sourceUnit(sourceUnit).sourceValue(
            sourceValue).variableValue(variableValue).imputedValue(variableValue).stringValue(stringValue).creationDate(
                Instant.now()).creationUsername(guide.getUsername()).changeDate(Instant.now()).changeUsername(
                    guide.getUsername()).unit(variable.getBaseUnit()).variableClassCode(varClassCode).variableClassName(
                        varClassName);

    inputDataRepository.saveInNewTransaction(inputData);
  }

  private BigDecimal convertToBigDecimal(String value) {
    try {
      return new BigDecimal(value);
    } catch (Throwable t) {
      //Unable to convert value to number
      throw t;
    }
  }

  private BigDecimal convertToBooleanAsBigDecimal(String value) {
    List<String> validValuesTrue = Arrays.asList("1", "TRUE", "YES", "SIM", "VERDADEIRO");
    List<String> validValuesFalse = Arrays.asList("0", "FALSE", "NO", "NAO", "NÃO", "FALSO");
    String auxValue = value.trim().toUpperCase();

    if (validValuesTrue.contains(value)) {
      return BigDecimal.ONE;
    }
    if (validValuesFalse.contains(value)) {
      return BigDecimal.ZERO;
    }

    throw new RuntimeException("Invalid boolean value.");
  }

  private void reportErrorInNewTransaction(InputDataUpload inputDataUpload, String username, Throwable error) {
    InputDataUploadLog log = new InputDataUploadLog();

    String localizedMessage = getLocalizedMessage(error);

    log.inputDataUpload(inputDataUpload).creationDate(Instant.now()).creationUsername(username).logMessage(
        localizedMessage);

    this.inputDataUploadLogRepository.saveInNewTransaction(log);
  }

  private BigDecimal toBigDecimal(Object value) {
    if (Integer.class.isInstance(value)) {
      return new BigDecimal((Integer) value);
    }

    return (BigDecimal) value;
  }
}
