import std/[algorithm, json, strutils, tables, wordwrap]

import ./reference_parser

type
  SegmentBreak = enum
    sbSame = "same"
    sbLine = "line"
    sbParagraph = "paragraph"

  BibleSegment = object
    code: string
    chapter: int
    verse: int
    segment: int
    breakKind: SegmentBreak
    indent: int
    text: string
    annotations: seq[BibleAnnotation]

  BibleAnnotation = object
    kind: string
    offset: int
    targetText: string

  EmbeddedOutputFormat = enum
    eofPlain
    eofMarkdown

  BibleIndex = object
    segments: seq[BibleSegment]
    verses: Table[string, bool]
    lastVerseByChapter: Table[string, int]
    lastChapterByBook: Table[string, int]
    translationName: string

proc verseKey(code: string, chapter, verse: int): string =
  code & "\t" & $chapter & "\t" & $verse

proc chapterKey(code: string, chapter: int): string =
  code & "\t" & $chapter

proc legacySegment(text: string): tuple[breakKind: SegmentBreak, text: string] =
  const pilcrow = "\xC2\xB6"
  let stripped = text.strip(leading = true, trailing = false)

  if stripped.startsWith(pilcrow):
    result.breakKind = sbParagraph
    result.text = stripped[pilcrow.len .. ^1].strip(leading = true, trailing = false)
  else:
    result.breakKind = sbSame
    result.text = text

proc parseSegmentBreak(s: string): SegmentBreak =
  case s
  of "same": sbSame
  of "line": sbLine
  of "paragraph": sbParagraph
  else:
    raise newException(ValueError, "invalid embedded Bible segment break: " & s)

proc parseAnnotations(metadata: string): seq[BibleAnnotation] =
  if metadata.strip.len == 0:
    return

  let node = parseJson(metadata)
  if node.kind != JObject or not node.hasKey("annotations"):
    return

  for item in node["annotations"].getElems:
    let targetText =
      if item.hasKey("targetText"): item["targetText"].getStr
      else: ""

    if targetText.strip.len == 0:
      continue

    result.add(BibleAnnotation(
      kind:
        if item.hasKey("kind"): item["kind"].getStr
        else: "",
      offset:
        if item.hasKey("offset"): item["offset"].getInt
        else: 0,
      targetText: targetText))

proc addSegment(index: var BibleIndex, segment: BibleSegment) =
  index.segments.add(segment)
  index.verses[verseKey(segment.code, segment.chapter, segment.verse)] = true

  let cKey = chapterKey(segment.code, segment.chapter)
  if not index.lastVerseByChapter.hasKey(cKey) or
      segment.verse > index.lastVerseByChapter[cKey]:
    index.lastVerseByChapter[cKey] = segment.verse

  if not index.lastChapterByBook.hasKey(segment.code) or
      segment.chapter > index.lastChapterByBook[segment.code]:
    index.lastChapterByBook[segment.code] = segment.chapter

proc loadBibleIndex(rows, translationName: string): BibleIndex =
  result.translationName = translationName

  for line in rows.splitLines:
    if line.strip.len == 0:
      continue

    let parts = line.split('\t', maxsplit = 7)
    if parts.len != 4 and parts.len != 7 and parts.len != 8:
      raise newException(ValueError,
        "invalid embedded " & translationName & " row: " & line)

    if parts.len == 4:
      let legacy = legacySegment(parts[3])
      result.addSegment(BibleSegment(
        code: parts[0],
        chapter: parseInt(parts[1]),
        verse: parseInt(parts[2]),
        segment: 0,
        breakKind: legacy.breakKind,
        indent: 0,
        text: legacy.text))
    else:
      result.addSegment(BibleSegment(
        code: parts[0],
        chapter: parseInt(parts[1]),
        verse: parseInt(parts[2]),
        segment: parseInt(parts[3]),
        breakKind: parseSegmentBreak(parts[4]),
        indent: parseInt(parts[5]),
        text: parts[6],
        annotations:
          if parts.len == 8: parseAnnotations(parts[7])
          else: @[]))

proc requireLastChapter(index: BibleIndex, code: string): int =
  if not index.lastChapterByBook.hasKey(code):
    raise newException(ValueError,
      "no embedded " & index.translationName & " data for " & code)
  index.lastChapterByBook[code]

proc requireLastVerse(index: BibleIndex, code: string, chapter: int): int =
  let cKey = chapterKey(code, chapter)
  if not index.lastVerseByChapter.hasKey(cKey):
    raise newException(ValueError,
      "no embedded " & index.translationName & " data for " &
        bookInfo(code).name & " " & $chapter)
  index.lastVerseByChapter[cKey]

proc requireVerse(index: BibleIndex, code: string, chapter, verse: int): string =
  let vKey = verseKey(code, chapter, verse)
  if not index.verses.hasKey(vKey):
    raise newException(ValueError,
      "no embedded " & index.translationName & " data for " &
        bookInfo(code).name & " " & $chapter & ":" & $verse)
  ""

proc selectedVerses(index: BibleIndex, reference: PassageReference, range: RefRange):
    Table[string, bool] =

  let code = reference.book.code
  discard index.requireLastChapter(code)

  for chapter in range.start.chapter .. range.finish.chapter:
    let startVerse =
      if chapter == range.start.chapter and range.start.verse > 0:
        range.start.verse
      else:
        1

    let endVerse =
      if chapter == range.finish.chapter and range.finish.verse > 0:
        range.finish.verse
      else:
        index.requireLastVerse(code, chapter)

    if startVerse > endVerse:
      raise newException(ValueError, "reference range starts after it ends")

    for verse in startVerse .. endVerse:
      discard index.requireVerse(code, chapter, verse)
      result[verseKey(code, chapter, verse)] = true

proc shouldSeparateSegments(previous, next: string): bool =
  if previous.len == 0 or next.len == 0:
    return false
  if previous[^1].isSpaceAscii or next[0].isSpaceAscii:
    return false
  next[0] notin {'.', ',', ';', ':', '?', '!', ')', ']', '}'}

proc appendSameLine(line: var string, content: string) =
  if shouldSeparateSegments(line, content):
    line.add(" ")
  line.add(content)

proc addFormattedSegment(
    lines: var seq[string],
    segment: BibleSegment,
    emittedVerses: var Table[string, bool]) =

  let vKey = verseKey(segment.code, segment.chapter, segment.verse)
  let marker =
    if emittedVerses.hasKey(vKey): ""
    else:
      emittedVerses[vKey] = true
      "[" & $segment.verse & "] "

  let prefix = repeat("  ", segment.indent + 1)
  let content = marker & segment.text
  let hasPassageLine = lines.len > 1 and lines[^1].len > 0

  if segment.breakKind == sbSame and hasPassageLine:
    lines[^1].appendSameLine(content)
  else:
    let isMarkerlessParagraphContinuation =
      segment.breakKind == sbParagraph and marker.len == 0 and hasPassageLine

    if isMarkerlessParagraphContinuation:
      lines[^1].appendSameLine(content)
    else:
      if segment.breakKind == sbParagraph and hasPassageLine:
        lines.add("")
      lines.add(prefix & content)

proc normalizeFirstSelectedSegment(segment: BibleSegment): BibleSegment =
  result = segment
  if result.breakKind == sbSame:
    result.breakKind = sbLine
  elif result.breakKind == sbParagraph and result.segment == 0:
    result.breakKind = sbLine

proc addAllSegments(lines: var seq[string], index: BibleIndex, code: string) =
  var emittedVerses: Table[string, bool]
  var firstSelected = true

  discard index.requireLastChapter(code)
  for segment in index.segments:
    if segment.code == code:
      var adjusted = segment
      if firstSelected:
        adjusted = normalizeFirstSelectedSegment(adjusted)
      lines.addFormattedSegment(adjusted, emittedVerses)
      firstSelected = false

proc addRangeSeparator(lines: var seq[string]) =
  if lines.len > 1 and lines[^1].len > 0:
    lines.add("")

proc addVerseLines(
    lines: var seq[string],
    index: BibleIndex,
    reference: PassageReference,
    range: RefRange) =

  let code = reference.book.code
  let selected = index.selectedVerses(reference, range)
  var emittedVerses: Table[string, bool]
  var firstSelected = true

  for segment in index.segments:
    if segment.code == code and
        selected.hasKey(verseKey(segment.code, segment.chapter, segment.verse)):
      var adjusted = segment
      if firstSelected:
        adjusted = normalizeFirstSelectedSegment(adjusted)
      lines.addFormattedSegment(adjusted, emittedVerses)
      firstSelected = false

proc fetchReference(index: BibleIndex, reference: PassageReference): string =
  var lines = @[$reference]
  let code = reference.book.code

  if reference.ranges.len == 0:
    lines.addAllSegments(index, code)
  else:
    for idx, range in reference.ranges:
      if idx > 0:
        lines.addRangeSeparator()
      lines.addVerseLines(index, reference, range)

  lines.join("\n")

proc fetchPassages*(rows, reference, translationName: string): seq[string] =
  let index = loadBibleIndex(rows, translationName)
  for parsedReference in parseReferences(reference):
    result.add(fetchReference(index, parsedReference))

proc footnoteLabel(index: int): string =
  var n = index
  while true:
    result.insert($char(ord('a') + (n mod 26)), 0)
    n = (n div 26) - 1
    if n < 0:
      break

proc addSegmentWithFootnotes(
    line: var string,
    segment: BibleSegment,
    keepVerseNumbers: bool,
    notes: var seq[string],
    outputFormat: EmbeddedOutputFormat) =

  var text = segment.text
  var annotations = segment.annotations
  annotations.sort(proc(a, b: BibleAnnotation): int = cmp(a.offset, b.offset))

  var inserted = 0
  for annotation in annotations:
    let label = footnoteLabel(notes.len)
    notes.add(annotation.targetText)

    let insertAt = max(0, min(text.len, annotation.offset + inserted))
    let marker =
      case outputFormat
      of eofPlain: "[" & label & "]"
      of eofMarkdown: "[^" & label & "]"
    text.insert(marker, insertAt)
    inserted += marker.len

  let marker =
    if keepVerseNumbers:
      case outputFormat
      of eofPlain: $segment.verse & " "
      of eofMarkdown: "**" & $segment.verse & "** "
    else:
      ""

  let content = marker & text
  if line.len == 0:
    line.add(repeat("  ", segment.indent) & content)
  elif segment.breakKind == sbSame:
    line.appendSameLine(content)
  else:
    line.add("\n" & repeat("  ", segment.indent) & content)

proc addFootnotes(
    lines: var seq[string],
    notes: seq[string],
    outputFormat: EmbeddedOutputFormat) =
  if notes.len == 0:
    return

  lines.add("")
  for idx, note in notes:
    let label = footnoteLabel(idx)
    lines.add(
      case outputFormat
      of eofPlain: label & ". " & note
      of eofMarkdown: "[^" & label & "]: " & note)

proc selectedChapters(index: BibleIndex, reference: PassageReference):
    seq[tuple[chapter: int, verses: Table[string, bool]]] =

  let code = reference.book.code

  if reference.ranges.len == 0:
    for chapter in 1 .. index.requireLastChapter(code):
      var verses: Table[string, bool]
      for verse in 1 .. index.requireLastVerse(code, chapter):
        discard index.requireVerse(code, chapter, verse)
        verses[verseKey(code, chapter, verse)] = true
      result.add((chapter, verses))
  else:
    var chapterPositions: Table[int, int]
    for range in reference.ranges:
      let selected = index.selectedVerses(reference, range)
      for key in selected.keys:
        let parts = key.split('\t')
        let chapter = parseInt(parts[1])
        if not chapterPositions.hasKey(chapter):
          chapterPositions[chapter] = result.len
          var verses: Table[string, bool]
          result.add((chapter, verses))
        result[chapterPositions[chapter]].verses[key] = true

proc formattedChapter(
    index: BibleIndex,
    code: string,
    selected: Table[string, bool],
    keepVerseNumbers: bool,
    outputFormat: EmbeddedOutputFormat): string =

  var lines: seq[string]
  var currentLine = ""
  var notes: seq[string]
  var emittedVerses: Table[string, bool]
  var firstSelected = true

  proc flushLine() =
    if currentLine.len > 0:
      lines.add(currentLine)
      currentLine = ""

  for segment in index.segments:
    if segment.code != code:
      continue

    let key = verseKey(segment.code, segment.chapter, segment.verse)
    if not selected.hasKey(key):
      continue

    var adjusted = segment
    if firstSelected:
      adjusted = normalizeFirstSelectedSegment(adjusted)

    let startsNewParagraph =
      adjusted.breakKind == sbParagraph and
      not firstSelected and
      (not emittedVerses.hasKey(key))

    if startsNewParagraph:
      flushLine()
      lines.add("")
    elif adjusted.breakKind == sbLine:
      flushLine()

    let marker =
      if emittedVerses.hasKey(key): false
      else:
        emittedVerses[key] = true
        true

    var renderSegment = adjusted
    if not marker:
      renderSegment.verse = 0

    if marker:
      currentLine.addSegmentWithFootnotes(
        renderSegment,
        keepVerseNumbers,
        notes,
        outputFormat)
    else:
      var noVerseSegment = renderSegment
      noVerseSegment.text = renderSegment.text
      noVerseSegment.breakKind = sbSame
      currentLine.addSegmentWithFootnotes(noVerseSegment, false, notes, outputFormat)

    firstSelected = false

  flushLine()
  lines.addFootnotes(notes, outputFormat)

  var wrapped: seq[string]
  for line in lines:
    if line.len == 0:
      wrapped.add("")
      continue

    let prepared =
      if line.len > 90: line.strip
      else: line & "  "
    wrapped.add(wrapWords(prepared, maxLineWidth = 74, newLine = "\p"))
  wrapped.join("\p")

proc fetchFormattedPassages(
    rows,
    reference,
    translationName: string,
    keepVerseNumbers: bool,
    outputFormat: EmbeddedOutputFormat): seq[string] =

  let index = loadBibleIndex(rows, translationName)
  for parsedReference in parseReferences(reference):
    var parts: seq[string]
    for chapter in index.selectedChapters(parsedReference):
      parts.add(index.formattedChapter(
        parsedReference.book.code,
        chapter.verses,
        keepVerseNumbers,
        outputFormat))

    let body = parts.join("\p\p")
    let referenceSeparator =
      case outputFormat
      of eofPlain:
        if body.contains("\pa. "): "\p\p"
        else: "\p"
      of eofMarkdown:
        if body.contains("\p[^a]: "): "\p\p"
        else: "\p"

    let referenceLine =
      case outputFormat
      of eofPlain:
        "– " & $parsedReference & " (" & translationName.toUpperAscii & ")"
      of eofMarkdown:
        "> -- *" & $parsedReference & " (" &
          translationName.toUpperAscii & ")*"

    let formattedBody =
      case outputFormat
      of eofPlain:
        body
      of eofMarkdown:
        var markdownLines: seq[string]
        for line in body.splitLines:
          if line.len == 0:
            markdownLines.add("")
          elif line.startsWith("[^"):
            markdownLines.add(line)
          else:
            markdownLines.add("> " & line)
        markdownLines.join("\p")

    result.add(formattedBody & referenceSeparator & referenceLine)

proc fetchPlainPassages*(
    rows,
    reference,
    translationName: string,
    keepVerseNumbers = true): seq[string] =

  fetchFormattedPassages(
    rows,
    reference,
    translationName,
    keepVerseNumbers,
    eofPlain)

proc fetchMarkdownPassages*(rows, reference, translationName: string): seq[string] =
  fetchFormattedPassages(
    rows,
    reference,
    translationName,
    keepVerseNumbers = true,
    eofMarkdown)
