/*
 * 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 static nz.org.riskscape.cli.Terminal.*;

import java.io.IOException;
import java.io.PrintStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.text.BreakIterator;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Collectors;

import com.google.common.base.Strings;
import com.google.common.collect.Lists;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import nz.org.riskscape.cli.Terminal;
import nz.org.riskscape.engine.OsUtils;
import nz.org.riskscape.engine.i18n.MessageSource;
import nz.org.riskscape.engine.i18n.RiskscapeMessageUtils;
import nz.org.riskscape.picocli.CommandLine.Help.Ansi;
import nz.org.riskscape.picocli.CommandLine.Help.Ansi.IStyle;
import nz.org.riskscape.picocli.CommandLine.Help.Ansi.Text;

/**
 * Wrap a list to tell the CLI to print it out as a table
 */
public class Table {

  public static Ansi defaultAnsi = Ansi.AUTO;

  /**
   * Helpful for separating paragraphs within the same row in a table.
   */
  // TODO two newlines together screws up the table formatting. Adding a space in between avoids the problem
  public static final String LINE_BREAK = OsUtils.LINE_SEPARATOR + " " + OsUtils.LINE_SEPARATOR;

  /**
   * A regex that matches common line endings as used on *nix, Windows and Mac.
   *
   * Can be used with {@link String#split(java.lang.String) } to split a string into lines.
   */
  public static final String LINE_ENDINGS_REGEX = "\r\n|\r|\n";

  /**
   * Labels and provides access to fields for a particular value.  The labels will be used in conjunction
   * with the class name to do an i18n lookup for the header label
   */
  @RequiredArgsConstructor
  public static final class Property<T> {
    /**
     * Builds a property that can be used for populating a {@link Table} with unstyled text
     *
     * @param <T> type of object being displayed
     * @param field name of the field the tabulated object that this property displays - does not have to map to a real
     * field of java-bean style method.
     * @param callback A function to use for producing unstyled text that describes this property against the given
     * object, e.g. `(Integer dollars) -> "$" + dollars.toString()`
     */
    public static <T> Property<T> of(String field, Function<T, String> callback) {
      return new Property<>(field, callback.andThen(str -> applyAnsi(str, defaultAnsi)));
    }

    /**
     * Builds a property that can be used for populating a {@link Table} with styled text
     *
     * @param <T> type of object being displayed
     * @param field name of the field the tabulated object that this property displays - does not have to map to a real
     * field of java-bean style method.
     * @param callback A function to use for producing styled text that describes this property against the given
     * object, e.g.
     * `(Ansi ansi, Integer dollars) ->
     * ansi.apply("$" Style.fg_green).concat(ansi.apply(dollars.toString(), NO_STYLES))`
     *
     */
    public static <T> Property<T> styled(String field, BiFunction<T, Ansi, Text> callback) {
      return new Property<>(field, (t) -> callback.apply(t, defaultAnsi));
    }

    /**
     * Build a very basic property that uses a java bean style get method to get the value of the field from the method.
     */
    public static <T> Property<T> of(String fieldName) {
      final String methodName = "get" + fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1);
      AtomicReference<Method> accessor = new AtomicReference<>();
      return new Property<>(fieldName, (item) -> {
        Method method = accessor.get();
        if (method == null || method.getClass() != item.getClass()) {
          try {
            method = item.getClass().getMethod(methodName);
            accessor.set(method);
          } catch (SecurityException | ReflectiveOperationException | IllegalArgumentException e) {
            throw new RuntimeException("Failed to lookup method for field " + fieldName, e);
          }
        }

        Object result;
        try {
          result = method.invoke(item);
        } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
          throw new RuntimeException("Failed to call method for field " + fieldName, e);
        }

        return applyAnsi(result, defaultAnsi);
      });
    }

    public final String field;
    public final Function<T, Text> callback;
  }

  public static List<List<Text>> rowsToText(List<List<String>> rows, IStyle... styles) {
    return Lists.transform(rows, row -> rowToText(row, styles));
  }

  /**
   * Applies ANSI to table strings/objects with a null-safetry check
   */
  private static Text applyAnsi(Object applyTo, Ansi ansi, IStyle... styles) {
    return ansi.apply(applyTo == null ? "" : applyTo.toString(), Arrays.asList(styles));
  }

  public static List<Text> rowToText(List<String> things, IStyle... styles) {
    return Lists.transform(things, str -> applyAnsi(str, defaultAnsi, styles));
  }

  private static <T> List<List<Text>> getRows(List<T> items, List<Property<T>> properties) {
    return Lists.transform(items, item -> {
      return Lists.transform(properties, property -> property.callback.apply(item));
    });
  }

  /**
   * Construct a translated table header from the class and {@link Property}s being tabulated.
   */
  public static <T, U> List<Text> getHeaders(MessageSource ms, Class<T> itemClass, List<Property<U>> props) {
    return Lists.transform(props, p -> {
      String str = ms.getMessage(RiskscapeMessageUtils.forFieldLabel(p.field, itemClass));
      return applyAnsi(str, defaultAnsi);
    });
  }

  /**
   * Produces a {@link Table} from the collection.
   *
   * I18N table headings are produced by using
   * {@link RiskscapeMessageUtils#forFieldLabel(java.lang.String, java.lang.Class)}
   *
   * @param <T>
   * @param items to put in table
   * @param itemClass class of all items. Used to generate I18N keys
   * @param props what properties of itemClass to have in table
   * @return a {@link Table} from the collection.
   */
  public static <T> Table fromList(List<T> items, Class<?> itemClass, MessageSource messages,
      List<Property<T>> props) {

    return new Table(
        getHeaders(messages, itemClass, props),
        getRows(items, props)
    );
  }

  /**
   * Construct a {@link Table} from a list of plain ol' java objects, using a list of property names as the header and
   * the accessors.
   * @param <T>
   * @param items a list of items to include in the table
   * @param propertyNames
   * @return a {@link Table}
   */
  public static <T> Table fromList(List<T> items, String... propertyNames) {
    List<String> propertyNameList = Arrays.asList(propertyNames);
    List<Property<T>> props = Lists.transform(
      propertyNameList,
      name -> Property.of(name)
    );

    return new Table(
      rowToText(propertyNameList),
      getRows(items, props)
    );
  }

  /**
   * Initial list capacity during buffer breakdown
   */
  private static final int LINES_CAPACITY = 10;

  @Getter
  private final List<List<Text>> rows;

  @Getter
  private final List<Text> header;

  public static Table fromStrings(List<List<String>> strings) {
    return new Table(
        rowToText(strings.get(0)),
        rowsToText(strings.subList(1, strings.size()))
    );
  }

  public Table(List<Text> header, List<List<Text>> rows) {
    this.header = header;
    // we sort the rows by the first column
    this.rows = rows.stream()
        .sorted((row1, row2) -> row1.get(0).plainString().compareTo(row2.get(0).plainString()))
        .collect(Collectors.toList());
  }

  private List<Integer> computeColumnWidths(final int termWidth) {
    List<Integer> widths =
        header.stream().map(Text::getCJKAdjustedLength).collect(Collectors.toList());

    List<Integer> maxWidths = Lists.newArrayList(widths);

    // start by calculating the largest width they'd need
    rows.forEach(row -> {
      for (int i = 0; i < widths.size(); i++) {
        Text content = row.get(i);
        if (content == null) {
          continue;
        }
        String plainText = content.plainString();
        //Content may contain line feeds. We we want to split on line feed and find the longest part.
        for (String part: plainText.split(LINE_ENDINGS_REGEX)) {
          maxWidths.set(i, Math.max(maxWidths.get(i), part.length()));
        }
      }
    });

    final int fairWidth = termWidth / widths.size();

    // if maxwidths exceeds termWidth, which is quite likely, distribute
    // available space evenly between columns that need more space than the evenly shared amount
    if (maxWidths.stream().collect(Collectors.summingInt(integer -> integer)) > termWidth) {

      int numFlexColumns = 0;
      int availableFlexSpace = 0;
      for (int i = 0; i < maxWidths.size(); i++) {
        if (maxWidths.get(i) > fairWidth) {
          numFlexColumns++;
          availableFlexSpace += fairWidth;
          // set it to -1 so we can detect a gap for latter filling
          maxWidths.set(i, -1);
        } else {
          availableFlexSpace += fairWidth - maxWidths.get(i);
        }
      }

      int flexColumnWidth = availableFlexSpace /  numFlexColumns;
      for (int i = 0; i < maxWidths.size(); i++) {
        if (maxWidths.get(i) == -1) {
          maxWidths.set(i, flexColumnWidth);
        }
      }

      // spread any rounding errors across widths fairly but arbitrarily
      int currentTotalWidth = maxWidths.stream().collect(Collectors.summingInt(integer -> integer));
      while (currentTotalWidth < termWidth) {
        int idx = currentTotalWidth % maxWidths.size();
        maxWidths.set(idx, maxWidths.get(idx) + 1);
        currentTotalWidth++;
      }
    }

    return maxWidths;

  }

  /**
   * Display the given table for a specific width
   * @param terminal required for dynamic height and width and print stream
   */
  public void print(Terminal terminal) {
    // subtract the number of spaces used for table borders
    final int displayWidth = terminal.getDisplayWidth();
    final int displayHeight = terminal.getDisplayHeight();
    final PrintStream stream = terminal.getOut();
    final int usableWidth = displayWidth - 1 - header.size();
    int usableHeight = displayHeight - 2; // 2 x hlines
    List<Integer> columnWidths = computeColumnWidths(usableWidth);

    String hline = columnWidths.stream()
        .map(width -> Strings.repeat("-", width))
        .collect(Collectors.joining("+", "+", "+"));

    String rowSep = columnWidths.stream()
        .map(width -> Strings.repeat(" ", width))
        .collect(Collectors.joining("|", "|", "|"));

    stream.println(hline);

    List<List<String>> griddedRow = gridifyRow(columnWidths, header);
    for (List<String> gridRow : griddedRow) {
      stream.print('|');
      for (String cellText : gridRow) {
        stream.print(cellText);
        stream.print('|');
      }
      stream.println();
      usableHeight--;
    }

    stream.println(hline);

    for (int i = 0; i < rows.size(); i++) {

      if (i != 0) {
        stream.println(rowSep);
        usableHeight--;
      }

      List<Text> row = rows.get(i);
      griddedRow = gridifyRow(columnWidths, row);
      for (List<String> gridRow : griddedRow) {
        stream.print('|');
        for (String cellText : gridRow) {
          stream.print(cellText);
          stream.print('|');
        }
        stream.println();
        usableHeight--;

        // paginate the display so it nicely fits the terminal window
        if (usableHeight <= 1 && terminal.isTTY()) {
          waitForUser(terminal);
          usableHeight = displayHeight;
        }
      }
    }

    stream.println(hline);
  }

  /**
   * <p>Transforms a row in to a list of lists, splitting each cell in to multiple lines based on splitting the sentence
   * up in to words using {@link BreakIterator#getLineInstance() ()}</p>
   * <pre><code>
   * // assuming column widths of [8, 8, 10]...
   * ["this big sentence", "not this", "and definitely this one because it is massive"]
   *
   * // transformation would yield...
   * ["this big", "not this", "and       "]
   * ["sentence", "        ", "definitely"],
   * ["        ", "        ", "this one  "],
   * ["        ", "        ", "because it"],
   * ["        ", "        ", "is massive"],
   * </code></pre>
   * @param columnWidths the widths of each column, used for splitting
   * @param row the row to split.
   * @return A list of lists that can be printed out as a grid within the widths specified by columnWidths
   */
  private List<List<String>> gridifyRow(List<Integer> columnWidths, List<Text> row) {
    List<List<Text>> columns = Lists.newArrayList();
    for (int i = 0; i < row.size(); i++) {
      Text cellText = row.get(i);

      if (cellText == null) {
        cellText = EMPTY_TEXT;
      }

      int width = columnWidths.get(i);

      List<Text> splitText = splitText(width, cellText);
      columns.add(splitText);
    }

    int numRows = columns.stream().map(l -> l.size()).collect(Collectors.maxBy(Integer::compareTo)).orElse(0);

    List<List<String>> gridded = Lists.newArrayList();
    for (int i = 0; i < numRows; i++) {
      List<String> gridRow = Lists.newArrayListWithCapacity(row.size());
      gridded.add(gridRow);
      for (int columnIndex = 0; columnIndex < columns.size(); columnIndex++) {
        List<Text> column = columns.get(columnIndex);
        if (column.size() > i) {
          Text cellText = column.get(i);
          // we need to add trailing padding to the content. We use the CJKAdjustedLength as that
          // takes into account characters that are double width etc
          gridRow.add(cellText.toString()
              + Strings.repeat(" ", columnWidths.get(columnIndex) - cellText.getCJKAdjustedLength()));
        } else {
          gridRow.add(Strings.padEnd("", columnWidths.get(columnIndex), ' '));
        }
      }
    }

    return gridded;
  }

  /**
   * Routine for splitting a line of text in to a list of lines that will fit within the given width exactly.  Existing
   * line breaks are respected.
   */
  public static List<Text> splitText(int maxWidth, Text cellText) {
    //line instance breaks on words(plus any associated punctuation)
    BreakIterator tokenizer = BreakIterator.getLineInstance();

    List<Text> lines = Lists.newArrayListWithCapacity(LINES_CAPACITY);
    String plainString = cellText.plainString();
    boolean newline = true;

    int paraPos = 0;
    // we split the text on common line endings so we can process one line at a time
    for (String paraString: plainString.split(LINE_ENDINGS_REGEX)) {
      // we need to find the start of paraString in plainString. This is because when paraPos is advanced
      // it is advanced by the length of paraString (so doesn't include the line separator characters
      paraPos = plainString.indexOf(paraString, paraPos);

      Text paraText = cellText.substring(paraPos, paraString.length());
      // advance paraPost by the length of the string
      paraPos += paraString.length();

      Text ansiBuffer = defaultAnsi.apply("", NO_STYLES);
      int currentLength = 0;
      tokenizer.setText(paraString);
      int start = tokenizer.first();
      for (int end = tokenizer.next(); end != BreakIterator.DONE; start = end, end = tokenizer.next()) {
        Text textPiece = paraText.substring(start, end);
        String plainPiece = textPiece.plainString();
        String plainPieceTrimmed = plainPiece.trim();
        Text trimmed = textPiece.substring(0, plainPieceTrimmed.length());
        // don't bother starting a new line with whitespace - consume the token and move on
        if (newline && Strings.isNullOrEmpty(plainPiece.trim())) {
          continue;
        }

        // if the chunk fits on the line, add it
        if (currentLength + textPiece.getCJKAdjustedLength()<= maxWidth) {
          newline = false;
          ansiBuffer = ansiBuffer.concat(textPiece);
          currentLength += textPiece.getCJKAdjustedLength();
        // if the chunk fits perfectly without whitespace (should only be trailing because of how the tokenizer works
        // then add the trimmed chunk
        } else if (currentLength + trimmed.getCJKAdjustedLength() == maxWidth) {
          newline = false;
          ansiBuffer = ansiBuffer.concat(trimmed);
          currentLength += trimmed.getCJKAdjustedLength();
        } else {

          if (newline) {
            // uh oh, this piece of text won't fit - throw away bit that's too long
            Text fragment = textPiece.substring(0, maxWidth - 1);
            ansiBuffer = ansiBuffer.concat(fragment).concat(Character.toString(ELLIPSIS));
            currentLength += fragment.getCJKAdjustedLength();

          } else {
            // go back a position, try again
            end = tokenizer.previous();
            start = tokenizer.previous();
          }

          newline = true;
          lines.add(ansiBuffer);
          ansiBuffer = EMPTY_TEXT;
          currentLength = 0;
        }
      }

      if (ansiBuffer.plainString().length() != 0) {
        lines.add(ansiBuffer);
      }
    }

    return lines;
  }

  /**
   * Block until enter is pressed
   * Java buffers System.in until the enter key is pressed. This is why we require the enter key to be pressed
   * and ignore other key presses.
   */
  private void waitForUser(Terminal terminal) {
    terminal.getOut().format("Press enter to continue...").flush();
    try {
      // block until enter key is pushed
      terminal.readline();
    } catch (IOException e) {
      terminal.getOut().println(e);
    }
  }
}
