// Copyright (c) 2022, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import '../ast.dart';
import '../block_parser.dart';
import '../charcode.dart';
import '../line.dart';
import '../patterns.dart';
import '../text_parser.dart';
import '../util.dart';
import 'block_syntax.dart';
import 'ordered_list_with_checkbox_syntax.dart';
import 'unordered_list_with_checkbox_syntax.dart';

class ListItem {
  const ListItem(
    this.lines, {
    this.taskListItemState,
  });

  final List<Line> lines;
  final TaskListItemState? taskListItemState;
}

enum TaskListItemState { checked, unchecked }

/// Base class for both ordered and unordered lists.
abstract class ListSyntax extends BlockSyntax {
  @override
  bool canParse(BlockParser parser) =>
      pattern.hasMatch(parser.current.content) &&
      !hrPattern.hasMatch(parser.current.content);

  @override
  bool canEndBlock(BlockParser parser) {
    // An empty list cannot interrupt a paragraph. See
    // https://spec.commonmark.org/0.30/#example-285.
    // Ideally, [BlockSyntax.canEndBlock] should be changed to be a method
    // which accepts a [BlockParser], but this would be a breaking change,
    // so we're going with this temporarily.
    final match = pattern.firstMatch(parser.current.content)!;

    // Allow only lists starting with 1 to interrupt paragraphs, if it is an
    // ordered list. See https://spec.commonmark.org/0.30/#example-304.
    // But there should be an exception for nested ordered lists, for example:
    // ```
    // 1. one
    // 2. two
    //   3. three
    //   4. four
    // 5. five
    // ```
    if (parser.parentSyntax is! ListSyntax &&
        match[1] != null &&
        match[1] != '1') {
      return false;
    }

    // An empty list item cannot interrupt a paragraph. See
    // https://spec.commonmark.org/0.30/#example-285
    return match[2]?.isNotEmpty ?? false;
  }

  const ListSyntax();

  /// A list of patterns that can start a valid block within a list item.
  static final blocksInList = [
    blockquotePattern,
    headerPattern,
    hrPattern,
    indentPattern,
    listPattern,
  ];

  @override
  Node parse(BlockParser parser) {
    final match = pattern.firstMatch(parser.current.content);
    final ordered = match![1] != null;

    final taskListParserEnabled = this is UnorderedListWithCheckboxSyntax ||
        this is OrderedListWithCheckboxSyntax;
    final items = <ListItem>[];
    var childLines = <Line>[];
    TaskListItemState? taskListItemState;

    void endItem() {
      if (childLines.isNotEmpty) {
        items.add(ListItem(childLines, taskListItemState: taskListItemState));
        childLines = <Line>[];
      }
    }

    String parseTaskListItem(String text) {
      final pattern = RegExp(r'^ {0,3}\[([ xX])\][ \t]');

      if (taskListParserEnabled && pattern.hasMatch(text)) {
        return text.replaceFirstMapped(pattern, (match) {
          taskListItemState = match[1] == ' '
              ? TaskListItemState.unchecked
              : TaskListItemState.checked;

          return '';
        });
      } else {
        taskListItemState = null;
        return text;
      }
    }

    late Match? possibleMatch;
    bool tryMatch(RegExp pattern) {
      possibleMatch = pattern.firstMatch(parser.current.content);
      return possibleMatch != null;
    }

    String? listMarker;
    int? indent;
    // In case the first number in an ordered list is not 1, use it as the
    // "start".
    int? startNumber;

    int? blankLines;

    while (!parser.isDone) {
      final currentIndent = parser.current.content.indentation() +
          (parser.current.tabRemaining ?? 0);

      if (parser.current.isBlankLine) {
        childLines.add(parser.current);

        if (blankLines != null) {
          blankLines++;
        }
      } else if (indent != null && indent <= currentIndent) {
        // A list item can begin with at most one blank line. See:
        // https://spec.commonmark.org/0.30/#example-280
        if (blankLines != null && blankLines > 1) {
          break;
        }

        final indentedLine = parser.current.content.dedent(indent);

        childLines.add(Line(
          blankLines == null
              ? indentedLine.text
              : parseTaskListItem(indentedLine.text),
          tabRemaining: indentedLine.tabRemaining,
        ));
      } else if (tryMatch(hrPattern)) {
        // Horizontal rule takes precedence to a new list item.
        break;
      } else if (tryMatch(listPattern)) {
        blankLines = null;
        final match = possibleMatch!;
        final textParser = TextParser(parser.current.content);
        var precedingWhitespaces = textParser.moveThroughWhitespace();
        final markerStart = textParser.pos;
        final digits = match[1] ?? '';
        if (digits.isNotEmpty) {
          startNumber ??= int.parse(digits);
          textParser.advanceBy(digits.length);
        }
        textParser.advance();

        // See https://spec.commonmark.org/0.30/#ordered-list-marker
        final marker = textParser.substring(
          markerStart,
          textParser.pos,
        );

        var isBlank = true;
        var contentWhitespances = 0;
        var containsTab = false;
        int? contentBlockStart;

        if (!textParser.isDone) {
          containsTab = textParser.charAt() == $tab;
          // Skip the first whitespace.
          textParser.advance();
          contentBlockStart = textParser.pos;
          if (!textParser.isDone) {
            contentWhitespances = textParser.moveThroughWhitespace();

            if (!textParser.isDone) {
              isBlank = false;
            }
          }
        }

        // Changing the bullet or ordered list delimiter starts a new list.
        if (listMarker != null && listMarker.last() != marker.last()) {
          break;
        }

        // End the current list item and start a new one.
        endItem();

        // Start a new list item, the last item will be ended up outside of the
        // `while` loop.
        listMarker = marker;
        precedingWhitespaces += digits.length + 2;
        if (isBlank) {
          // See https://spec.commonmark.org/0.30/#example-278.
          blankLines = 1;
          indent = precedingWhitespaces;
        } else if (contentWhitespances >= 4) {
          // See https://spec.commonmark.org/0.30/#example-270.
          //
          // If the list item starts with indented code, we need to _not_ count
          // any indentation past the required whitespace character.
          indent = precedingWhitespaces;
        } else {
          indent = precedingWhitespaces + contentWhitespances;
        }

        taskListItemState = null;
        var content = contentBlockStart != null && !isBlank
            ? parseTaskListItem(textParser.substring(contentBlockStart))
            : '';

        if (content.isEmpty && containsTab) {
          content = content.prependSpace(2);
        }

        childLines.add(Line(
          content,
          tabRemaining: containsTab ? 2 : null,
        ));
      } else if (BlockSyntax.isAtBlockEnd(parser)) {
        // Done with the list.
        break;
      } else {
        // If the previous item is a blank line, this means we're done with the
        // list and are starting a new top-level paragraph.
        if (childLines.isNotEmpty && childLines.last.isBlankLine) {
          parser.encounteredBlankLine = true;
          break;
        }

        // Anything else is paragraph continuation text.
        childLines.add(parser.current);
      }
      parser.advance();
    }

    endItem();
    final itemNodes = <Element>[];

    items.forEach(_removeLeadingEmptyLine);
    final anyEmptyLines = _removeTrailingEmptyLines(items);
    var anyEmptyLinesBetweenBlocks = false;
    var containsTaskList = false;
    const taskListClass = 'task-list-item';

    for (final item in items) {
      Element? checkboxToInsert;
      if (item.taskListItemState != null) {
        containsTaskList = true;
        checkboxToInsert = Element.withTag('input')
          ..attributes['type'] = 'checkbox';
        if (item.taskListItemState == TaskListItemState.checked) {
          checkboxToInsert.attributes['checked'] = 'true';
        }
      }

      final itemParser = BlockParser(item.lines, parser.document);
      final children = itemParser.parseLines(parentSyntax: this);
      final itemElement = checkboxToInsert == null
          ? Element('li', children)
          : (Element('li', _addCheckbox(children, checkboxToInsert))
            ..attributes['class'] = taskListClass);

      itemNodes.add(itemElement);
      anyEmptyLinesBetweenBlocks =
          anyEmptyLinesBetweenBlocks || itemParser.encounteredBlankLine;
    }

    // Must strip paragraph tags if the list is "tight".
    // https://spec.commonmark.org/0.30/#lists
    final listIsTight = !anyEmptyLines && !anyEmptyLinesBetweenBlocks;

    if (listIsTight) {
      // We must post-process the list items, converting any top-level paragraph
      // elements to just text elements.
      for (final item in itemNodes) {
        final isTaskList = item.attributes['class'] == taskListClass;
        final children = item.children;
        if (children != null) {
          Node? lastNode;
          for (var i = 0; i < children.length; i++) {
            final child = children[i];
            if (child is Element && child.tag == 'p') {
              final childContent = child.children!;
              if (lastNode is Element && !isTaskList) {
                childContent.insert(0, Text('\n'));
              }

              children
                ..removeAt(i)
                ..insertAll(i, childContent);
            }

            lastNode = child;
          }
        }
      }
    }

    final listElement = Element(ordered ? 'ol' : 'ul', itemNodes);
    if (ordered && startNumber != 1) {
      listElement.attributes['start'] = '$startNumber';
    }

    if (containsTaskList) {
      listElement.attributes['class'] = 'contains-task-list';
    }
    return listElement;
  }

  List<Node> _addCheckbox(List<Node> children, Element checkbox) {
    if (children.isNotEmpty) {
      final firstChild = children.first;
      if (firstChild is Element && firstChild.tag == 'p') {
        firstChild.children!.insert(0, checkbox);
        return children;
      }
    }
    return [checkbox, ...children];
  }

  void _removeLeadingEmptyLine(ListItem item) {
    if (item.lines.isNotEmpty && item.lines.first.isBlankLine) {
      item.lines.removeAt(0);
    }
  }

  /// Removes any trailing empty lines and notes whether any items are separated
  /// by such lines.
  bool _removeTrailingEmptyLines(List<ListItem> items) {
    var anyEmpty = false;
    for (var i = 0; i < items.length; i++) {
      if (items[i].lines.length == 1) continue;
      while (items[i].lines.isNotEmpty && items[i].lines.last.isBlankLine) {
        if (i < items.length - 1) {
          anyEmpty = true;
        }
        items[i].lines.removeLast();
      }
    }
    return anyEmpty;
  }
}
