/*
 * Copyright (c) Tor Norbye.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.facebook.ktfmt.kdoc

class ParagraphListBuilder(
    comment: String,
    private val options: KDocFormattingOptions,
    private val task: FormattingTask
) {
  private val lineComment: Boolean = comment.isLineComment()
  private val commentPrefix: String =
      if (lineComment) "//" else if (comment.isKDocComment()) "/**" else "/*"
  private val paragraphs: MutableList<Paragraph> = mutableListOf()
  private val lines =
      if (lineComment) {
        comment.split("\n").map { it.trimStart() }
      } else if (!comment.contains("\n")) {
        listOf("* ${comment.removePrefix(commentPrefix).removeSuffix("*/").trim()}")
      } else {
        comment.removePrefix(commentPrefix).removeSuffix("*/").trim().split("\n")
      }

  private fun lineContent(line: String): String {
    val trimmed = line.trim()
    return when {
      lineComment && trimmed.startsWith("// ") -> trimmed.substring(3)
      lineComment && trimmed.startsWith("//") -> trimmed.substring(2)
      trimmed.startsWith("* ") -> trimmed.substring(2)
      trimmed.startsWith("*") -> trimmed.substring(1)
      else -> trimmed
    }
  }

  private fun closeParagraph(): Paragraph {
    val text = paragraph.text
    when {
      paragraph.preformatted -> {}
      text.isKDocTag() -> {
        paragraph.doc = true
        paragraph.hanging = true
      }
      text.isTodo() -> {
        paragraph.hanging = true
      }
      text.isListItem() -> paragraph.hanging = true
      text.isDirectiveMarker() -> {
        paragraph.block = true
        paragraph.preformatted = true
      }
    }
    if (!paragraph.isEmpty() || paragraph.allowEmpty) {
      paragraphs.add(paragraph)
    }
    return paragraph
  }

  private fun newParagraph(): Paragraph {
    closeParagraph()
    val prev = paragraph
    paragraph = Paragraph(task)
    prev.next = paragraph
    paragraph.prev = prev
    return paragraph
  }

  private var paragraph = Paragraph(task)

  private fun appendText(s: String): ParagraphListBuilder {
    paragraph.content.append(s)
    return this
  }

  private fun addLines(
      i: Int,
      includeEnd: Boolean = true,
      until: (Int, String, String) -> Boolean = { _, _, _ -> true },
      customize: (Int, Paragraph) -> Unit = { _, _ -> },
      shouldBreak: (String, String) -> Boolean = { _, _ -> false },
      separator: String = " "
  ): Int {
    var j = i
    while (j < lines.size) {
      val l = lines[j]
      val lineWithIndentation = lineContent(l)
      val lineWithoutIndentation = lineWithIndentation.trim()

      if (!includeEnd) {
        if (j > i && until(j, lineWithoutIndentation, lineWithIndentation)) {
          stripTrailingBlankLines()
          return j
        }
      }

      if (shouldBreak(lineWithoutIndentation, lineWithIndentation)) {
        newParagraph()
      }

      if (lineWithIndentation.isQuoted()) {
        appendText(lineWithoutIndentation.substring(2).collapseSpaces())
      } else {
        appendText(lineWithoutIndentation.collapseSpaces())
      }
      appendText(separator)
      customize(j, paragraph)
      if (includeEnd) {
        if (j > i && until(j, lineWithoutIndentation, lineWithIndentation)) {
          stripTrailingBlankLines()
          return j + 1
        }
      }

      j++
    }

    stripTrailingBlankLines()
    newParagraph()

    return j
  }

  private fun addPreformatted(
      i: Int,
      includeStart: Boolean = false,
      includeEnd: Boolean = true,
      expectClose: Boolean = false,
      customize: (Int, Paragraph) -> Unit = { _, _ -> },
      until: (String) -> Boolean = { true },
  ): Int {
    newParagraph()
    var j = i
    var foundClose = false
    var allowCustomize = true
    while (j < lines.size) {
      val l = lines[j]
      val lineWithIndentation = lineContent(l)
      if (lineWithIndentation.contains("```") &&
          lineWithIndentation.trimStart().startsWith("```")) {
        // Don't convert
 tags if we already have nested ``` content; that will lead to
        // trouble
        allowCustomize = false
      }
      val done = (includeStart || j > i) && until(lineWithIndentation)
      if (!includeEnd && done) {
        foundClose = true
        break
      }
      j++
      if (includeEnd && done) {
        foundClose = true
        break
      }
    }

    // Don't convert if there's already a mixture

    // We ran out of lines. This means we had an unterminated preformatted
    // block. This is unexpected(unless it was an indented block) and most
    // likely a documentation error (even Dokka will start formatting return
    // value documentation in preformatted text if you have an opening <pre>
    // without a closing </pre> before a @return comment), but try to backpedal
    // a bit such that we don't apply full preformatted treatment everywhere to
    // things like line breaking.
    if (!foundClose && expectClose) {
      // Just add a single line as preformatted and then treat the rest in the
      // normal way
      allowCustomize = false
      j = lines.size
    }

    for (index in i until j) {
      val l = lines[index]
      val lineWithIndentation = lineContent(l)
      appendText(lineWithIndentation)
      paragraph.preformatted = true
      paragraph.allowEmpty = true
      if (allowCustomize) {
        customize(index, paragraph)
      }
      newParagraph()
    }
    stripTrailingBlankLines()
    newParagraph()

    return j
  }

  private fun stripTrailingBlankLines() {
    for (p in paragraphs.size - 1 downTo 0) {
      val paragraph = paragraphs[p]
      if (!paragraph.isEmpty()) {
        break
      }
      paragraphs.removeAt(p)
    }
  }

  fun scan(indentSize: Int): ParagraphList {
    var i = 0
    while (i < lines.size) {
      val l = lines[i++]
      val lineWithIndentation = lineContent(l)
      val lineWithoutIndentation = lineWithIndentation.trim()

      fun newParagraph(i: Int): Paragraph {
        val paragraph = this.newParagraph()

        if (i >= 0 && i < lines.size) {
          if (lines[i] == l) {
            paragraph.originalIndent = lineWithIndentation.length - lineWithoutIndentation.length
          } else {
            // We've looked ahead, e.g. when adding lists etc
            val line = lineContent(lines[i])
            val trimmed = line.trim()
            paragraph.originalIndent = line.length - trimmed.length
          }
        }
        return paragraph
      }

      if (lineWithIndentation.startsWith("    ") && // markdown preformatted text
      (i == 1 || lineContent(lines[i - 2]).isBlank()) && // we've already ++'ed i above
          // Make sure it's not just deeply indented inside a different block
          (paragraph.prev == null ||
              lineWithIndentation.length - lineWithoutIndentation.length >=
                  paragraph.prev!!.originalIndent + 4)) {
        i = addPreformatted(i - 1, includeEnd = false, expectClose = false) { !it.startsWith(" ") }
      } else if (lineWithoutIndentation.startsWith("-") &&
          lineWithoutIndentation.containsOnly('-', '|', ' ')) {
        val paragraph = newParagraph(i - 1)
        appendText(lineWithoutIndentation)
        newParagraph(i).block = true
        // Dividers must be surrounded by blank lines
        if (lineWithIndentation.isLine() &&
            (i < 2 || lineContent(lines[i - 2]).isBlank()) &&
            (i > lines.size - 1 || lineContent(lines[i]).isBlank())) {
          paragraph.separator = true
        }
      } else if (lineWithoutIndentation.startsWith("=") &&
          lineWithoutIndentation.containsOnly('=', ' ')) {
        // Header
        // ======
        newParagraph(i - 1).block = true
        appendText(lineWithoutIndentation)
        newParagraph(i).block = true
      } else if (lineWithoutIndentation.startsWith("#")
      // "## X" is a header, "##X" is not
      &&
          lineWithoutIndentation.firstOrNull { it != '#' }?.equals(' ') ==
              true) { // not isHeader() because <h> is handled separately
        // ## Header
        newParagraph(i - 1).block = true
        appendText(lineWithoutIndentation)
        newParagraph(i).block = true
      } else if (lineWithoutIndentation.startsWith("*") &&
          lineWithoutIndentation.containsOnly('*', ' ')) {
        // Horizontal rule:
        // *******
        // * * *
        // Unlike --- lines, these aren't required to be preceded by or followed by
        // blank lines.
        newParagraph(i - 1).block = true
        appendText(lineWithoutIndentation)
        newParagraph(i).block = true
      } else if (lineWithoutIndentation.startsWith("```")) {
        i = addPreformatted(i - 1, expectClose = true) { it.trimStart().startsWith("```") }
      } else if (lineWithoutIndentation.startsWith("<pre>", ignoreCase = true)) {
        i =
            addPreformatted(
                i - 1,
                includeStart = true,
                expectClose = true,
                customize = { _, _ ->
                  if (options.convertMarkup) {
                    fun handleTag(tag: String) {
                      val text = paragraph.text
                      val trimmed = text.trim()

                      val index = text.indexOf(tag, ignoreCase = true)
                      if (index == -1) {
                        return
                      }
                      paragraph.content.clear()
                      if (trimmed.equals(tag, ignoreCase = true)) {
                        paragraph.content.append("```")
                        return
                      }

                      // Split paragraphs; these things have to be on their own line
                      // in the ``` form (unless both are in the middle)
                      val before = text.substring(0, index).replace("</code>", "", true).trim()
                      if (before.isNotBlank()) {
                        paragraph.content.append(before)
                        newParagraph()
                        paragraph.preformatted = true
                        paragraph.allowEmpty = true
                      }
                      appendText("```")
                      val after =
                          text.substring(index + tag.length).replace("", "", true).trim()
                      if (after.isNotBlank()) {
                        newParagraph()
                        appendText(after)
                        paragraph.preformatted = true
                        paragraph.allowEmpty = true
                      }
                    }

                    handleTag("
")
                    handleTag("
")
                  }
                },
                until = { it.contains("
", ignoreCase = true) })
      } else if (lineWithoutIndentation.isQuoted()) {
        i--
        val paragraph = newParagraph(i)
        paragraph.quoted = true
        paragraph.block = false
        i =
            addLines(
                i,
                until = { _, w, _ ->
                  w.isBlank() ||
                      w.isListItem() ||
                      w.isKDocTag() ||
                      w.isTodo() ||
                      w.isDirectiveMarker() ||
                      w.isHeader()
                },
                customize = { _, p -> p.quoted = true },
                includeEnd = false)
        newParagraph(i)
      } else if (lineWithoutIndentation.equals("
", true) ||
          lineWithoutIndentation.equals("
", true)) {
        newParagraph(i - 1).block = true
        appendText(lineWithoutIndentation)
        newParagraph(i).hanging = true
        i =
            addLines(
                i,
                includeEnd = true,
                until = { _, w, _ -> w.equals("
", true) || w.equals("
", true) },
                customize = { _, p -> p.block = true },
                shouldBreak = { w, _ ->
                  w.startsWith("
", true) ||
                      w.startsWith("
", true) ||
                      w.startsWith("
", true)
                })
        newParagraph(i)
      } else if (lineWithoutIndentation.isListItem() ||
          lineWithoutIndentation.isKDocTag() && task.type == CommentType.KDOC ||
          lineWithoutIndentation.isTodo()) {
        i--
        newParagraph(i).hanging = true
        val start = i
        i =
            addLines(
                i,
                includeEnd = false,
                until = { j: Int, w: String, s: String ->
                  // See if it's a line continuation
                  if (s.isBlank() &&
                      j < lines.size - 1 &&
                      lineContent(lines[j + 1]).startsWith(" ")) {
                    false
                  } else {
                    s.isBlank() ||
                        w.isListItem() ||
                        w.isQuoted() ||
                        w.isKDocTag() ||
                        w.isTodo() ||
                        s.startsWith("```") ||
                        w.startsWith("<pre>") ||
                        w.isDirectiveMarker() ||
                        w.isLine() ||
                        w.isHeader() ||
                        // Not indented by at least two spaces following a blank line?
                        s.length > 2 &&
                            (!s[0].isWhitespace() || !s[1].isWhitespace()) &&
                            j < lines.size - 1 &&
                            lineContent(lines[j - 1]).isBlank()
                  }
                },
                shouldBreak = { w, _ -> w.isBlank() },
                customize = { j, p ->
                  if (lineContent(lines[j]).isBlank() && j >= start) {
                    p.hanging = true
                    p.continuation = true
                  }
                })
        newParagraph(i)
      } else if (lineWithoutIndentation.isEmpty()) {
        newParagraph(i).separate = true
      } else if (lineWithoutIndentation.isDirectiveMarker()) {
        newParagraph(i - 1)
        appendText(lineWithoutIndentation)
        newParagraph(i).block = true
      } else {
        if (lineWithoutIndentation.indexOf('|') != -1 &&
            paragraph.isEmpty() &&
            (i < 2 || !lines[i - 2].contains("---"))) {
          val result = Table.getTable(lines, i - 1, ::lineContent)
          if (result != null) {
            val (table, nextRow) = result
            val content =
                if (options.alignTableColumns) {
                  // Only considering maxLineWidth here, not maxCommentWidth; we
                  // cannot break table lines, only adjust tabbing, and a padded table
                  // seems more readable (maxCommentWidth < maxLineWidth is there to
                  // prevent long lines for readability)
                  table.format(options.maxLineWidth - indentSize - 3)
                } else {
                  table.original()
                }
            for (index in content.indices) {
              val line = content[index]
              appendText(line)
              paragraph.separate = index == 0
              paragraph.block = true
              paragraph.table = true
              newParagraph(-1)
            }
            i = nextRow
            newParagraph(i)
            continue
          }
        }

        // Some common HTML block tags
        if (lineWithoutIndentation.startsWith("<") &&
            (lineWithoutIndentation.startsWith("<p>", true) ||
                lineWithoutIndentation.startsWith("<p/>", true) ||
                lineWithoutIndentation.startsWith("<h1", true) ||
                lineWithoutIndentation.startsWith("<h2", true) ||
                lineWithoutIndentation.startsWith("<h3", true) ||
                lineWithoutIndentation.startsWith("<h4", true) ||
                lineWithoutIndentation.startsWith("<table", true) ||
                lineWithoutIndentation.startsWith("<tr", true) ||
                lineWithoutIndentation.startsWith("<caption", true) ||
                lineWithoutIndentation.startsWith("<td", true) ||
                lineWithoutIndentation.startsWith("<div", true))) {
          newParagraph(i - 1).block = true
          if (lineWithoutIndentation.equals("<p>", true) ||
              lineWithoutIndentation.equals("<p/>", true) ||
              options.convertMarkup && lineWithoutIndentation.equals("</p>", true)) {
            if (options.convertMarkup) {
              // Replace <p> with a blank line
              paragraph.separate = true
            } else {
              appendText(lineWithoutIndentation)
              newParagraph(i).block = true
            }
            continue
          } else if (lineWithoutIndentation.endsWith("</h1>", true) ||
              lineWithoutIndentation.endsWith("</h2>", true) ||
              lineWithoutIndentation.endsWith("</h3>", true) ||
              lineWithoutIndentation.endsWith("</h4>", true)) {
            if (lineWithoutIndentation.startsWith("<h", true) &&
                options.convertMarkup &&
                paragraph.isEmpty()) {
              paragraph.separate = true
              val count = lineWithoutIndentation[lineWithoutIndentation.length - 2] - '0'
              for (j in 0 until count.coerceAtLeast(0).coerceAtMost(8)) {
                appendText("#")
              }
              appendText(" ")
              appendText(lineWithoutIndentation.substring(4, lineWithoutIndentation.length - 5))
            } else if (options.collapseSpaces) {
              appendText(lineWithoutIndentation.collapseSpaces())
            } else {
              appendText(lineWithoutIndentation)
            }
            newParagraph(i).block = true
            continue
          }
        }

        i = addPlainText(i, lineWithoutIndentation)
      }
    }

    closeParagraph()
    arrange()
    if (!lineComment) {
      punctuate()
    }

    return ParagraphList(paragraphs)
  }

  private fun convertPrefix(text: String): String {
    return if (options.convertMarkup &&
        (text.startsWith("<p>", true) || text.startsWith("<p/>", true))) {
      paragraph.separate = true
      text.substring(text.indexOf('>') + 1).trim()
    } else {
      text
    }
  }

  private fun convertSuffix(trimmedPrefix: String): String {
    return if (options.convertMarkup &&
        (trimmedPrefix.endsWith("<p/>", true) || (trimmedPrefix.endsWith("</p>", true)))) {
      trimmedPrefix.substring(0, trimmedPrefix.length - 4).trimEnd().removeSuffix("*").trimEnd()
    } else {
      trimmedPrefix
    }
  }

  private fun addPlainText(i: Int, text: String, braceBalance: Int = 0): Int {
    val trimmed = convertSuffix(convertPrefix(text))
    val s = trimmed.let { if (options.collapseSpaces) it.collapseSpaces() else it }
    appendText(s)
    appendText(" ")

    if (braceBalance > 0) {
      val end = s.indexOf('}')
      if (end == -1 && i < lines.size) {
        val next = lineContent(lines[i]).trim()
        if (breakOutOfTag(next)) {
          return i
        }
        return addPlainText(i + 1, next, 1)
      }
    }

    val index = s.indexOf("{@")
    if (index != -1) {
      // find end
      val end = s.indexOf('}', index)
      if (end == -1 && i < lines.size) {
        val next = lineContent(lines[i]).trim()
        if (breakOutOfTag(next)) {
          return i
        }
        return addPlainText(i + 1, next, 1)
      }
    }

    return i
  }

  private fun breakOutOfTag(next: String): Boolean {
    if (next.isBlank() || next.startsWith("```")) {
      // See https://github.com/tnorbye/kdoc-formatter/issues/77
      // There may be comments which look unusual from a formatting
      // perspective where it looks like you have embedded markup
      // or blank lines; if so, just give up on trying to turn
      // this into paragraph text
      return true
    }
    return false
  }

  private fun docTagRank(tag: String): Int {
    // Canonical kdoc order -- https://kotlinlang.org/docs/kotlin-doc.html#block-tags
    // Full list in Dokka's sources: plugins/base/src/main/kotlin/parsers/Parser.kt
    return when {
      tag.startsWith("@param") -> 0
      tag.startsWith("@return") -> 1
      tag.startsWith("@constructor") -> 2
      tag.startsWith("@receiver") -> 3
      tag.startsWith("@property") -> 4
      tag.startsWith("@throws") -> 5
      tag.startsWith("@exception") -> 6
      tag.startsWith("@sample") -> 7
      tag.startsWith("@see") -> 8
      tag.startsWith("@author") -> 9
      tag.startsWith("@since") -> 10
      tag.startsWith("@suppress") -> 11
      tag.startsWith("@deprecated") -> 12
      else -> 100 // custom tags
    }
  }

  /**
   * Make a pass over the paragraphs and make sure that we (for example) place blank lines around
   * preformatted text.
   */
  private fun arrange() {
    if (paragraphs.isEmpty()) {
      return
    }

    sortDocTags()
    adjustParagraphSeparators()
    adjustIndentation()
    removeBlankParagraphs()
    stripTrailingBlankLines()
  }

  private fun sortDocTags() {
    if (options.orderDocTags && paragraphs.any { it.doc }) {
      val order = paragraphs.mapIndexed { index, paragraph -> paragraph to index }.toMap()
      val comparator =
          object : Comparator<List<Paragraph>> {
            override fun compare(l1: List<Paragraph>, l2: List<Paragraph>): Int {
              val p1 = l1.first()
              val p2 = l2.first()
              val o1 = order[p1]!!
              val o2 = order[p2]!!

              // Sort TODOs to the end
              if (p1.text.isTodo() != p2.text.isTodo()) {
                return if (p1.text.isTodo()) 1 else -1
              }

              if (p1.doc == p2.doc) {
                if (p1.doc) {
                  // Sort @return after @param etc
                  val r1 = docTagRank(p1.text)
                  val r2 = docTagRank(p2.text)
                  if (r1 != r2) {
                    return r1 - r2
                  }
                  // Within identical tags, preserve current order, except for
                  // parameter names which are sorted by signature order.
                  val orderedParameterNames = task.orderedParameterNames
                  if (orderedParameterNames.isNotEmpty()) {
                    fun Paragraph.parameterRank(): Int {
                      val name = text.getParamName()
                      if (name != null) {
                        val index = orderedParameterNames.indexOf(name)
                        if (index != -1) {
                          return index
                        }
                      }
                      return 1000
                    }

                    val i1 = p1.parameterRank()
                    val i2 = p2.parameterRank()

                    // If the parameter names are not matching, ignore.
                    if (i1 != i2) {
                      return i1 - i2
                    }
                  }
                }
                return o1 - o2
              }
              return if (p1.doc) 1 else -1
            }
          }

      // We don't sort the paragraphs list directly; we have to tie all the
      // paragraphs following a KDoc parameter to that paragraph (until the
      // next KDoc tag). So instead we create a list of lists -- consisting of
      // one list for each paragraph, though with a KDoc parameter it's a list
      // containing first the KDoc parameter paragraph and then all following
      // parameters. We then sort by just the first item in this list of list,
      // and then restore the paragraph list from the result.
      val units = mutableListOf<List<Paragraph>>()
      var tag: MutableList<Paragraph>? = null
      for (paragraph in paragraphs) {
        if (paragraph.doc) {
          tag = mutableListOf()
          units.add(tag)
        }
        if (tag != null && !paragraph.text.isTodo()) {
          tag.add(paragraph)
        } else {
          units.add(listOf(paragraph))
        }
      }
      units.sortWith(comparator)

      var prev: Paragraph? = null
      paragraphs.clear()
      for (paragraph in units.flatten()) {
        paragraphs.add(paragraph)
        prev?.next = paragraph
        paragraph.prev = prev
        prev = paragraph
      }
    }
  }

  private fun adjustParagraphSeparators() {
    var prev: Paragraph? = null

    for (paragraph in paragraphs) {
      paragraph.cleanup()
      val text = paragraph.text
      paragraph.separate =
          when {
            prev == null -> false
            paragraph.preformatted && prev.preformatted -> false
            paragraph.table ->
                paragraph.separate && (!prev.block || prev.text.isKDocTag() || prev.table)
            paragraph.separator || prev.separator -> true
            text.isLine(1) || prev.text.isLine(1) -> false
            paragraph.separate && paragraph.text.isListItem() -> false
            paragraph.separate -> true
            // Don't separate kdoc tags, except for the first one
            paragraph.doc -> !prev.doc
            text.isDirectiveMarker() -> false
            text.isTodo() && !prev.text.isTodo() -> true
            text.isHeader() -> true
            // Set preformatted paragraphs off (but not
 tags where it's implicit)
            paragraph.preformatted ->
                !prev.preformatted &&
                    !text.startsWith(" false
            paragraph.continuation -> true
            paragraph.hanging -> false
            paragraph.quoted -> prev.quoted
            text.isHeader() -> true
            text.startsWith("
", true) || text.startsWith("
", true) -> true
            else -> !paragraph.block && !paragraph.isEmpty()
          }

      if (paragraph.hanging) {
        if (paragraph.doc || text.startsWith("
", true) || text.isTodo()) {
          paragraph.hangingIndent = getIndent(options.hangingIndent)
        } else if (paragraph.continuation && paragraph.prev != null) {
          paragraph.hangingIndent = paragraph.prev!!.hangingIndent
          // Dedent to match hanging indent
          val s = paragraph.text.trimStart()
          paragraph.content.clear()
          paragraph.content.append(s)
        } else {
          paragraph.hangingIndent = getIndent(text.indexOf(' ') + 1)
        }
      }
      prev = paragraph
    }
  }

  private fun adjustIndentation() {
    val firstIndent = paragraphs[0].originalIndent
    if (firstIndent > 0) {
      for (paragraph in paragraphs) {
        if (paragraph.originalIndent <= firstIndent) {
          paragraph.originalIndent = 0
        }
      }
    }

    // Handle nested lists
    var inList = paragraphs.firstOrNull()?.hanging ?: false
    var startIndent = 0
    var levels: MutableSet<Int>? = null
    for (i in 1 until paragraphs.size) {
      val paragraph = paragraphs[i]
      if (!inList) {
        if (paragraph.hanging) {
          inList = true
          startIndent = paragraph.originalIndent
        }
      } else {
        if (!paragraph.hanging) {
          inList = false
        } else {
          if (paragraph.originalIndent == startIndent) {
            paragraph.originalIndent = 0
          } else if (paragraph.originalIndent > 0) {
            (levels ?: mutableSetOf<Int>().also { levels = it }).add(paragraph.originalIndent)
          }
        }
      }
    }

    levels?.sorted()?.let { sorted ->
      val assignments = mutableMapOf<Int, Int>()
      for (i in sorted.indices) {
        assignments[sorted[i]] = (i + 1) * options.nestedListIndent
      }
      for (paragraph in paragraphs) {
        if (paragraph.originalIndent > 0) {
          val assigned = assignments[paragraph.originalIndent] ?: continue
          paragraph.originalIndent = assigned
          paragraph.indent = getIndent(paragraph.originalIndent)
        }
      }
    }
  }

  private fun removeBlankParagraphs() {
    // Remove blank lines between list items and from the end as well as around
    // separators
    for (i in paragraphs.size - 2 downTo 0) {
      if (paragraphs[i].isEmpty() && (!paragraphs[i].preformatted || i == paragraphs.size - 1)) {
        paragraphs.removeAt(i)
        if (i > 0) {
          paragraphs[i - 1].next = null
        }
      }
    }
  }

  private fun punctuate() {
    if (!options.addPunctuation || paragraphs.isEmpty()) {
      return
    }
    val last = paragraphs.last()
    if (last.preformatted || last.doc || last.hanging && !last.continuation || last.isEmpty()) {
      return
    }

    val text = last.content
    if (!text.startsWithUpperCaseLetter()) {
      return
    }

    for (i in text.length - 1 downTo 0) {
      val c = text[i]
      if (c.isWhitespace()) {
        continue
      }
      if (c.isLetterOrDigit() || c.isCloseSquareBracket()) {
        text.setLength(i + 1)
        text.append('.')
      }
      break
    }
  }
}

fun String.containsOnly(vararg s: Char): Boolean {
  for (c in this) {
    if (s.none { it == c }) {
      return false
    }
  }
  return true
}

fun StringBuilder.startsWithUpperCaseLetter() =
    this.isNotEmpty() && this[0].isUpperCase() && this[0].isLetter()

fun Char.isCloseSquareBracket() = this == ']'


/*
 * Copyright (c) Tor Norbye.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.facebook.ktfmt.kdoc

import kotlin.math.max

class Table(
    private val columns: Int,
    private val widths: List<Int>,
    private val rows: List<Row>,
    private val align: List<Align>,
    private val original: List<String>
) {
  fun original(): List<String> {
    return original
  }

  /**
   * Format the table. Note that table rows cannot be broken into multiple lines in Markdown tables,
   * so the [maxWidth] here is used to decide whether to add padding around the table only, and it's
   * quite possible for the table to format to wider lengths than [maxWidth].
   */
  fun format(maxWidth: Int = Integer.MAX_VALUE): List<String> {
    val tableMaxWidth =
        2 + widths.sumOf { it + 2 } // +2: "| " in each cell and final " |" on the right

    val pad = tableMaxWidth <= maxWidth
    val lines = mutableListOf<String>()
    for (i in rows.indices) {
      val sb = StringBuilder()
      val row = rows[i]
      for (column in 0 until row.cells.size) {
        sb.append('|')
        if (pad) {
          sb.append(' ')
        }
        val cell = row.cells[column]
        val width = widths[column]
        val s =
            if (align[column] == Align.CENTER && i > 0) {
              String.format(
                  "%-${width}s",
                  String.format("%${cell.length + (width - cell.length) / 2}s", cell))
            } else if (align[column] == Align.RIGHT && i > 0) {
              String.format("%${width}s", cell)
            } else {
              String.format("%-${width}s", cell)
            }
        sb.append(s)
        if (pad) {
          sb.append(' ')
        }
      }
      sb.append('|')
      lines.add(sb.toString())
      sb.clear()

      if (i == 0) {
        for (column in 0 until row.cells.size) {
          sb.append('|')
          var width = widths[column]
          if (align[column] != Align.LEFT) {
            width--
            if (align[column] == Align.CENTER) {
              sb.append(':')
              width--
            }
          }
          if (pad) {
            sb.append('-')
          }
          val s = "-".repeat(width)
          sb.append(s)
          if (pad) {
            sb.append('-')
          }
          if (align[column] != Align.LEFT) {
            sb.append(':')
          }
        }
        sb.append('|')
        lines.add(sb.toString())
        sb.clear()
      }
    }

    return lines
  }

  companion object {
    /**
     * If the line starting at index [start] begins a table, return that table as well as the index
     * of the first line after the table.
     */
    fun getTable(
        lines: List<String>,
        start: Int,
        lineContent: (String) -> String
    ): Pair<Table, Int>? {
      if (start > lines.size - 2) {
        return null
      }
      val headerLine = lineContent(lines[start])
      val separatorLine = lineContent(lines[start + 1])
      val barCount = countSeparators(headerLine)
      if (!isHeaderDivider(barCount, separatorLine.trim())) {
        return null
      }
      val header = getRow(headerLine) ?: return null
      val rows = mutableListOf<Row>()
      rows.add(header)

      val dividerRow = getRow(separatorLine) ?: return null

      var i = start + 2
      while (i < lines.size) {
        val line = lineContent(lines[i])
        if (!line.contains("|")) {
          break
        }
        val row = getRow(line) ?: break
        rows.add(row)
        i++
      }

      val rowsAndDivider = rows + dividerRow
      if (rowsAndDivider.all {
        val first = it.cells.firstOrNull()
        first != null && first.isBlank()
      }) {
        rowsAndDivider.forEach { if (it.cells.isNotEmpty()) it.cells.removeAt(0) }
      }

      // val columns = rows.maxOf { it.cells.size }
      val columns = dividerRow.cells.size
      val maxColumns = rows.maxOf { it.cells.size }
      val widths = mutableListOf<Int>()
      for (column in 0 until maxColumns) {
        widths.add(3)
      }
      for (row in rows) {
        for (column in 0 until row.cells.size) {
          widths[column] = max(widths[column], row.cells[column].length)
        }
        for (column in row.cells.size until columns) {
          row.cells.add("")
        }
      }

      val align = mutableListOf<Align>()
      for (cell in dividerRow.cells) {
        val direction =
            if (cell.endsWith(":")) {
              if (cell.startsWith(":-")) {
                Align.CENTER
              } else {
                Align.RIGHT
              }
            } else {
              Align.LEFT
            }
        align.add(direction)
      }
      for (column in align.size until maxColumns) {
        align.add(Align.LEFT)
      }
      val table =
          Table(columns, widths, rows, align, lines.subList(start, i).map { lineContent(it) })
      return Pair(table, i)
    }

    /** Returns true if the given String looks like a markdown table header divider. */
    private fun isHeaderDivider(barCount: Int, s: String): Boolean {
      var i = 0
      var count = 0
      while (i < s.length) {
        val c = s[i++]
        if (c == '\\') {
          i++
        } else if (c == '|') {
          count++
        } else if (c.isWhitespace() || c == ':') {
          continue
        } else if (c == '-' &&
            (s.startsWith("--", i) ||
                s.startsWith("-:", i) ||
                i > 1 && s.startsWith(":-:", i - 2) ||
                i > 1 && s.startsWith(":--", i - 2))) {
          while (i < s.length && s[i] == '-') {
            i++
          }
        } else {
          return false
        }
      }

      return barCount == count
    }

    private fun getRow(s: String): Row? {
      // Can't just use String.split('|') because that would not handle escaped |'s
      if (s.indexOf('|') == -1) {
        return null
      }
      val row = Row()
      var i = 0
      var end = 0
      while (end < s.length) {
        val c = s[end]
        if (c == '\\') {
          end++
        } else if (c == '|') {
          val cell = s.substring(i, end).trim()
          if (end > 0) {
            row.cells.add(cell.trim())
          }
          i = end + 1
        }
        end++
      }
      if (end > i) {
        val cell = s.substring(i, end).trim()
        if (cell.isNotEmpty()) {
          row.cells.add(cell.trim())
        }
      }

      return row
    }

    private fun countSeparators(s: String): Int {
      var i = 0
      var count = 0
      while (i < s.length) {
        val c = s[i]
        if (c == '|') {
          count++
        } else if (c == '\\') {
          i++
        }
        i++
      }
      return count
    }
  }

  enum class Align {
    LEFT,
    RIGHT,
    CENTER
  }

  class Row {
    val cells = mutableListOf<String>()
  }
}


/*
 * Copyright (c) Tor Norbye.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.facebook.ktfmt.kdoc

import java.util.regex.Pattern
import kotlin.math.min

fun getIndent(width: Int): String {
  val sb = StringBuilder()
  for (i in 0 until width) {
    sb.append(' ')
  }
  return sb.toString()
}

fun getIndentSize(indent: String, options: KDocFormattingOptions): Int {
  var size = 0
  for (c in indent) {
    if (c == '\t') {
      size += options.tabWidth
    } else {
      size++
    }
  }
  return size
}

/** Returns line number (1-based) */
fun getLineNumber(source: String, offset: Int, startLine: Int = 1, startOffset: Int = 0): Int {
  var line = startLine
  for (i in startOffset until offset) {
    val c = source[i]
    if (c == '\n') {
      line++
    }
  }
  return line
}

private val numberPattern = Pattern.compile("^\\d+([.)]) ")

fun String.isListItem(): Boolean {
  return startsWith("- ") ||
      startsWith("* ") ||
      startsWith("+ ") ||
      firstOrNull()?.isDigit() == true && numberPattern.matcher(this).find() ||
      startsWith("<li>", ignoreCase = true)
}

fun String.collapseSpaces(): String {
  if (indexOf("  ") == -1) {
    return this.trimEnd()
  }
  val sb = StringBuilder()
  var prev: Char = this[0]
  for (i in indices) {
    if (prev == ' ') {
      if (this[i] == ' ') {
        continue
      }
    }
    sb.append(this[i])
    prev = this[i]
  }
  return sb.trimEnd().toString()
}

fun String.isTodo(): Boolean {
  return startsWith("TODO:") || startsWith("TODO(")
}

fun String.isHeader(): Boolean {
  return startsWith("#") || startsWith("<h", true)
}

fun String.isQuoted(): Boolean {
  return startsWith("> ")
}

fun String.isDirectiveMarker(): Boolean {
  return startsWith("<!--") || startsWith("-->")
}

/**
 * Returns true if the string ends with a symbol that implies more text is coming, e.g. ":" or ","
 */
fun String.isExpectingMore(): Boolean {
  val last = lastOrNull { !it.isWhitespace() } ?: return false
  return last == ':' || last == ','
}

/**
 * Does this String represent a divider line? (Markdown also requires it to be surrounded by empty
 * lines which has to be checked by the caller)
 */
fun String.isLine(minCount: Int = 3): Boolean {
  return startsWith('-') && containsOnly('-', ' ') && count { it == '-' } >= minCount ||
      startsWith('_') && containsOnly('_', ' ') && count { it == '_' } >= minCount
}

fun String.isKDocTag(): Boolean {
  // Not using a hardcoded list here since tags can change over time
  if (startsWith("@") && length > 1) {
    for (i in 1 until length) {
      val c = this[i]
      if (c.isWhitespace()) {
        return i > 2
      } else if (!c.isLetter() || !c.isLowerCase()) {
        if (c == '[' && (startsWith("@param") || startsWith("@property"))) {
          // @param is allowed to use brackets -- see
          // https://kotlinlang.org/docs/kotlin-doc.html#param-name
          // Example: @param[foo] The description of foo
          return true
        } else if (i == 1 && c.isLetter() && c.isUpperCase()) {
          // Allow capitalized tgs, such as @See -- this is normally a typo; convertMarkup
          // should also fix these.
          return true
        }
        return false
      }
    }
    return true
  }
  return false
}

/**
 * If this String represents a KDoc `@param` tag, returns the corresponding parameter name,
 * otherwise null.
 */
fun String.getParamName(): String? {
  val length = this.length
  var start = 0
  while (start < length && this[start].isWhitespace()) {
    start++
  }
  if (!this.startsWith("@param", start)) {
    return null
  }
  start += "@param".length

  while (start < length) {
    if (this[start].isWhitespace()) {
      start++
    } else {
      break
    }
  }

  if (start < length && this[start] == '[') {
    start++
    while (start < length) {
      if (this[start].isWhitespace()) {
        start++
      } else {
        break
      }
    }
  }

  var end = start
  while (end < length) {
    if (!this[end].isJavaIdentifierPart()) {
      break
    }
    end++
  }

  if (end > start) {
    return this.substring(start, end)
  }

  return null
}

private fun getIndent(start: Int, lookup: (Int) -> Char): String {
  var i = start - 1
  while (i >= 0 && lookup(i) != '\n') {
    i--
  }
  val sb = StringBuilder()
  for (j in i + 1 until start) {
    sb.append(lookup(j))
  }
  return sb.toString()
}

/**
 * Given a character [lookup] function in a document of [max] characters, for a comment starting at
 * offset [start], compute the effective indent on the first line and on subsequent lines.
 *
 * For a comment starting on its own line, the two will be the same. But for a comment that is at
 * the end of a line containing code, the first line indent will not be the indentation of the
 * earlier code, it will be the full indent as if all the code characters were whitespace characters
 * (which lets the formatter figure out how much space is available on the first line).
 */
fun computeIndents(start: Int, lookup: (Int) -> Char, max: Int): Pair<String, String> {
  val originalIndent = getIndent(start, lookup)
  val suffix = !originalIndent.all { it.isWhitespace() }
  val indent =
      if (suffix) {
        originalIndent.map { if (it.isWhitespace()) it else ' ' }.joinToString(separator = "")
      } else {
        originalIndent
      }

  val secondaryIndent =
      if (suffix) {
        // We don't have great heuristics to figure out what the indent should be
        // following a source line -- e.g. it can be implied by things like whether
        // the line ends with '{' or an operator, but it's more complicated than
        // that. So we'll cheat and just look to see what the existing code does!
        var offset = start
        while (offset < max && lookup(offset) != '\n') {
          offset++
        }
        offset++
        val sb = StringBuilder()
        while (offset < max) {
          if (lookup(offset) == '\n') {
            sb.clear()
          } else {
            val c = lookup(offset)
            if (c.isWhitespace()) {
              sb.append(c)
            } else {
              if (c == '*') {
                // in a comment, the * is often one space indented
                // to line up with the first * in the opening /** and
                // the actual indent should be aligned with the /
                sb.setLength(sb.length - 1)
              }
              break
            }
          }
          offset++
        }
        sb.toString()
      } else {
        originalIndent
      }

  return Pair(indent, secondaryIndent)
}

/**
 * Attempt to preserve the caret position across reformatting. Returns the delta in the new comment.
 */
fun findSamePosition(comment: String, delta: Int, reformattedComment: String): Int {
  // First see if the two comments are identical up to the delta; if so, same
  // new position
  for (i in 0 until min(comment.length, reformattedComment.length)) {
    if (i == delta) {
      return delta
    } else if (comment[i] != reformattedComment[i]) {
      break
    }
  }

  var i = comment.length - 1
  var j = reformattedComment.length - 1
  if (delta == i + 1) {
    return j + 1
  }
  while (i >= 0 && j >= 0) {
    if (i == delta) {
      return j
    }
    if (comment[i] != reformattedComment[j]) {
      break
    }
    i--
    j--
  }

  fun isSignificantChar(c: Char): Boolean = c.isWhitespace() || c == '*'

  // Finally it's somewhere in the middle; search by character skipping over
  // insignificant characters (space, *, etc)
  fun nextSignificantChar(s: String, from: Int): Int {
    var curr = from
    while (curr < s.length) {
      val c = s[curr]
      if (isSignificantChar(c)) {
        curr++
      } else {
        break
      }
    }
    return curr
  }

  var offset = 0
  var reformattedOffset = 0
  while (offset < delta && reformattedOffset < reformattedComment.length) {
    offset = nextSignificantChar(comment, offset)
    reformattedOffset = nextSignificantChar(reformattedComment, reformattedOffset)
    if (offset == delta) {
      return reformattedOffset
    }
    offset++
    reformattedOffset++
  }
  return reformattedOffset
}

// Until stdlib version is no longer experimental
fun <T, R : Comparable<R>> Iterable<T>.maxOf(selector: (T) -> R): R {
  val iterator = iterator()
  if (!iterator.hasNext()) throw NoSuchElementException()
  var maxValue = selector(iterator.next())
  while (iterator.hasNext()) {
    val v = selector(iterator.next())
    if (maxValue < v) {
      maxValue = v
    }
  }
  return maxValue
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.facebook.ktfmt.cli

import com.google.common.truth.Truth.assertThat
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.PrintStream
import java.lang.IllegalStateException
import java.nio.file.Files
import java.util.concurrent.ForkJoinPool
import kotlin.io.path.createTempDirectory
import org.junit.After
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4

@Suppress("FunctionNaming")
@RunWith(JUnit4::class)
class MainTest {

  private val root = createTempDirectory().toFile()

  private val emptyInput = "".byteInputStream()
  private val out = ByteArrayOutputStream()
  private val err = ByteArrayOutputStream()

  @After
  fun tearDown() {
    root.deleteRecursively()
  }

  /**
   * Scenario: someone _really_ wants to format this file, regardless of its extension. When a
   * single argument file is given, it is used as is without filtering by extension.
   */
  @Test
  fun `expandArgsToFileNames - single file arg is used as is`() {
    val fooBar = root.resolve("foo.bar")
    fooBar.writeText("hi")
    assertThat(Main.expandArgsToFileNames(listOf(fooBar.toString()))).containsExactly(fooBar)
  }

  @Test
  fun `expandArgsToFileNames - single arg which is not a file is not returned`() {
    val fooBar = root.resolve("foo.bar")
    assertThat(Main.expandArgsToFileNames(listOf(fooBar.toString()))).isEmpty()
  }

  @Test
  fun `expandArgsToFileNames - single arg which is a directory is resolved to its recursively contained kt files`() {
    val dir = root.resolve("dir")
    dir.mkdirs()
    val foo = dir.resolve("foo.kt")
    foo.writeText("")
    val bar = dir.resolve("bar.kt")
    bar.writeText("")
    assertThat(Main.expandArgsToFileNames(listOf(dir.toString()))).containsExactly(foo, bar)
  }

  @Test
  fun `expandArgsToFileNames - multiple directory args are resolved to their recursively contained kt files`() {
    val dir1 = root.resolve("dir1")
    dir1.mkdirs()
    val foo1 = dir1.resolve("foo1.kt")
    foo1.writeText("")
    val bar1 = dir1.resolve("bar1.kt")
    bar1.writeText("")

    val dir2 = root.resolve("dir2")
    dir1.mkdirs()
    val foo2 = dir1.resolve("foo2.kt")
    foo2.writeText("")
    val bar2 = dir1.resolve("bar2.kt")
    bar2.writeText("")

    assertThat(Main.expandArgsToFileNames(listOf(dir1.toString(), dir2.toString())))
        .containsExactly(foo1, bar1, foo2, bar2)
  }

  @Test
  fun `expandArgsToFileNames - a dash is an error`() {
    try {
      Main.expandArgsToFileNames(listOf(root.resolve("foo.bar").toString(), File("-").toString()))
      fail("expected exception, but nothing was thrown")
    } catch (e: IllegalStateException) {
      assertThat(e.message).contains("Error")
    }
  }

  @Test
  fun `Using '-' as the filename formats an InputStream`() {
    val code = "fun    f1 (  ) :    Int =    0"
    Main(code.byteInputStream(), PrintStream(out), PrintStream(err), arrayOf("-")).run()

    val expected = "fun f1(): Int = 0\n"
    assertThat(out.toString("UTF-8")).isEqualTo(expected)
  }

  @Test
  fun `Parsing errors are reported (stdin)`() {
    val code = "fun    f1 (  "
    val returnValue =
        Main(code.byteInputStream(), PrintStream(out), PrintStream(err), arrayOf("-")).run()

    assertThat(returnValue).isEqualTo(1)
    assertThat(err.toString("UTF-8")).startsWith("<stdin>:1:14: error: ")
  }

  @Test
  fun `Parsing errors are reported (stdin-name)`() {
    val code = "fun    f1 (  "
    val returnValue =
        Main(
                code.byteInputStream(),
                PrintStream(out),
                PrintStream(err),
                arrayOf("--stdin-name=file/Foo.kt", "-"))
            .run()

    assertThat(returnValue).isEqualTo(1)
    assertThat(err.toString("UTF-8")).startsWith("file/Foo.kt:1:14: error: ")
  }

  @Test
  fun `Parsing errors are reported (file)`() {
    val fooBar = root.resolve("foo.kt")
    fooBar.writeText("fun    f1 (  ")
    val returnValue =
        Main(emptyInput, PrintStream(out), PrintStream(err), arrayOf(fooBar.toString())).run()

    assertThat(returnValue).isEqualTo(1)
    assertThat(err.toString("UTF-8")).contains("foo.kt:1:14: error: ")
  }

  @Test
  fun `all files in args are processed, even if one of them has an error`() {
    val file1 = root.resolve("file1.kt")
    val file2Broken = root.resolve("file2.kt")
    val file3 = root.resolve("file3.kt")
    file1.writeText("fun    f1 ()  ")
    file2Broken.writeText("fun    f1 (  ")
    file3.writeText("fun    f1 ()  ")

    // Make Main() process files serially.
    val forkJoinPool = ForkJoinPool(1)

    val returnValue: Int =
        forkJoinPool
            .submit<Int> {
              Main(
                      emptyInput,
                      PrintStream(out),
                      PrintStream(err),
                      arrayOf(file1.toString(), file2Broken.toString(), file3.toString()))
                  .run()
            }
            .get()

    assertThat(returnValue).isEqualTo(1)
    assertThat(err.toString("UTF-8")).contains("Done formatting $file1")
    assertThat(err.toString("UTF-8")).contains("file2.kt:1:14: error: ")
    assertThat(err.toString("UTF-8")).contains("Done formatting $file3")
  }

  @Test
  fun `file is not modified if it is already formatted`() {
    val code = """fun f() = println("hello, world")""" + "\n"
    val formattedFile = root.resolve("formatted_file.kt")
    formattedFile.writeText(code)
    val formattedFilePath = formattedFile.toPath()

    val lastModifiedTimeBeforeRunningFormatter =
        Files.getLastModifiedTime(formattedFilePath).toMillis()
    Main(emptyInput, PrintStream(out), PrintStream(err), arrayOf(formattedFile.toString())).run()
    val lastModifiedTimeAfterRunningFormatter =
        Files.getLastModifiedTime(formattedFilePath).toMillis()

    assertThat(lastModifiedTimeBeforeRunningFormatter)
        .isEqualTo(lastModifiedTimeAfterRunningFormatter)
  }

  @Test
  fun `file is modified if it is not formatted`() {
    val code = """fun f() =   println(  "hello, world")""" + "\n"
    val unformattedFile = root.resolve("unformatted_file.kt")
    unformattedFile.writeText(code)
    val unformattedFilePath = unformattedFile.toPath()

    val lastModifiedTimeBeforeRunningFormatter =
        Files.getLastModifiedTime(unformattedFilePath).toMillis()
    // The test may run under 1ms, and we need to make sure the new file timestamp will be different
    Thread.sleep(100)
    Main(emptyInput, PrintStream(out), PrintStream(err), arrayOf(unformattedFile.toString())).run()
    val lastModifiedTimeAfterRunningFormatter =
        Files.getLastModifiedTime(unformattedFilePath).toMillis()

    assertThat(lastModifiedTimeBeforeRunningFormatter)
        .isLessThan(lastModifiedTimeAfterRunningFormatter)
  }

  @Test
  fun `dropbox-style is passed to formatter (file)`() {
    val code =
        """fun f() {
    for (child in
        node.next.next.next.next.next.next.next.next.next.next.next.next.next.next.data()) {
        println(child)
    }
}
"""
    val fooBar = root.resolve("foo.kt")
    fooBar.writeText(code)

    Main(
            emptyInput,
            PrintStream(out),
            PrintStream(err),
            arrayOf("--dropbox-style", fooBar.toString()))
        .run()

    assertThat(fooBar.readText()).isEqualTo(code)
  }

  @Test
  fun `dropbox-style is passed to formatter (stdin)`() {
    val code =
        """fun f() {
          |for (child in
          |node.next.next.next.next.next.next.next.next.next.next.next.next.next.next.data()) {
          |println(child)
          |}
          |}
          |"""
            .trimMargin()
    val formatted =
        """fun f() {
          |    for (child in
          |        node.next.next.next.next.next.next.next.next.next.next.next.next.next.next.data()) {
          |        println(child)
          |    }
          |}
          |"""
            .trimMargin()
    Main(
            code.byteInputStream(),
            PrintStream(out),
            PrintStream(err),
            arrayOf("--dropbox-style", "-"))
        .run()

    assertThat(out.toString("UTF-8")).isEqualTo(formatted)
  }

  @Test
  fun `expandArgsToFileNames - resolves 'kt' and 'kts' filenames only (recursively)`() {
    val f1 = root.resolve("1.kt")
    val f2 = root.resolve("2.kt")
    val f3 = root.resolve("3")
    val f4 = root.resolve("4.dummyext")
    val f5 = root.resolve("5.kts")

    val dir = root.resolve("foo")
    dir.mkdirs()
    val f6 = root.resolve("foo/1.kt")
    val f7 = root.resolve("foo/2.kts")
    val f8 = root.resolve("foo/3.dummyext")
    val files = listOf(f1, f2, f3, f4, f5, f6, f7, f8)
    for (f in files) {
      f.createNewFile()
    }
    assertThat(Main.expandArgsToFileNames(files.map { it.toString() }))
        .containsExactly(f1, f2, f5, f6, f7)
  }

  @Test
  fun `formatting from stdin prints formatted code to stdout regardless of whether it was already formatted`() {
    val expected = """fun f() = println("hello, world")""" + "\n"

    Main(
            """fun f (   ) =    println("hello, world")""".byteInputStream(),
            PrintStream(out),
            PrintStream(err),
            arrayOf("-"))
        .run()
    assertThat(out.toString("UTF-8")).isEqualTo(expected)

    out.reset()

    Main(
            """fun f () = println("hello, world")""".byteInputStream(),
            PrintStream(out),
            PrintStream(err),
            arrayOf("-"))
        .run()
    assertThat(out.toString("UTF-8")).isEqualTo(expected)
  }

  @Test
  fun `--dry-run prints filename and does not change file`() {
    val code = """fun f () =    println( "hello, world" )"""
    val file = root.resolve("foo.kt")
    file.writeText(code)

    Main(emptyInput, PrintStream(out), PrintStream(err), arrayOf("--dry-run", file.toString()))
        .run()

    assertThat(file.readText()).isEqualTo(code)
    assertThat(out.toString("UTF-8")).contains(file.toString())
  }

  @Test
  fun `--dry-run prints 'stdin' and does not reformat code from stdin`() {
    val code = """fun f () =    println( "hello, world" )"""

    Main(code.byteInputStream(), PrintStream(out), PrintStream(err), arrayOf("--dry-run", "-"))
        .run()

    assertThat(out.toString("UTF-8")).doesNotContain("hello, world")
    assertThat(out.toString("UTF-8")).isEqualTo("<stdin>\n")
  }

  @Test
  fun `--dry-run prints nothing when there are no changes needed (file)`() {
    val code = """fun f() = println("hello, world")\n"""
    val file = root.resolve("foo.kt")
    file.writeText(code)

    Main(emptyInput, PrintStream(out), PrintStream(err), arrayOf("--dry-run", file.toString()))
        .run()

    assertThat(out.toString("UTF-8")).isEmpty()
  }

  @Test
  fun `--dry-run prints nothing when there are no changes needed (stdin)`() {
    val code = """fun f() = println("hello, world")\n"""

    Main(code.byteInputStream(), PrintStream(out), PrintStream(err), arrayOf("--dry-run", "-"))
        .run()

    assertThat(out.toString("UTF-8")).isEmpty()
  }

  @Test
  fun `Exit code is 0 when there are changes (file)`() {
    val code = """fun f () =    println( "hello, world" )"""
    val file = root.resolve("foo.kt")
    file.writeText(code)

    val exitCode =
        Main(emptyInput, PrintStream(out), PrintStream(err), arrayOf(file.toString())).run()

    assertThat(exitCode).isEqualTo(0)
  }

  @Test
  fun `Exits with 0 when there are changes (stdin)`() {
    val code = """fun f () =    println( "hello, world" )"""

    val exitCode =
        Main(code.byteInputStream(), PrintStream(out), PrintStream(err), arrayOf("-")).run()

    assertThat(exitCode).isEqualTo(0)
  }

  @Test
  fun `Exit code is 1 when there are changes and --set-exit-if-changed is set (file)`() {
    val code = """fun f () =    println( "hello, world" )"""
    val file = root.resolve("foo.kt")
    file.writeText(code)

    val exitCode =
        Main(
                emptyInput,
                PrintStream(out),
                PrintStream(err),
                arrayOf("--set-exit-if-changed", file.toString()))
            .run()

    assertThat(exitCode).isEqualTo(1)
  }

  @Test
  fun `Exit code is 1 when there are changes and --set-exit-if-changed is set (stdin)`() {
    val code = """fun f () =    println( "hello, world" )"""

    val exitCode =
        Main(
                code.byteInputStream(),
                PrintStream(out),
                PrintStream(err),
                arrayOf("--set-exit-if-changed", "-"))
            .run()

    assertThat(exitCode).isEqualTo(1)
  }

  @Test
  fun `--set-exit-if-changed and --dry-run changes nothing, prints filenames, and exits with 1 (file)`() {
    val code = """fun f () =    println( "hello, world" )"""
    val file = root.resolve("foo.kt")
    file.writeText(code)

    val exitCode =
        Main(
                emptyInput,
                PrintStream(out),
                PrintStream(err),
                arrayOf("--dry-run", "--set-exit-if-changed", file.toString()))
            .run()

    assertThat(file.readText()).isEqualTo(code)
    assertThat(out.toString("UTF-8")).contains(file.toString())
    assertThat(exitCode).isEqualTo(1)
  }

  @Test
  fun `--set-exit-if-changed and --dry-run changes nothing, prints filenames, and exits with 1 (stdin)`() {
    val code = """fun f () =    println( "hello, world" )"""

    val exitCode =
        Main(
                code.byteInputStream(),
                PrintStream(out),
                PrintStream(err),
                arrayOf("--dry-run", "--set-exit-if-changed", "-"))
            .run()

    assertThat(out.toString("UTF-8")).doesNotContain("hello, world")
    assertThat(out.toString("UTF-8")).isEqualTo("<stdin>\n")
    assertThat(exitCode).isEqualTo(1)
  }

  @Test
  fun `--stdin-name can only be used with stdin`() {
    val code = """fun f () =    println( "hello, world" )"""
    val file = root.resolve("foo.kt")
    file.writeText(code)

    val exitCode =
        Main(
                emptyInput,
                PrintStream(out),
                PrintStream(err),
                arrayOf("--stdin-name=bar.kt", file.toString()))
            .run()

    assertThat(file.readText()).isEqualTo(code)
    assertThat(out.toString("UTF-8")).isEmpty()
    assertThat(err.toString("UTF-8")).isEqualTo("Error: --stdin-name can only be used with stdin\n")
    assertThat(exitCode).isEqualTo(1)
  }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.facebook.ktfmt.cli

import com.facebook.ktfmt.format.Formatter
import com.facebook.ktfmt.format.FormattingOptions
import com.google.common.truth.Truth.assertThat
import java.io.ByteArrayOutputStream
import java.io.FileNotFoundException
import java.io.PrintStream
import kotlin.io.path.createTempDirectory
import kotlin.test.assertFailsWith
import org.junit.After
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4

@Suppress("FunctionNaming")
@RunWith(JUnit4::class)
class ParsedArgsTest {

  private val root = createTempDirectory().toFile()

  @After
  fun tearDown() {
    root.deleteRecursively()
  }

  @Test
  fun `files to format are returned and unknown flags are reported`() {
    val (parsed, out) = parseTestOptions("foo.kt", "--unknown")

    assertThat(parsed.fileNames).containsExactly("foo.kt")
    assertThat(out.toString()).isEqualTo("Unexpected option: --unknown\n")
  }

  @Test
  fun `files to format are returned and flags starting with @ are reported`() {
    val (parsed, out) = parseTestOptions("foo.kt", "@unknown")

    assertThat(parsed.fileNames).containsExactly("foo.kt")
    assertThat(out.toString()).isEqualTo("Unexpected option: @unknown\n")
  }

  @Test
  fun `parseOptions uses default values when args are empty`() {
    val (parsed, _) = parseTestOptions("foo.kt")

    val formattingOptions = parsed.formattingOptions
    assertThat(formattingOptions.style).isEqualTo(FormattingOptions.Style.FACEBOOK)
    assertThat(formattingOptions.maxWidth).isEqualTo(100)
    assertThat(formattingOptions.blockIndent).isEqualTo(2)
    assertThat(formattingOptions.continuationIndent).isEqualTo(4)
    assertThat(formattingOptions.removeUnusedImports).isTrue()
    assertThat(formattingOptions.debuggingPrintOpsAfterFormatting).isFalse()

    assertThat(parsed.dryRun).isFalse()
    assertThat(parsed.setExitIfChanged).isFalse()
    assertThat(parsed.stdinName).isNull()
  }

  @Test
  fun `parseOptions recognizes --dropbox-style and rejects unknown flags`() {
    val (parsed, out) = parseTestOptions("--dropbox-style", "foo.kt", "--unknown")

    assertThat(parsed.fileNames).containsExactly("foo.kt")
    assertThat(parsed.formattingOptions.blockIndent).isEqualTo(4)
    assertThat(parsed.formattingOptions.continuationIndent).isEqualTo(4)
    assertThat(out.toString()).isEqualTo("Unexpected option: --unknown\n")
  }

  @Test
  fun `parseOptions recognizes --google-style`() {
    val (parsed, _) = parseTestOptions("--google-style", "foo.kt")
    assertThat(parsed.formattingOptions).isEqualTo(Formatter.GOOGLE_FORMAT)
  }

  @Test
  fun `parseOptions recognizes --dry-run`() {
    val (parsed, _) = parseTestOptions("--dry-run", "foo.kt")
    assertThat(parsed.dryRun).isTrue()
  }

  @Test
  fun `parseOptions recognizes -n as --dry-run`() {
    val (parsed, _) = parseTestOptions("-n", "foo.kt")
    assertThat(parsed.dryRun).isTrue()
  }

  @Test
  fun `parseOptions recognizes --set-exit-if-changed`() {
    val (parsed, _) = parseTestOptions("--set-exit-if-changed", "foo.kt")
    assertThat(parsed.setExitIfChanged).isTrue()
  }

  @Test
  fun `parseOptions defaults to removing imports`() {
    val (parsed, _) = parseTestOptions("foo.kt")
    assertThat(parsed.formattingOptions.removeUnusedImports).isTrue()
  }

  @Test
  fun `parseOptions recognizes --do-not-remove-unused-imports to removing imports`() {
    val (parsed, _) = parseTestOptions("--do-not-remove-unused-imports", "foo.kt")
    assertThat(parsed.formattingOptions.removeUnusedImports).isFalse()
  }

  @Test
  fun `parseOptions handles dropbox style and --do-not-remove-unused-imports`() {
    val (parsed, _) =
        parseTestOptions("--do-not-remove-unused-imports", "--dropbox-style", "foo.kt")
    assertThat(parsed.formattingOptions.removeUnusedImports).isFalse()
    assertThat(parsed.formattingOptions.style).isEqualTo(FormattingOptions.Style.DROPBOX)
  }

  @Test
  fun `parseOptions handles google style and --do-not-remove-unused-imports`() {
    val (parsed, _) = parseTestOptions("--do-not-remove-unused-imports", "--google-style", "foo.kt")
    assertThat(parsed.formattingOptions.removeUnusedImports).isFalse()
    assertThat(parsed.formattingOptions.style).isEqualTo(FormattingOptions.Style.GOOGLE)
  }

  @Test
  fun `parseOptions --stdin-name`() {
    val (parsed, _) = parseTestOptions("--stdin-name=my/foo.kt")
    assertThat(parsed.stdinName).isEqualTo("my/foo.kt")
  }

  @Test
  fun `parseOptions --stdin-name with empty value`() {
    val (parsed, _) = parseTestOptions("--stdin-name=")
    assertThat(parsed.stdinName).isEqualTo("")
  }

  @Test
  fun `parseOptions --stdin-name without value`() {
    val (parsed, out) = parseTestOptions("--stdin-name")
    assertThat(out).isEqualTo("Found option '--stdin-name', expected '--stdin-name=<value>'\n")
    assertThat(parsed.stdinName).isNull()
  }

  @Test
  fun `parseOptions --stdin-name prefix`() {
    val (parsed, out) = parseTestOptions("--stdin-namea")
    assertThat(out).isEqualTo("Found option '--stdin-namea', expected '--stdin-name=<value>'\n")
    assertThat(parsed.stdinName).isNull()
  }

  @Test
  fun `processArgs use the @file option with non existing file`() {
    val out = ByteArrayOutputStream()

    val e =
        assertFailsWith<FileNotFoundException> {
          ParsedArgs.processArgs(PrintStream(out), arrayOf("@non-existing-file"))
        }
    assertThat(e.message).contains("non-existing-file (No such file or directory)")
  }

  @Test
  fun `processArgs use the @file option with file containing arguments`() {
    val out = ByteArrayOutputStream()
    val file = root.resolve("existing-file")
    file.writeText("--google-style\n--dry-run\n--set-exit-if-changed\nFile1.kt\nFile2.kt\n")

    val parsed = ParsedArgs.processArgs(PrintStream(out), arrayOf("@" + file.absolutePath))

    assertThat(parsed.formattingOptions).isEqualTo(Formatter.GOOGLE_FORMAT)
    assertThat(parsed.dryRun).isTrue()
    assertThat(parsed.setExitIfChanged).isTrue()
    assertThat(parsed.fileNames).containsExactlyElementsIn(listOf("File1.kt", "File2.kt"))
  }

  private fun parseTestOptions(vararg args: String): Pair<ParsedArgs, String> {
    val out = ByteArrayOutputStream()
    return Pair(ParsedArgs.parseOptions(PrintStream(out), arrayOf(*args)), out.toString())
  }
}