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

import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.LinkedList;
import java.util.List;
import java.util.regex.Pattern;

import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.WKTWriter;

import com.google.common.base.Joiner;

import lombok.Getter;
import lombok.NonNull;
import nz.org.riskscape.engine.RiskscapeException;
import nz.org.riskscape.engine.Tuple;
import nz.org.riskscape.engine.problem.ProblemFactory;
import nz.org.riskscape.engine.resource.CreateHandle;
import nz.org.riskscape.engine.types.Geom;
import nz.org.riskscape.engine.types.Nullable;
import nz.org.riskscape.engine.types.Struct;
import nz.org.riskscape.engine.types.Struct.StructMember;
import nz.org.riskscape.problem.Problem;
import nz.org.riskscape.problem.ProblemSink;
import nz.org.riskscape.problem.Problems;
import nz.org.riskscape.problem.Problem.Severity;
import nz.org.riskscape.engine.types.Type;

/**
 * Very simple csv writer, to avoid the ceremony and geometry-required-ness of the geotools one.
 * Quotes values with double quotes if they contain a double quote, a comma or a return character.
 */
public class CsvWriter extends RiskscapeWriter implements AutoCloseable {

  public static final char BOM = '\ufeff';

  interface LocalProblems extends ProblemFactory {
    Problem dataTruncated(String columnName, long rowNumber);
    Problem warningLimitReached(int maxWarnings);
  }

  static final LocalProblems PROBLEMS = Problems.get(LocalProblems.class);

  /**
   * Per column character limit beyond which column content will be truncated. This is because Excel
   * has a limit of 32,767 characters and will not display a file that contains columns that exceed
   * that limit.
   *
   * Ideally this would be configurable, but we have no facility for passing options to output formats (GL#420)
   */
  public static final int COLUMN_MAX_CHARACTERS = 32767;

  private Pattern naughtyCharacter = Pattern.compile("[\\n,\"]");
  private static final char[] SEPARATOR = ",".toCharArray();
  private static final char[] LINE_SEPARATOR = String.format("%n").toCharArray();

  private static final int MAX_WARNINGS = 100;
  private final Struct type;
  private final CreateHandle handle;
  private final boolean includeBom;
  private final Writer writer;
  private boolean headerWritten = false;
  private WKTWriter wktWriter = new WKTWriter();
  private int truncationWarningsGiven = 0;
  private long rowNumber = 1;
  private ProblemSink sink;
  @Getter
  private URI storedAt = null;

  public CsvWriter(Struct type, ProblemSink problemSink, CreateHandle handle, boolean includeBom) {
    this.type = type;
    this.sink = problemSink;
    this.handle = handle;
    this.writer = new OutputStreamWriter(handle.getOutputStream(), StandardCharsets.UTF_8);
    this.includeBom = includeBom;
  }

  /**
   * Create a {@link RiskscapeWriter} that will write to the given writer.
   *
   * Note that {@link #getStoredAt() } will always return null for a writer created using this constructor.
   */
  public CsvWriter(Struct type, ProblemSink problemSink, Writer writer) {
    this.type = type;
    this.sink = problemSink;
    this.handle = null;
    this.writer = writer;
    this.includeBom = true;
  }

  @Override
  public void close() throws IOException {
    // we will need to write the header on close if no tuples have been written
    writeHeaderIfNecessary();
    writer.close();
    // set the uri from the handle
    storedAt = handle != null ? handle.store() : null;
  }

  @Override
  public void write(@NonNull Tuple value) {
    try {
      writeHeaderIfNecessary();
      write(type, value, true);
      // TODO configurable line separator?
      writer.write(LINE_SEPARATOR);
    } catch (IOException e) {
      throw new RiskscapeException("I/O error writing tuples to csv", e);
    }
  }

  protected boolean write(Struct struct, Tuple value, boolean first) throws IOException {
    List<StructMember> members = struct.getMembers();
    for (StructMember member : members) {
      Type unwrappedType = Nullable.unwrap(member.getType());
      if (unwrappedType instanceof Struct) {
        Tuple child = value == null ? null : value.fetch(member);
        first = write((Struct) unwrappedType, child, first);
      } else {
        Object toWrite = value == null ? null : value.fetch(member);

        if (!first) {
          writer.write(SEPARATOR);
        }
        if (toWrite != null) {
          if (unwrappedType instanceof Geom) {
            toWrite = wktWriter.write((Geometry) toWrite);
          }
          String toWriteStr = toWrite.toString();
          if (toWriteStr.length() > COLUMN_MAX_CHARACTERS) {
            // the content is too big to fit into an Excel cell. We truncate it.
            toWriteStr = toWriteStr.substring(0, COLUMN_MAX_CHARACTERS - 1);
            if (truncationWarningsGiven < MAX_WARNINGS) {
              sink.log(PROBLEMS.dataTruncated(member.getKey(), rowNumber).withSeverity(Severity.WARNING));
              truncationWarningsGiven++;

              if (truncationWarningsGiven == MAX_WARNINGS) {
                sink.log(PROBLEMS.warningLimitReached(MAX_WARNINGS).withSeverity(Severity.WARNING));
              }
            }
          }
          writer.write(quote(toWriteStr));
        }
        first = false;
      }
    }

    rowNumber++;

    return first;
  }

  private String quote(String toQuote) {
    boolean requiresQuotes = naughtyCharacter.matcher(toQuote).find();
    if (requiresQuotes) {
      String quoted = toQuote.replaceAll("\"", "\"\"");
      return '"' + quoted + '"';
    } else {
      return toQuote;
    }
  }

  private void writeHeaderIfNecessary() throws IOException {
    if (!headerWritten) {
      if (includeBom) {
        writer.write(BOM);
      }
      writeHeader(type, true, new LinkedList<>());
      headerWritten = true;
      writer.write(LINE_SEPARATOR);
    }
  }

  private boolean writeHeader(Struct structType, boolean first, LinkedList<String> parents) throws IOException {
    for (StructMember member : structType.getMembers()) {
      LinkedList<String> toWrite = new LinkedList<>(parents);
      toWrite.add(member.getKey());

      Struct unwrapped = member.getType().findAllowNull(Struct.class).orElse(null);
      if (unwrapped != null) {
        first = writeHeader(unwrapped, first, toWrite);
      } else {
        if (!first) {
          writer.write(SEPARATOR);
        }
        writer.write(quote(Joiner.on(".").join(toWrite)));
      }
      first = false;
    }
    return first;
  }

}
