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

import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Formatter;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import com.google.common.collect.Lists;

import lombok.RequiredArgsConstructor;
import nz.org.riskscape.engine.RiskscapeException;
import nz.org.riskscape.engine.RiskscapeIOException;

/**
 * metadata from the execution of a pipeline
 */

public class Manifest {

  /**
   * Filename of manifest file.
   */
  public static final String MANIFEST_FILE = "manifest.txt";

  /**
   * Pattern used by {@link #verify(java.nio.file.Path, java.util.Formatter) } to harvest outputfiles
   * from manifest.txt
   */
  private static final Pattern OUTPUT_FILE_PATTERN = Pattern.compile(
      "^(?<filename>.*) \\(size=(?<size>.*), checksum=(?<checksum>.*)\\)$");

  /**
   * Pattern used by {@link #verify(java.nio.file.Path, java.util.Formatter) } to harvest the meta
   * manifest entry from manifest.txt.
   *
   * Verify stops looking for output files once this pattern is matched so care must be taken to ensure
   * that earlier lines in the manifest do not match.
   */
  private static final Pattern MANIFEST_FILE_PATTERN = Pattern.compile(
      "^manifest.txt \\(checksum=(?<checksum>.*)\\)$");

  private static final char[] HEX_CODE = "0123456789ABCDEF".toCharArray();

  /**
   * Updates the {@link MessageDigest} with the parameters.
   *
   * Used to build up a digest for the manifest file itself. But rather than checksumming the content
   * is is a checksum of all the output files contained in it.
   */
  private static void update(MessageDigest md, String filename, String size, String checksum) {
    md.update(filename.getBytes());
    md.update(size.getBytes());
    if (checksum != null) {
      md.update(checksum.getBytes());
    }
  }

  /**
   * Returns a checksum of file.
   * @param file
   * @return checksum
   */
  private static String checksum(Path file) {

    MessageDigest md = getDigestInstance();
    byte[] data = new byte[1024];
    int read;
    try (FileInputStream fis = new FileInputStream(file.toFile())) {
      while ((read = fis.read(data)) != -1) {
        md.update(data, 0, read);
      }

      return checksum(md);
    } catch (IOException e) {
      throw new RiskscapeIOException(String.format("Could not checksum file", file.toString()), e);
    }
  }

  /**
   * Verifies that the output files contained in manifest.txt have not been tampered with.
   * @param manifestFile path to the manifest file to verify, if this is a directory then {@link #MANIFEST_FILE}
   *                     is appended
   * @param out {@link Formatter} to write verification results to.
   */
  public static void verify(Path manifestFile, Formatter out) {
    if (Files.isDirectory(manifestFile)) {
      manifestFile = manifestFile.resolve(Manifest.MANIFEST_FILE);
    }

    if (! Files.exists(manifestFile)) {
      out.format("%s does not exist. Aborting%n", manifestFile);
      return;
    } else if (! Files.isReadable(manifestFile)) {
      out.format("%s cannot be read. Check file permissions. Aborting%n", manifestFile);
      return;
    }
    Path outputDirectory = manifestFile.getParent();

    try {
      List<String> manifestContent = Files.readAllLines(manifestFile);

      MessageDigest manifestDigest = getDigestInstance();
      String manifestCheckSum = "";

      for (String line: manifestContent) {
        Matcher outputFileMatcher = OUTPUT_FILE_PATTERN.matcher(line);
        if (outputFileMatcher.matches()) {
          String filename = outputFileMatcher.group("filename");
          String size = outputFileMatcher.group("size");
          String checksum = outputFileMatcher.group("checksum");

          Path outputFile = outputDirectory.resolve(filename);
          if (! Files.exists(outputFile)) {
            out.format("FAILED %s No such file or directory%n", outputFile);
            return;
          } else if (! Files.isReadable(outputFile)) {
            out.format("FAILED %s Permission denied. Check file permissions%n", outputFile);
            return;
          } else if ("null".equals(checksum)) {
            //Need to map "null" -> null to generage the correct manifest digest
            update(manifestDigest, filename, size, null);
            out.format("UNKNOWN %s checksum not available for verification%n", filename);
          } else {
            update(manifestDigest, filename, size, checksum);
            if (checksum.equals(checksum(outputFile))) {
              out.format("PASSED %s matches checksum%n", filename);
            } else {
              out.format("FAILED %s computed checksum does not match%n", filename);
            }
          }
        }
        Matcher manifestMatcher = MANIFEST_FILE_PATTERN.matcher(line);
        if (manifestMatcher.matches()) {
          manifestCheckSum = manifestMatcher.group("checksum");
          //No need to check any further. All output files must have been processed.
          break;
        }
      }
      if (!manifestCheckSum.equals(checksum(manifestDigest))) {
        out.format("FAILED %s computed checksum does not match%n", MANIFEST_FILE);
      }
    } catch (IOException ex) {
      out.format("FAILED, could not read manifest.txt. Cause: %s%n", ex.getMessage());
    }

  }

  /**
   * Get a new instance of a {@link MessageDigest} of the type used for {@link Manifest} checksums.
   *
   * The returned {@link MessageDigest} may be used with {@link DigestOutputStream} to be updated
   * as an output file is written.
   *
   * @return message digest
   */
  public static MessageDigest getDigestInstance() {
    try {
      return  MessageDigest.getInstance("SHA-256");
    } catch (NoSuchAlgorithmException e) {
      //JRE's are required to have SHA-256 so should not get here
      throw new RiskscapeException("Could not get SHA-256 message digest", e);
    }
  }

  /**
   * Produces a checksum from the {@link MessageDigest} as a {@link String}.
   *
   * @param md digest to checksum
   * @return checksum from md as a string
   */
  public static String checksum(MessageDigest md) {
    byte[] digest = md.digest();
    StringBuilder r = new StringBuilder(digest.length * 2);
    for (byte b : digest) {
        r.append(HEX_CODE[(b >> 4) & 0xF]);
        r.append(HEX_CODE[(b & 0xF)]);
    }
    //toLowerCase to match Linux sha256sum output.
    return r.toString().toLowerCase();
  }

  public Manifest() {
  }

  public Manifest(RealizedPipeline realized, LocalDateTime startTime) {
    this.pipelineDescription = realized.getMetadata().getDescription();
    this.pipelineId = realized.getMetadata().getName();
    this.steps = StepDescription.build(realized);
    this.startTime = startTime;
  }

  public Manifest(RealizedPipeline realized) {
    this(realized, LocalDateTime.now());
  }

  @RequiredArgsConstructor
  public static class VersionInfo {
    public final String component;
    public final String version;
  }

  @RequiredArgsConstructor
  public static class LocalInfo  {
    public final String user;
    public final String host;
  }

  @RequiredArgsConstructor
  public static class OutputInfo {
    public final String filename;
    public final String size;
    public final String checksum;

    public OutputInfo(String filename, String size, MessageDigest md) {
      this(filename, size, md != null ? checksum(md) : null);
    }

  }

  @RequiredArgsConstructor
  public static class StepDescription {

    public static List<StepDescription> build(RealizedPipeline realized) {
      Set<RealizedStep> seen = new HashSet<>();
      List<RealizedStep> visitList = Lists.newArrayList(realized.getEndSteps());
      List<StepDescription> descriptions = Lists.newArrayListWithCapacity(realized.getRealizedSteps().size());
      while (!visitList.isEmpty()) {
        RealizedStep step = visitList.remove(0);

        if (!seen.contains(step)) {
          seen.add(step);

          StepDescription desc = new StepDescription(step);
          descriptions.add(desc);
          visitList.addAll(step.getDependencies());
        }
      }

      Lists.reverse(descriptions);

      return descriptions;
    }

    public StepDescription(RealizedStep step) {
      this.stepName = step.getStepName();
      this.stepId = step.getImplementation().getId();
      this.dependsOn = step.getDependencies().stream().map(rs -> rs.getStepName()).collect(Collectors.toList());
      this.parameterDescriptions = new HashMap<>(step.getBoundParameters().size());
      for (Entry<String, List<?>> boundParam : step.getBoundParameters().entrySet()) {
        List<String> values = boundParam.getValue()
            .stream()
            .map(obj -> obj == null ? "null" : obj.toString())
            .collect(Collectors.toList());

        parameterDescriptions.put(boundParam.getKey(), values);
      }
    }

    public final String stepName;
    public final String stepId;
    public final List<String> dependsOn;
    public final Map<String, List<String>> parameterDescriptions;

    @Override
    public String toString() {
      StringBuilder sb = new StringBuilder();
      Formatter out = new Formatter(sb);
      out.format("Step-%s: %s%n", stepName, stepId);
      out.format("  Parameters: %n");
      for (Entry<String, List<String>> bound : parameterDescriptions.entrySet()) {
        out.format("    %s : %s%n", bound.getKey(), bound.getValue());
      }
      if (dependsOn != null && !dependsOn.isEmpty()) {
        out.format("  Depends On: %n");
        for (String dependency : dependsOn) {
          out.format("    %s%n", dependency);
        }
      }
      return sb.toString();
    }
  }

  public String pipelineId;
  public String pipelineDescription;
  public List<StepDescription> steps;

  public LocalDateTime startTime;
  public LocalDateTime finishTime;

  public List<OutputInfo> outputs = Lists.newArrayList();
  public List<VersionInfo> versionInfo = Lists.newArrayList();
  public LocalInfo localInfo;

  /**
   * Write the manifest to pipelineDirectory.
   * @param output stream to write manifest bytes to. Expected to contain outputs.
   */
  public void write(OutputStream output) {
    Formatter formatter = new Formatter(output);
    writeTo(formatter);
    formatter.flush();
  }

  /**
   * Write the manifest file describing a pipeline run.
   * @param f {@link Formatter} to write to
   */
  public void writeTo(Formatter f) {

    f.format("Pipeline-ID: %s%n", pipelineId);
    f.format("Pipeline-Description: %s%n", pipelineDescription);
    f.format("Start-Time: %s%n", DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(startTime));
    f.format("Finish-Time: %s%n", DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(finishTime));

    if (!versionInfo.isEmpty()) {
      f.format("%n");
      f.format("Riskscape Version%n");
      f.format("-----------------%n");
      for (VersionInfo version : versionInfo) {
        f.format("%s: %s%n", version.component, version.version);
      }
    }

    f.format("%n");
    f.format("Host Information%n");
    f.format("----------------%n");
    f.format("User: %s%n", localInfo.user);
    f.format("Host: %s%n", localInfo.host);

    f.format("%n");
    f.format("Pipeline Outputs%n");
    f.format("----------------%n");

    //manifestDigest is used to produce a checksum for the manifest itself. This checksum is made
    //up of the output files and their checksums which should make it <hard enough> for bad people
    //to work around.
    //Caution: changing the format of "Pipeline Outputs" will have implications on verification.
    //         E.g older pipelines may not be verified correctly.
    MessageDigest manifestDigest = getDigestInstance();
    for (OutputInfo output: outputs) {
      f.format("%s (size=%s, checksum=%s)%n", output.filename, output.size, output.checksum);
      update(manifestDigest, output.filename, output.size, output.checksum);
    }
    f.format("%s (checksum=%s)%n", MANIFEST_FILE, checksum(manifestDigest));

    f.format("%n");
    f.format("Pipeline Structure%n");
    f.format("------------------%n");
    for (StepDescription desc : steps) {
      f.format("%n%s", desc.toString());
    }
  }

  /**
   * Add {@link OutputInfo} to this manifest.
   *
   * Using this method is preferred as it takes care of concurrency.
   *
   * @param info output info to add
   */
  public void add(OutputInfo info) {
    synchronized (this) {
      outputs.add(info);
    }
  }

}
