package gov.cms.grouper.snf.transfer;

import com.mmm.his.cer.foundation.transfer.Claim;
import gov.cms.grouper.snf.model.Assessment;
import gov.cms.grouper.snf.model.SnfDiagnosisCode;
import gov.cms.grouper.snf.model.enums.*;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.File;
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.*;
import java.util.stream.Collectors;

/**
 * The SNF record containing MDS inputs and outputs
 */
public class SnfClaim extends Claim implements ISnfClaim {

  private static final long serialVersionUID = -6053717059913154415L;

  // INPUTS
  private int version;
  private Map<String, Assessment> assessmentMap = new HashMap<>();
  private LocalDate assessmentReferenceDate;

  // TODO change to enum
  private Integer obra;
  private Integer pps;

  // OUTPUTS
  private String hippsCode = "";
  private String recalculated_z0100b = "";
  private String mdsRaiVersion = "";

  private final List<String> errors = new ArrayList<>();

  // Miscellaneous
  private String originalRecord;
  private Document rawData;

  @Override
  public int getVersion() {
    return version;
  }

  @Override
  public void setVersion(int version) {
    this.version = version;
  }

  public String getRecalculated_z0100b() {
    return recalculated_z0100b;
  }

  public void setRecalculated_z0100b(String recalculated_z0100b) {
    this.recalculated_z0100b = recalculated_z0100b;
  }

  public SnfDiagnosisCode getPrimaryDiagnosis() {
    return getCodes(SnfDiagnosisCode.class).stream().findFirst().orElse(null);
  }

  public void setPrimaryDiagnosis(SnfDiagnosisCode pdx) {
    if (codes.isEmpty()) {
      codes.add(pdx);
    } else {
      codes.set(0, pdx);
    }
  }

  public void insertPrimaryDiagnosis(SnfDiagnosisCode pdx) {
    codes.add(0, pdx);
  }

  public List<SnfDiagnosisCode> getSecondaryDiagnoses() {
    return getCodes(SnfDiagnosisCode.class).stream().skip(1).collect(Collectors.toList());
  }

  @Override
  public void addAssessment(Assessment assessment) {
    assessmentMap.put(assessment.getItem(), assessment);
  }

  @Override
  public Map<String, Assessment> getAssessmentMap() {
    return assessmentMap;
  }

  public void setAssessmentMap(Map<String, Assessment> assessments) {
    this.assessmentMap = assessments;
  }

  public LocalDate getAssessmentReferenceDate() {
    return assessmentReferenceDate;
  }

  public void setAssessmentReferenceDate(LocalDate assessmentReferenceDate) {
    this.assessmentReferenceDate = assessmentReferenceDate;
  }

  /**
   * return list of errors on the record
   */
  @Override
  public List<String> getErrors() {
    return errors;
  }

  @Override
  public void addErrors(String error) {
    errors.add(error);
  }

  /**
   * return true when unable to compute hipps else there are no errors
   */
  public boolean hasError() {
    return !errors.isEmpty();
  }

  @Override
  public String getHippsCode() {
    return hippsCode;
  }

  @Override
  public void setHippsCode(String code) {
    hippsCode = code;
  }

  public String getOriginalRecord() {
    return originalRecord;
  }

  public void setOriginalRecord(String originalRecord) {
    this.originalRecord = originalRecord;
  }

  public Document getRawData() {
    return rawData;
  }

  public void setRawData(Document rawData) {
    this.rawData = rawData;
  }

  /**
   * Return XML structured String with output data.
   * @param hipps the calculated hipps value
   * @param productVersion the current product version
   * @param errors any errors encountered during processing.
   * @return XML structured String with output data.
   */
  public String getOutputXmlString(String hipps, String productVersion, String errors) {
    try {
      Document root = rawData;

      // Replace with outputs
      updateXmlElement(root, "RECALCULATED_Z0100A", hipps);
      updateXmlElement(root, "RECALCULATED_Z0100B", productVersion);
//      updateXmlElement(root, "ERROR_REASON", errors);

      TransformerFactory tf = TransformerFactory.newInstance();
      Transformer transformer = tf.newTransformer();
      transformer.setOutputProperty(OutputKeys.INDENT, "yes");
      StringWriter writer = new StringWriter();
      transformer.transform(new DOMSource(root), new StreamResult(writer));

      String xmlString = writer.getBuffer().toString();
      xmlString = xmlString.replaceAll(" {8}\r\n", ""); // Replace 8 spaces with new lines with nothing
      xmlString = xmlString.replaceAll(" {4}\r\n", ""); // Replace 4 spaces with new lines with nothing
      return xmlString;
    } catch (Exception e) {
      throw new RuntimeException("Unable to convert SnfClaim to XML String:", e);
    }
  }

  /**
   * Updates or adds XML element to root of Document.
   *
   * @param doc Document to be updated.
   * @param tag XML tag to be added as child to root of doc.
   * @param newValue Value to go into XML tag.
   */
  private void updateXmlElement(Document doc, String tag, String newValue) {
    NodeList nodeList = doc.getElementsByTagName(tag);
    // Update existing element if exist
    if (nodeList.getLength() > 0) {
      Element existingElement = (Element) nodeList.item(0);
      // Update the value
      existingElement.setTextContent(newValue);

    // Create new element
    } else {
      if (!newValue.trim().isEmpty()) {
        Element root = doc.getDocumentElement();
        Element newElement = doc.createElement(tag);
        newElement.appendChild(doc.createTextNode(newValue));
        root.appendChild(newElement);
      }
    }
  }

  public boolean isFixedStringOrigin() {
    return getRawData() == null;
  }

  public boolean isXmlOrigin() {
    return getRawData() != null;
  }

  /**
   * Return the assessment indicator.
   * @deprecated
   * This method can be replaced with {@link SnfClaim#getAssessmentIndicator()}.
   * @return the assessment indicator indicating which type of assessment was completed.
   */
  @Deprecated
  @Override
  public Integer getAiCode() {
    return getAssessmentIndicator();
  }

  /**
   * Return the assessment indicator based on the provided PPS value.
   * @since v2.2
   * @return the assessment indicator indicating which type of assessment was completed.
   */
  public Integer getAssessmentIndicator() {
    Optional<PpsAssessment> maybePpsAssessment = PpsAssessment.of(this.pps);
    if (maybePpsAssessment.isPresent()) {
      PpsAssessment ppsAssessment = maybePpsAssessment.get();
      switch (ppsAssessment) {
        case INTERIM_PAYMENT_ASSESSMENT:
          return AssessmentIndicator.INTERIM_PAYMENT_ASSESSMENT.getValue();
        case FIVE_DAY:
          return AssessmentIndicator.FIVE_DAY.getValue();
      }
    }
    return null;
  }

  /**
   * Set the assessment indicator indicating which type of assessment was completed.
   * @param code indicator value
   * @deprecated
   * This method is no longer has any functionality.
   */
  @Deprecated
  @Override
  public void setAiCode(Integer code) {}


  @Override
  public void setPps(Integer pps) {
    this.pps = pps;
  }

  @Override
  public Integer getPps() {
    return this.pps;
  }

  @Override
  public Integer getObra() {
    return obra;
  }

  @Override
  public void setObra(Integer obra) {
    this.obra = obra;
  }

  public String getMdsRaiVersion() {
    return mdsRaiVersion;
  }

  public void setMdsRaiVersion(String mdsRaiVersion) {
    this.mdsRaiVersion = mdsRaiVersion;
  }

  @Override
  public String toString() {
    return "SnfClaim{"
        + "version=" + version
        + ", assessmentReferenceDate=" + assessmentReferenceDate
        + ", aiCode=" + AssessmentIndicator.of(getAssessmentIndicator()).orElse(null)
        + ", obra=" + ObraAssessment.of(obra).orElse(null)
        + ", pps=" + PpsAssessment.of(pps).orElse(null)
        + ", codes=" + codes
        + ", hippsCode='" + hippsCode
        + "', errors=" + errors
        + ", assessments=" + assessmentMap
        + ", originalRecord='" + originalRecord
        + '}';
  }

  /**
   * This wraps the claim in a proxy claim in order to store the original record for use later when
   * the record must be restored.
   */
  public static SnfClaim mapMdsToClaim(String utfRecord) {
    SnfClaim claim = new SnfClaim();
    claim.setOriginalRecord(utfRecord);
    if (utfRecord.length() < Rai300.FIXED_LENGTH_SIZE) {
      claim.addErrors(SnfError.INVALID_LINE_LENGTH.getReason(String.valueOf(utfRecord.length())));
    }

    for (Rai300 item : Rai300.values()) {
      int index = item.getIndex() - 1;
      String itemValue = utfRecord.substring(index, index + item.getLength());

      extractToClaim(claim, itemValue, item);
    }

    return claim;
  }

  /**
   * This retrieves the original record from the Claim and then puts the Hipps code into is proper
   * place in the record. No other data is altered.
   *
   * @return if the claim is a Proxy claim, then it returns the original record with the HIPPS code
   * placed within the record. Otherwise, it will just return the HIPPS Code (5 character string).
   */
  public String mapClaimToMds() {
    String hippsCode = hasError() ? "       " : getHippsCode();

    StringBuilder record = new StringBuilder(getOriginalRecord());
    int hippsIndex = Rai300.RECALCULATED_Z0100A.getIndex() - 1;
    record.replace(hippsIndex, hippsIndex + hippsCode.length(), hippsCode);

    int versionIndex = Rai300.RECALCULATED_Z0100B.getIndex() - 1;
    // substring to remove the leading V
    String version = getRecalculated_z0100b();
    record.replace(versionIndex, versionIndex + version.length(), version);

    return record.toString();
  }

  /**
   * This takes a file path to an XML formatted claim and returns the SNF claim that results.
   * @param xmlFilePath Path to XML Claim
   * @return SNF Claim
   * @throws ParserConfigurationException product of XML parsing
   * @throws IOException product of XML parsing
   * @throws SAXException product of XML parsing
   */
  public static SnfClaim mapXmlFileToClaim(String xmlFilePath)
          throws ParserConfigurationException, IOException, SAXException {

    DocumentBuilder documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
    File file = new File(xmlFilePath);
    Document document = documentBuilder.parse(file);

    return identifyXmlTags(document);
  }

  /**
   * This takes an XML formatted claim and returns the SNF claim that results.
   * @param xmlString The claim in XML format
   * @return SNF Claim
   * @throws ParserConfigurationException product of XML parsing
   * @throws IOException product of XML parsing
   * @throws SAXException product of XML parsing
   */
  public static SnfClaim mapXmlStringToClaim(String xmlString)
          throws ParserConfigurationException, IOException, SAXException {

    DocumentBuilder documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
    InputSource is = new InputSource(new StringReader(xmlString));
    Document document = documentBuilder.parse(is);

    return identifyXmlTags(document);
  }

  /**
   * Utility method that goes through XML tags
   * @param document XML document
   * @return Filled in SnfClaim
   */
  private static SnfClaim identifyXmlTags(Document document) {
    String baseTag = "ASSESSMENT";

    SnfClaim claim = new SnfClaim();
    claim.setRawData(document);

    // Treat every opening and closing tag combination as one element
    // i.e <foo>hello wor ld</foo>
    document.getDocumentElement().normalize();

    NodeList nodeList = document.getElementsByTagName(baseTag);

    Node node = nodeList.item(0); // Expected only 1 Assessment within the file

    if (node.getNodeType() == Node.ELEMENT_NODE) {
      Element element = (Element) node;

      for (Rai300 field : Rai300.values()) {
        String tag = field.name();

        Node xmlNode = element.getElementsByTagName(tag).item(0);
        String xmlValue = (xmlNode == null) ? "" : xmlNode.getTextContent().trim();

        extractToClaim(claim, xmlValue, field);
      }

    }
    return claim;
  }

  /**
   * Util method that fills in claim based on Rai300 item.
   * @param claim Claim to be filled.
   * @param itemValue Value from mapping.
   * @param item Rai300 field being mapped.
   */
  private static void extractToClaim(SnfClaim claim, String itemValue, Rai300 item) {
    if (item.getAssessmentType() == AssessmentType.ARD) {
      try {
        LocalDate ard = LocalDate.parse(itemValue, DateTimeFormatter.BASIC_ISO_DATE);
        claim.setAssessmentReferenceDate(ard);
      } catch (DateTimeParseException e) {
        claim.setAssessmentReferenceDate(null);
      }
    } else if (item.getAssessmentType() == AssessmentType.PDX) {
      claim.insertPrimaryDiagnosis(new SnfDiagnosisCode(itemValue, null, null, null));

    } else if (item.getAssessmentType() == AssessmentType.SERVICES) {
      if (!itemValue.replaceAll("[.^]", "").trim().isEmpty()) {
        claim.addCode(new SnfDiagnosisCode(itemValue, null, null, null));
      }
    } else if (item.getAssessmentType() == AssessmentType.PPS) {
      int result;
      try {
        result = Integer.parseInt(itemValue);
      } catch (Exception exception) {
        result = Integer.MIN_VALUE;
      }
      claim.setPps(result);

    } else if (item.getAssessmentType() == AssessmentType.OBRA) {
      int result;
      try {
        result = Integer.parseInt(itemValue);
      } catch (Exception exception) {
        result = Integer.MIN_VALUE;
      }
      int value = result;
      claim.setObra(value);
    } else if (item.getAssessmentType() != AssessmentType.ERROR_REASON){
      Assessment assessment = new Assessment(item.name(), item.getXmlTag(), itemValue);
      claim.addAssessment(assessment);
    }
  }
}
