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

import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.stream.Collectors;

import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
import org.geotools.geometry.jts.ReferencedEnvelope;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;

import nz.org.riskscape.engine.FailedObjectException;
import nz.org.riskscape.engine.NoSuchObjectException;
import nz.org.riskscape.engine.Project;
import nz.org.riskscape.engine.Tuple;
import nz.org.riskscape.engine.cli.Table.Property;
import nz.org.riskscape.engine.coverage.TypedCoverage;
import nz.org.riskscape.engine.data.Bookmark;
import nz.org.riskscape.engine.data.BookmarkProblems;
import nz.org.riskscape.engine.data.ResolvedBookmark;
import nz.org.riskscape.engine.geo.GeometryRenderer;
import nz.org.riskscape.engine.output.Format;
import nz.org.riskscape.engine.pipeline.ExecutionContext;
import nz.org.riskscape.engine.relation.InvalidTupleException;
import nz.org.riskscape.engine.relation.Relation;
import nz.org.riskscape.engine.relation.SpatialMetadata;
import nz.org.riskscape.engine.relation.TupleIterator;
import nz.org.riskscape.engine.types.Struct.StructMember;
import nz.org.riskscape.picocli.CommandLine.Command;
import nz.org.riskscape.picocli.CommandLine.Option;
import nz.org.riskscape.picocli.CommandLine.Parameters;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.problem.ResultOrProblems;

@Command(
    name = "bookmark",
    subcommands = {
        BookmarksCommand.BookmarkListCommand.class,
        BookmarksCommand.Info.class,
        BookmarksCommand.Eval.class
      }
    )
public class BookmarksCommand extends StubCommand {

  // cli specific bookmark resolving logic.  This used to use the Bookmark binder, which confusingly only bound a
  // ResolvedBookmark, but that's going away (gone)
  protected static ResolvedBookmark resolve(Project project, String bookmarkId, ExecutionContext ctx) {

    Bookmark bookmark;
    try {
      // first see if there's a bookmark with this id exactly
      bookmark = project.getBookmarks()
          .get(bookmarkId, project.getProblemSink());
    } catch (NoSuchObjectException noSuchObjectException) {
      // maybe it's a path or a URI?

      Path path = Paths.get(bookmarkId);
      // NB this is a PWD relative path lookup, rather than project relative lookup - I think that's right for the CLI
      if (Files.exists(path)) {
        // yep, it's a local path
        bookmark = Bookmark.fromURI(path.toUri());
      } else {

        // it if parses and begins with scheme: then it's a uri
        try {
          URI uri = new URI(bookmarkId);
          if (uri.getScheme() != null) {
            bookmark = Bookmark.fromURI(uri);
          } else {
            bookmark = null;
          }
        } catch (URISyntaxException e) {
          // nope, not a bookmark or a uri
          bookmark = null;
        }

        if (bookmark == null) {
          throw new ExitException(BookmarkProblems.get().notBookmarkOrFile(bookmarkId));
        }
      }
    } catch (FailedObjectException e) {
      // there was a problem with the bookmark itself - maybe it's missing required properties or something like that
      throw new ExitException(e.getProblems());
    }

    ResolvedBookmark resolved = project.getEngine().getBookmarkResolvers()
        .resolve(bookmark, ctx)
        // link the resolving problem to the bookmark
        .composeProblems(Problems.foundWith(bookmark))
        .orElseThrow(probs -> new ExitException(Problems.toSingleProblem(probs)));

    // don't return it if it's not valid, exit instead
    if (resolved.hasValidationErrors()) {
      throw new ExitException(Problems.foundWith(bookmark, resolved.validate()));
    }

    // don't return if it doesn't build
    resolved.getData(Object.class)
      .orElseThrow(probs -> new ExitException(Problems.foundWith(resolved.getBookmark(), probs)));

    return resolved;
  }

  public abstract static class Base extends ApplicationCommand {


    @Option(names = { "--brief" })
    public boolean brief;

    protected void doDetailedDump(ResolvedBookmark bookmark) {
      try {
        doDetailedDumpUncaught(bookmark);
      } catch (InvalidTupleException ex) {
        throw new ExitException(
            Problem.error("Measuring failed because of invalid data in the bookmark")
                .withChildren(Problems.caught(ex)), ex);
      } catch (RuntimeException ex) {
        throw new ExitException(1, ex, "Inspecting the bookmark failed because of an unexpected error - %s",
            getMessages().renderProblem(ex));
      }
    }

    protected void doDetailedDumpUncaught(ResolvedBookmark resolved) {
      Bookmark bookmark = resolved.getBookmark();

      if (bookmark.isFromURI()) {
        print("%nLocation : %s", bookmark.getLocation());
      } else {
        print("%n\"%s\"", bookmark.getId());
        print("  Description : %s", bookmark.getDescription());
        print("  Location    : %s", bookmark.getLocation());
      }

        printProblems(resolved.validate());

        // no point going further, it didn't work
        if (resolved.hasValidationErrors()) {
          return;
        }

        ResultOrProblems<Object> dataOr = resolved.getData(Object.class);
        printProblems(dataOr.getProblems());

        if (dataOr.hasErrors()) {
          return;
        }
        Object data = dataOr.getWithProblemsIgnored();

        CoordinateReferenceSystem displayCrs = getEngine().getDiagnostics().getDisplayCrs();

        if (data instanceof TypedCoverage) {
          TypedCoverage coverage = (TypedCoverage) data;
          if (coverage == null) {
            return;
          }
          CoordinateReferenceSystem crs = coverage.getCoordinateReferenceSystem();
          print("  Axis-order  : %s", GeometryRenderer.getAxisOrder(crs));

          print("  Coverage    : %s", coverage.toString());
          print("  CRS code    : %s", GeometryRenderer.getCode(crs));
          print("  CRS (full)  : %s", crs);
          if (coverage.getEnvelope().isPresent()) {
            ReferencedEnvelope bounds = coverage.getEnvelope().get();
            print("  Bounds      : %s (original)", GeometryRenderer.getBounds(bounds));

            // display the envelope in both the original CRS and a common CRS (e.g. WGS84),
            // which will probably be more intuitive to the user
            if (!GeometryRenderer.isCrsEquivalent(crs, displayCrs)) {
              print("  Bounds      : %s", GeometryRenderer.getBoundsInCrs(bounds, displayCrs));
            }
          }
        }


        if (data instanceof Relation) {
          Relation relation = (Relation) data;
          if (relation == null) {
            return;
          }

          print("  Attributes  :");
          java.util.List<StructMember> members = relation.getType().getMembers();
          for (StructMember member : members) {
            print("    %s[%s]", member.getKey(), member.getType());
          }


          StructMember sm = relation.getSpatialMetadata().map(SpatialMetadata::getGeometryStructMember).orElse(null);
          CoordinateReferenceSystem crs = relation.getSpatialMetadata().map(SpatialMetadata::getCrs).orElse(null);

          if (crs != null) {
            print("  Axis-order  : %s", GeometryRenderer.getAxisOrder(crs));
            print("  CRS code    : %s", GeometryRenderer.getCode(crs));
            print("  CRS (full)  : %s", crs);
          }

          if (!brief) {

            print("  Summarizing...", relation.toString());

            Envelope env = null;
            try (TupleIterator iterator = relation.iterator()) {
              long count = 0;
              while (iterator.hasNext()) {
                Tuple tuple = iterator.next();
                count++;

                if (sm != null) {
                  Geometry gm = tuple.fetch(sm);

                  if (gm == null) {
                    continue;
                  }
                  Envelope specific = gm.getEnvelopeInternal();

                  if (specific != null) {
                    if (env == null) {
                      env = specific;
                    }
                    env.expandToInclude(specific);
                  }
                }
              }
              print("  Row count   : %d", count);
              if (env != null) {
                ReferencedEnvelope bounds =
                    new ReferencedEnvelope(env.getMinX(), env.getMaxX(), env.getMinY(), env.getMaxY(), crs);
                print("  Bounds      : %s (original)", GeometryRenderer.getBounds(bounds));

                // display the envelope in both the original CRS and a common CRS (e.g. WGS84),
                // which will probably be more intuitive to the user
                if (!GeometryRenderer.isCrsEquivalent(crs, displayCrs)) {
                  print("  Bounds      : %s", GeometryRenderer.getBoundsInCrs(bounds, displayCrs));
                }
              }
            }
          }
        }
    }

    private void printProblems(java.util.List<Problem> problems) {
      if (!problems.isEmpty()) {
        print("  Problems:");
        for (Problem problem : problems) {
          stdout().println(getMessages().renderProblem(problem).toString(options -> options.prefixAll = "    "));
          stdout().println();
        }
      }
    }
  }

  @Command(name = "list")
  public static class BookmarkListCommand extends ApplicationCommand {

    @Override
    public Object doCommand(Project project) {
      java.util.List<Bookmark> bookmarks = project.getBookmarks().getReferences().stream().map(ref ->
        ref.getResult().orElse(
            Bookmark.builder().id(ref.getId()).description(this.formatFailedObjectErrorsForTable(ref)).build()
        )
      ).collect(Collectors.toList());

      return Table.fromList(
          bookmarks,
          Bookmark.class,
          getMessages().getLabels(),
          Arrays.asList(
              Property.of("id", Bookmark::getId),
              Property.of("description", Bookmark::getDescription),
              Property.of("location", b -> b.getLocation() == null ? "" : b.getLocation().toString())
          )
      );

    }
  }

  @Command(name = "info")
  public static class Info extends BookmarksCommand.Base {

    @Parameters
    public String bookmarkId;

    @Override
    public Object doCommand(Project project) {
      doDetailedDump(resolve(project, bookmarkId, getExecutionContext()));

      return null;
    }
  }

  @Command(
      name = "eval",
      aliases = "evaluate")
  public static class Eval extends ApplicationCommand {
    @Parameters(index = "0")
    public String bookmarkId;

    @Option(names = {"-f", "--format"})
    public String format;

    //generate shapefile that contains the input data as it would be used in the model.
    @Override
    public Object doCommand(Project useProject) {
      // sanity-check that the bookmark exists and is valid (which resolve does)
      resolve(useProject, bookmarkId, getExecutionContext());

      String saveFormat = "";
      if (format != null) {
        Format f = useProject.newBindingContext().bind(this.format, Format.class).orElseThrow(problems -> {
          throw new ExitException(Problems.foundWith("--format", problems));
        });
        saveFormat = String.format(", format: '%s'", f.getId());
      }
      String outputFilename = bookmarkIdToOutputName(bookmarkId);

      // we just want to run a simple pipeline that just loads the bookmark data as input
      String pipelineDsl = String.format("input('%s') -> save('%s'%s)", bookmarkId, outputFilename, saveFormat);

      // offload the work to the 'pipeline eval' command
      PipelineCommand.Eval command = new PipelineCommand.Eval();
      command.setEngine(getEngine());
      command.setProject(useProject);
      command.setTerminal(getTerminal());
      command.setMessages(getMessages());

      command.pipelineFile = pipelineDsl;
      command.runnerOptions.replace = true;
      command.runnerOptions.output = Paths.get(useProject.getOutputBaseLocation().resolve("bookmark-eval"))
          .toAbsolutePath().toString();

      return command.run();
    }

    /**
     * Translates the giving bookmark ID or URI into a usable output name.
     *
     * The returned name will not contain any slashes and will have anything that looks like
     * a trailing file extension removed.
     */
    String bookmarkIdToOutputName(String idOrUri) {
      String outputFilename = idOrUri;

      // start by removing any trailing slashes. they are not useful
      while (outputFilename.lastIndexOf("/") == outputFilename.length() - 1) {
        // if the last character is / we drop it
        outputFilename = outputFilename.substring(0, outputFilename.length() - 1);
      }
      if (outputFilename.contains("/")) {
        // if there are any slashes then we need to extract the last useful path element
        outputFilename = outputFilename.substring(outputFilename.lastIndexOf("/") + 1);
      }
      // if this is a filepath rather than an ID, strip off any file extension
      outputFilename = outputFilename.replaceAll("\\.[^.]*$", "");

      return outputFilename;
    }

  }

}
