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

import java.net.URI;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Formatter;
import java.util.List;
import java.util.Locale;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.function.Supplier;

import com.codahale.metrics.MetricRegistry;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import nz.org.riskscape.cli.Terminal;
import nz.org.riskscape.engine.HasMeter;
import nz.org.riskscape.engine.IdentifiedCollection;
import nz.org.riskscape.engine.Project;
import nz.org.riskscape.engine.RiskscapeException;
import nz.org.riskscape.engine.cli.CliProgressNotifier;
import nz.org.riskscape.engine.cli.ExitException;
import nz.org.riskscape.engine.cli.PipelineRenderer;
import nz.org.riskscape.engine.cli.TerminalCommand;
import nz.org.riskscape.engine.i18n.Messages;
import nz.org.riskscape.engine.model.Model;
import nz.org.riskscape.engine.output.PipelineJobContext;
import nz.org.riskscape.engine.output.PipelineOutputContainer;
import nz.org.riskscape.engine.output.PipelineOutputOptions;
import nz.org.riskscape.engine.output.PipelineOutputStores;
import nz.org.riskscape.engine.pipeline.ExecutionContext;
import nz.org.riskscape.engine.pipeline.ExecutionResult;
import nz.org.riskscape.engine.pipeline.PipelineExecutor;
import nz.org.riskscape.engine.pipeline.PipelineProblems;
import nz.org.riskscape.engine.pipeline.RealizedPipeline;
import nz.org.riskscape.engine.pipeline.RealizedStep;
import nz.org.riskscape.engine.problem.GeneralProblems;
import nz.org.riskscape.engine.util.FileProgressNotifier;
import nz.org.riskscape.engine.util.ParentingListener;
import nz.org.riskscape.engine.util.ProgressNotifier;
import nz.org.riskscape.problem.Problems;

/**
 * Has code for running the execution of a realized pipeline and sending the output etc to the right place.  Factored
 * out here for reuse from other commands
 */
@RequiredArgsConstructor
public class CliPipelineRunner implements PipelineRenderer {

  public static final Supplier<LocalDateTime> DEFAULT_TIME_FUNCTION = () -> LocalDateTime.now();

  private static final String MONITOR_PROGRESS_CLI = "cli";
  private static final String MONITOR_PROGRESS_NONE = "none";
  public static final String MONITOR_PROGRESS_DEFAULT = MONITOR_PROGRESS_CLI;

  /**
   * @return a file system path friendly string that represents the given `datetime`.
   */
  public static String createDirFriendlyTimestamp(LocalDateTime dateTime) {
    return DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(dateTime.truncatedTo(ChronoUnit.SECONDS)).replace(":", "_");
  }

  private final ExecutionContext executionContext;

  @Getter
  private final Messages messages;
  private final Terminal terminal;
  private final Supplier<LocalDateTime> currentTime;

  public CliPipelineRunner(ExecutionContext executionContext, TerminalCommand command) {
    this(executionContext, command.getMessages(), command.getTerminal(), DEFAULT_TIME_FUNCTION);
  }

  @Override
  public Locale getLocale() {
    return terminal.getLocale();
  }

  // TODO we don't need all these args any more, we have the executionContext
  public void run(RealizedPipeline realized, PipelineExecutor executor, Project useProject,
      CliPipelineRunnerOptions options) {

    if (options.printPipeline || options.printStep != null) {
      // try to print the pipeline even if it has errors - this is a special case
      // where --print could help diagnose the problem

      if (options.printStep != null) {

        // leave out anonymous steps from "did you mean" - doesn't make sense to dump out a bunch of select_1, select_2
        // etc
        List<String> candidates = IdentifiedCollection.getSimilar(
            options.printStep,
            realized.getRealizedSteps().stream()
              .filter(r -> r.isNamed()).map(RealizedStep::getName).toList()
        );

        printStep(realized.getStep(options.printStep).orElseThrow(() ->
        new RiskscapeException(GeneralProblems.noSuchObjectExists(
            options.printStep,
            RealizedStep.class,
            candidates
        ))), new Formatter(terminal.getOut()));
      } else {
        printPipeline(realized, new Formatter(terminal.getOut()));
      }
      return;
    }

    if (realized.getContext() != executionContext) {
      throw new AssertionError("Pipeline context does not match");
    }

    // we may have gotten a pipeline result, but individual steps still failed to realize
    if (realized.hasFailures()) {
      throw new ExitException(PipelineProblems.get().cannotRealize(Model.class)
          .withChildren(realized.getFailures()));
    }

    RealizedPipeline drained = realized.drainWarnings(terminal);

    MetricRegistry progressMetrics = new MetricRegistry();
    PipelineOutputContainer outputContainer = createContainer(options, drained);

    // see if any of the steps themselves expose metrics (looks like there's only ever one of these)
    for (RealizedStep step : drained.getRealizedSteps()) {
      step.getResult().ifPresent(r -> {
        if (r instanceof HasMeter) {
          MetricRegistry registry = ((HasMeter) r).getRegistry();
          MetricRegistry master = drained.getContext().getMetricRegistry();
          for (String names : registry.getNames()) {
            master.register(step.getStepName() + "." + names, registry.getMetrics().get(names));
          }
        }
      });
    }

    if (options.engineStats) {
      MetricRegistry engineMetrics = useProject.getEngine().getDiagnostics().getMetricRegistry();
      ParentingListener.mirror(engineMetrics, progressMetrics, "engine.");
    }

    Optional<ProgressNotifier> progressNotifier = createProgressNotifier(options, progressMetrics, useProject);

    ExecutionResult result;
    try {
      result = executor.execute(createJobContext(drained, outputContainer, progressMetrics)).get();
    } catch (InterruptedException | ExecutionException e) {
      // nb we want the cause for ExecutionException as it's just a wrapper exception - for InterruptedException it'll
      // just return itself, so no harm no foul
      throw new ExitException(PipelineProblems.get().executionFailed().withChildren(Problems.caught(e.getCause())));
    } finally {
      progressNotifier.ifPresent(pn -> pn.finish());
    }

    // this will write the manifest etc
    outputContainer.close();

    for (Entry<String, URI> entry : result.getPipelineOutputs().entrySet()) {
      terminal.getOut().println(entry.getValue());
    }
  }

  public PipelineOutputContainer createContainer(
      CliPipelineRunnerOptions cliOptions,
      RealizedPipeline pipeline
  ) {

    if (pipeline.getContext() != executionContext) {
      throw new AssertionError("Pipeline context does not match");
    }

    String name = pipeline.getMetadata().getName();

    PipelineOutputStores stores = pipeline.getContext().getEngine().getPipelineOutputStores();
    URI outputBaseToUse;
    if (cliOptions.output == null) {
      outputBaseToUse = executionContext.getProject().getOutputBaseLocation();
    } else {
      outputBaseToUse = cliOptions.getOutputBase(executionContext);
    }

    return stores
      .getStoreForURI(outputBaseToUse)
      .flatMap(store -> {
        PipelineOutputOptions outputOptions = store.newOutputOptions(name);
        outputOptions.setReplace(cliOptions.replace);
        outputOptions.setChecksum(cliOptions.checksum);
        outputOptions.setFormat(cliOptions.getFormat(executionContext));
        outputOptions.setPipelineDeclaration(Optional.of(pipeline.getAst()));

        URI outputTo = outputBaseToUse;
        if ("filesystem".equals(store.getId()) && cliOptions.output == null
            && "file".equals(outputBaseToUse.getScheme())) {
          // If the user has not specified --output and the output store is the filesystem then we
          // mung the output path
          Path outputDir = Paths.get(outputBaseToUse);
          outputDir = outputDir.resolve(name).resolve(createDirFriendlyTimestamp(currentTime.get()));
          outputTo = outputDir.toUri();
        }

        return store.create(outputTo, pipeline, outputOptions);
      })
      .drainWarnings(terminal, (severity, problems) -> Problems.foundWith("--output", problems)
      ).orElseThrow(probs ->
      new ExitException(1, Problems.foundWith("--output", probs))
    );
  }
  private PipelineJobContext createJobContext(RealizedPipeline realized, PipelineOutputContainer outputContainer,
      MetricRegistry progressMetrics) {
    return new PipelineJobContext() {
      @Override
      public PipelineOutputContainer getOutputContainer() {
        return outputContainer;
      }

      @Override
      public RealizedPipeline getPipeline() {
        return realized;
      }

      @Override
      public MetricRegistry getProgressMetrics() {
        return progressMetrics;
      }
    };
  }

  public Optional<ProgressNotifier> createProgressNotifier(CliPipelineRunnerOptions options, MetricRegistry metrics,
      Project useProject) {
    if (MONITOR_PROGRESS_CLI.equals(options.progressIndicator)) {
      return Optional.of(new CliProgressNotifier(metrics, terminal));
    } else if (MONITOR_PROGRESS_NONE.equals(options.progressIndicator)) {
      return Optional.empty();
    } else {
      // assume it's a filepath
      return Optional.of(
          new FileProgressNotifier(metrics, Paths.get(options.progressIndicator), executionContext.getProblemSink())
      );
    }
  }
}
