import std/[strutils]

type
  BookInfo* = object
    code*: string
    name*: string
    singleChapter*: bool

  RefPoint* = object
    chapter*: int
    verse*: int

  RefRange* = object
    start*: RefPoint
    finish*: RefPoint

  PassageReference* = object
    book*: BookInfo
    ranges*: seq[RefRange]

const CanonBooks*: array[66, BookInfo] = [
  BookInfo(code: "GEN", name: "Genesis"),
  BookInfo(code: "EXO", name: "Exodus"),
  BookInfo(code: "LEV", name: "Leviticus"),
  BookInfo(code: "NUM", name: "Numbers"),
  BookInfo(code: "DEU", name: "Deuteronomy"),
  BookInfo(code: "JOS", name: "Joshua"),
  BookInfo(code: "JDG", name: "Judges"),
  BookInfo(code: "RUT", name: "Ruth"),
  BookInfo(code: "1SA", name: "1 Samuel"),
  BookInfo(code: "2SA", name: "2 Samuel"),
  BookInfo(code: "1KI", name: "1 Kings"),
  BookInfo(code: "2KI", name: "2 Kings"),
  BookInfo(code: "1CH", name: "1 Chronicles"),
  BookInfo(code: "2CH", name: "2 Chronicles"),
  BookInfo(code: "EZR", name: "Ezra"),
  BookInfo(code: "NEH", name: "Nehemiah"),
  BookInfo(code: "EST", name: "Esther"),
  BookInfo(code: "JOB", name: "Job"),
  BookInfo(code: "PSA", name: "Psalms"),
  BookInfo(code: "PRO", name: "Proverbs"),
  BookInfo(code: "ECC", name: "Ecclesiastes"),
  BookInfo(code: "SNG", name: "Song of Solomon"),
  BookInfo(code: "ISA", name: "Isaiah"),
  BookInfo(code: "JER", name: "Jeremiah"),
  BookInfo(code: "LAM", name: "Lamentations"),
  BookInfo(code: "EZK", name: "Ezekiel"),
  BookInfo(code: "DAN", name: "Daniel"),
  BookInfo(code: "HOS", name: "Hosea"),
  BookInfo(code: "JOL", name: "Joel"),
  BookInfo(code: "AMO", name: "Amos"),
  BookInfo(code: "OBA", name: "Obadiah", singleChapter: true),
  BookInfo(code: "JON", name: "Jonah"),
  BookInfo(code: "MIC", name: "Micah"),
  BookInfo(code: "NAM", name: "Nahum"),
  BookInfo(code: "HAB", name: "Habakkuk"),
  BookInfo(code: "ZEP", name: "Zephaniah"),
  BookInfo(code: "HAG", name: "Haggai"),
  BookInfo(code: "ZEC", name: "Zechariah"),
  BookInfo(code: "MAL", name: "Malachi"),
  BookInfo(code: "MAT", name: "Matthew"),
  BookInfo(code: "MRK", name: "Mark"),
  BookInfo(code: "LUK", name: "Luke"),
  BookInfo(code: "JHN", name: "John"),
  BookInfo(code: "ACT", name: "Acts"),
  BookInfo(code: "ROM", name: "Romans"),
  BookInfo(code: "1CO", name: "1 Corinthians"),
  BookInfo(code: "2CO", name: "2 Corinthians"),
  BookInfo(code: "GAL", name: "Galatians"),
  BookInfo(code: "EPH", name: "Ephesians"),
  BookInfo(code: "PHP", name: "Philippians"),
  BookInfo(code: "COL", name: "Colossians"),
  BookInfo(code: "1TH", name: "1 Thessalonians"),
  BookInfo(code: "2TH", name: "2 Thessalonians"),
  BookInfo(code: "1TI", name: "1 Timothy"),
  BookInfo(code: "2TI", name: "2 Timothy"),
  BookInfo(code: "TIT", name: "Titus"),
  BookInfo(code: "PHM", name: "Philemon", singleChapter: true),
  BookInfo(code: "HEB", name: "Hebrews"),
  BookInfo(code: "JAS", name: "James"),
  BookInfo(code: "1PE", name: "1 Peter"),
  BookInfo(code: "2PE", name: "2 Peter"),
  BookInfo(code: "1JN", name: "1 John"),
  BookInfo(code: "2JN", name: "2 John", singleChapter: true),
  BookInfo(code: "3JN", name: "3 John", singleChapter: true),
  BookInfo(code: "JUD", name: "Jude", singleChapter: true),
  BookInfo(code: "REV", name: "Revelation")
]

const bookAliases = [
  ("GEN", "genesis"), ("GEN", "gen"),
  ("EXO", "exodus"), ("EXO", "exod"), ("EXO", "exo"),
  ("LEV", "leviticus"), ("LEV", "lev"),
  ("NUM", "numbers"), ("NUM", "num"), ("NUM", "numb"),
  ("DEU", "deuteronomy"), ("DEU", "deut"), ("DEU", "deu"),
  ("JOS", "joshua"), ("JOS", "josh"), ("JOS", "jos"),
  ("JDG", "judges"), ("JDG", "judg"), ("JDG", "jdg"),
  ("RUT", "ruth"), ("RUT", "rut"),
  ("1SA", "1 samuel"), ("1SA", "1 sam"), ("1SA", "i samuel"), ("1SA", "first samuel"),
  ("2SA", "2 samuel"), ("2SA", "2 sam"), ("2SA", "ii samuel"), ("2SA", "second samuel"),
  ("1KI", "1 kings"), ("1KI", "1 kgs"), ("1KI", "1 kin"), ("1KI", "i kings"), ("1KI", "first kings"),
  ("2KI", "2 kings"), ("2KI", "2 kgs"), ("2KI", "2 kin"), ("2KI", "ii kings"), ("2KI", "second kings"),
  ("1CH", "1 chronicles"), ("1CH", "1 chron"), ("1CH", "1 chr"), ("1CH", "i chronicles"), ("1CH", "first chronicles"),
  ("2CH", "2 chronicles"), ("2CH", "2 chron"), ("2CH", "2 chr"), ("2CH", "ii chronicles"), ("2CH", "second chronicles"),
  ("EZR", "ezra"), ("EZR", "ezr"),
  ("NEH", "nehemiah"), ("NEH", "neh"),
  ("EST", "esther"), ("EST", "est"),
  ("JOB", "job"),
  ("PSA", "psalms"), ("PSA", "psalm"), ("PSA", "ps"), ("PSA", "psa"),
  ("PRO", "proverbs"), ("PRO", "prov"), ("PRO", "pro"),
  ("ECC", "ecclesiastes"), ("ECC", "eccl"), ("ECC", "ecc"),
  ("SNG", "song of solomon"), ("SNG", "song"), ("SNG", "songs"), ("SNG", "canticles"), ("SNG", "sng"),
  ("ISA", "isaiah"), ("ISA", "isa"),
  ("JER", "jeremiah"), ("JER", "jer"),
  ("LAM", "lamentations"), ("LAM", "lam"),
  ("EZK", "ezekiel"), ("EZK", "ezek"), ("EZK", "ezk"),
  ("DAN", "daniel"), ("DAN", "dan"),
  ("HOS", "hosea"), ("HOS", "hos"),
  ("JOL", "joel"), ("JOL", "jol"),
  ("AMO", "amos"), ("AMO", "amo"),
  ("OBA", "obadiah"), ("OBA", "obad"), ("OBA", "oba"),
  ("JON", "jonah"), ("JON", "jon"),
  ("MIC", "micah"), ("MIC", "mic"),
  ("NAM", "nahum"), ("NAM", "nah"),
  ("HAB", "habakkuk"), ("HAB", "hab"),
  ("ZEP", "zephaniah"), ("ZEP", "zeph"), ("ZEP", "zep"),
  ("HAG", "haggai"), ("HAG", "hag"),
  ("ZEC", "zechariah"), ("ZEC", "zech"), ("ZEC", "zec"),
  ("MAL", "malachi"), ("MAL", "mal"),
  ("MAT", "matthew"), ("MAT", "matt"), ("MAT", "mat"), ("MAT", "mt"),
  ("MRK", "mark"), ("MRK", "mrk"), ("MRK", "mk"),
  ("LUK", "luke"), ("LUK", "luk"), ("LUK", "lk"),
  ("JHN", "john"), ("JHN", "jhn"), ("JHN", "jn"),
  ("ACT", "acts"), ("ACT", "act"),
  ("ROM", "romans"), ("ROM", "rom"),
  ("1CO", "1 corinthians"), ("1CO", "1 cor"), ("1CO", "1 co"), ("1CO", "i corinthians"), ("1CO", "first corinthians"),
  ("2CO", "2 corinthians"), ("2CO", "2 cor"), ("2CO", "2 co"), ("2CO", "ii corinthians"), ("2CO", "second corinthians"),
  ("GAL", "galatians"), ("GAL", "gal"),
  ("EPH", "ephesians"), ("EPH", "eph"),
  ("PHP", "philippians"), ("PHP", "php"),
  ("COL", "colossians"), ("COL", "col"),
  ("1TH", "1 thessalonians"), ("1TH", "1 thess"), ("1TH", "1 thes"), ("1TH", "i thessalonians"), ("1TH", "first thessalonians"),
  ("2TH", "2 thessalonians"), ("2TH", "2 thess"), ("2TH", "2 thes"), ("2TH", "ii thessalonians"), ("2TH", "second thessalonians"),
  ("1TI", "1 timothy"), ("1TI", "1 tim"), ("1TI", "i timothy"), ("1TI", "first timothy"),
  ("2TI", "2 timothy"), ("2TI", "2 tim"), ("2TI", "ii timothy"), ("2TI", "second timothy"),
  ("TIT", "titus"), ("TIT", "tit"),
  ("PHM", "philemon"), ("PHM", "philem"), ("PHM", "phm"),
  ("HEB", "hebrews"), ("HEB", "heb"),
  ("JAS", "james"), ("JAS", "jas"), ("JAS", "jam"),
  ("1PE", "1 peter"), ("1PE", "1 pet"), ("1PE", "1 pe"), ("1PE", "i peter"), ("1PE", "first peter"),
  ("2PE", "2 peter"), ("2PE", "2 pet"), ("2PE", "2 pe"), ("2PE", "ii peter"), ("2PE", "second peter"),
  ("1JN", "1 john"), ("1JN", "1 jn"), ("1JN", "1 jhn"), ("1JN", "i john"), ("1JN", "first john"),
  ("2JN", "2 john"), ("2JN", "2 jn"), ("2JN", "2 jhn"), ("2JN", "ii john"), ("2JN", "second john"),
  ("3JN", "3 john"), ("3JN", "3 jn"), ("3JN", "3 jhn"), ("3JN", "iii john"), ("3JN", "third john"),
  ("JUD", "jude"), ("JUD", "jud"),
  ("REV", "revelation"), ("REV", "revelations"), ("REV", "rev"), ("REV", "apocalypse")
]

proc bookInfo*(code: string): BookInfo =
  for book in CanonBooks:
    if book.code == code:
      return book

  raise newException(ValueError, "unknown Bible book code '" & code & "'")

proc bookIndex*(code: string): int =
  for idx, book in CanonBooks:
    if book.code == code:
      return idx

  raise newException(ValueError, "unknown Bible book code '" & code & "'")

proc normalizeReferenceInput(s: string): string =
  s.multiReplace([
    ("–", "-"),
    ("—", "-"),
    ("−", "-")
  ]).strip

proc normalizeBookPrefix(s: string): string =
  for ch in s:
    if ch.isAlphaAscii:
      result.add(ch.toLowerAscii)
    elif ch.isDigit:
      result.add(ch)

proc canonicalNamePrefixMatches(prefix: string): seq[BookInfo] =
  for book in CanonBooks:
    if normalizeBookPrefix(book.name).startsWith(prefix):
      result.add(book)

proc formatBookList(books: seq[BookInfo]): string =
  var names: seq[string] = @[]
  for book in books:
    names.add(book.name)
  names.join(", ")

proc matchCanonicalBookPrefix(input: string): tuple[
    matched: bool,
    ambiguous: bool,
    book: BookInfo,
    consumed: int,
    prefix: string,
    matches: seq[BookInfo]] =

  for idx in 1 .. input.len:
    if idx < input.len and input[idx].isAlphaAscii:
      continue

    let prefix = normalizeBookPrefix(input[0 ..< idx])
    if prefix.len == 0:
      continue

    let matches = canonicalNamePrefixMatches(prefix)
    if matches.len == 1:
      result.matched = true
      result.ambiguous = false
      result.book = matches[0]
      result.consumed = idx
      result.prefix = input[0 ..< idx].strip
      result.matches = matches
    elif matches.len > 1 and not result.matched:
      result.ambiguous = true
      result.consumed = idx
      result.prefix = input[0 ..< idx].strip
      result.matches = matches

proc matchAlias(input, alias: string): int =
  var i = 0
  var j = 0

  while j < alias.len:
    let aliasCh = alias[j]
    if aliasCh.isSpaceAscii or aliasCh == '.':
      while i < input.len and (input[i].isSpaceAscii or input[i] == '.'):
        inc i
      inc j
    else:
      while i < input.len and input[i] == '.':
        inc i

      if i >= input.len or input[i].toLowerAscii != aliasCh.toLowerAscii:
        return -1

      inc i
      inc j

  while i < input.len and input[i] == '.':
    inc i

  if i < input.len and input[i].isAlphaAscii:
    return -1

  i

proc parseBook(input: string): tuple[book: BookInfo, rest: string] =
  let canonicalPrefix = matchCanonicalBookPrefix(input)
  if canonicalPrefix.matched:
    result.book = canonicalPrefix.book
    result.rest = input[canonicalPrefix.consumed .. ^1].strip
    return

  var bestCode = ""
  var bestLen = -1

  for row in bookAliases:
    let consumed = matchAlias(input, row[1])
    if consumed > bestLen:
      bestCode = row[0]
      bestLen = consumed

  if bestLen < 0:
    if canonicalPrefix.ambiguous:
      raise newException(ValueError,
        "ambiguous Bible book prefix '" & canonicalPrefix.prefix & "' in '" &
          input & "'; matches " & canonicalPrefix.matches.formatBookList)

    raise newException(ValueError, "could not parse Bible book in '" & input & "'")

  result.book = bookInfo(bestCode)
  result.rest = input[bestLen .. ^1].strip

proc parsePositiveInt(s, label: string): int =
  if s.len == 0 or not s.allCharsInSet({'0'..'9'}):
    raise newException(ValueError, "invalid " & label & " '" & s & "'")

  result = parseInt(s)
  if result <= 0:
    raise newException(ValueError, label & " must be positive")

proc parsePoint(token: string, defaultChapter: int, singleChapter: bool): RefPoint =
  let normalized = token.strip
  if normalized.len == 0:
    raise newException(ValueError, "empty reference point")

  let colonIdx = normalized.find(':')
  if colonIdx >= 0:
    return RefPoint(
      chapter: parsePositiveInt(normalized[0 ..< colonIdx], "chapter"),
      verse: parsePositiveInt(normalized[colonIdx + 1 .. ^1], "verse"))

  let value = parsePositiveInt(normalized, "reference number")
  if singleChapter:
    RefPoint(chapter: 1, verse: value)
  elif defaultChapter > 0:
    RefPoint(chapter: defaultChapter, verse: value)
  else:
    RefPoint(chapter: value, verse: 0)

proc parseRange(segment: string, defaultChapter: int, singleChapter: bool): RefRange =
  let normalized = segment.strip
  let dashIdx = normalized.find('-')

  if dashIdx >= 0:
    result.start = parsePoint(normalized[0 ..< dashIdx], defaultChapter, singleChapter)
    let endDefaultChapter =
      if result.start.verse > 0: result.start.chapter
      else: 0
    result.finish = parsePoint(normalized[dashIdx + 1 .. ^1], endDefaultChapter, singleChapter)
  else:
    result.start = parsePoint(normalized, defaultChapter, singleChapter)
    result.finish = result.start

  if result.finish.chapter < result.start.chapter:
    raise newException(ValueError, "range ends before it starts: '" & segment & "'")

  if result.finish.chapter == result.start.chapter and
      result.start.verse > 0 and
      result.finish.verse > 0 and
      result.finish.verse < result.start.verse:
    raise newException(ValueError, "range ends before it starts: '" & segment & "'")

proc parsePassageSpec(spec: string, book: BookInfo): seq[RefRange] =
  var currentChapter = 0

  for rawSegment in spec.split(','):
    let segment = rawSegment.strip
    if segment.len == 0:
      raise newException(ValueError, "empty passage range in '" & spec & "'")

    let range = parseRange(segment, currentChapter, book.singleChapter)
    result.add(range)

    if segment.contains(':') or (range.start.verse > 0 and range.finish.verse > 0):
      currentChapter = range.start.chapter
    else:
      currentChapter = 0

proc parseReference*(input: string): PassageReference =
  let normalized = normalizeReferenceInput(input)
  let parsedBook = parseBook(normalized)

  result.book = parsedBook.book
  if parsedBook.rest.len > 0:
    result.ranges = parsePassageSpec(parsedBook.rest, result.book)

proc parseReferences*(input: string): seq[PassageReference] =
  for rawRef in input.split(';'):
    let refText = rawRef.strip
    if refText.len > 0:
      result.add(parseReference(refText))

  if result.len == 0:
    raise newException(ValueError, "empty Bible reference")

proc `$`*(point: RefPoint): string =
  if point.verse > 0: $point.chapter & ":" & $point.verse
  else: $point.chapter

proc `$`*(range: RefRange): string =
  if range.start == range.finish:
    return $range.start

  if range.start.chapter == range.finish.chapter and
      range.start.verse > 0 and
      range.finish.verse > 0:
    return $range.start.chapter & ":" & $range.start.verse & "-" & $range.finish.verse

  $range.start & "-" & $range.finish

proc formatSingleChapterRange(range: RefRange): string =
  if range.start == range.finish:
    return $range.start.verse

  if range.start.chapter == range.finish.chapter:
    return $range.start.verse & "-" & $range.finish.verse

  $range.start & "-" & $range.finish

proc `$`*(reference: PassageReference): string =
  result = reference.book.name
  if reference.ranges.len > 0:
    var rangeText: seq[string] = @[]
    for range in reference.ranges:
      if reference.book.singleChapter:
        rangeText.add(formatSingleChapterRange(range))
      else:
        rangeText.add($range)
    result.add(" " & rangeText.join(", "))
