{.push raises: [].}

import std/os
import std/posix

import lcgi

import utils/lrewrap
import monoucha/libregexp

proc parseSection(query: string): tuple[page, section: string] =
  var section = ""
  if query.len > 0 and query[^1] == ')':
    for i in countdown(query.high, 0):
      if query[i] == '(':
        section = query.substr(i + 1, query.high - 1)
        break
  if section != "":
    return (query.substr(0, query.high - 2 - section.len), section)
  return (query, "")

proc processBackspace(line: string): string =
  var s = ""
  var i = 0
  var thiscs = 0 .. -1
  var bspace = false
  var inU = false
  var inB = false
  var pendingInU = false
  var pendingInB = false
  while i < line.len:
    # this is the same "sometimes works" algorithm as in ansi2html
    if line[i] == '\b' and thiscs.len > 0:
      bspace = true
      inc i
      continue
    let n = line.pointLenAt(i)
    if thiscs.len == 0:
      thiscs = i ..< i + n
      i += n
      continue
    if bspace and thiscs.len > 0:
      if line[i] == '_' and not pendingInU and line[thiscs.a] != '_':
        pendingInU = true
      elif line[thiscs.a] == '_' and not pendingInU and line[i] != '_':
        # underscore comes first; set thiscs to the current charseq
        thiscs = i ..< i + n
        pendingInU = true
      elif line[i] == '_' and line[thiscs.a] == '_':
        if inB and not pendingInB:
          pendingInB = true
        elif inU and not pendingInU:
          pendingInU = true
        elif not pendingInB:
          pendingInB = true
        else:
          pendingInU = true
      elif not pendingInB:
        pendingInB = true
      bspace = false
    else:
      if pendingInU != inU:
        s &= (if inU: "</u>" else: "<u>")
        inU = pendingInU
      if pendingInB != inB:
        s &= (if inB: "</b>" else: "<b>")
        inB = pendingInB
      if thiscs.len > 0:
        let cs = case line[thiscs.a]
        of '&': "&amp;"
        of '<': "&lt;"
        of '>': "&gt;"
        else: line.substr(thiscs.a, thiscs.b)
        s &= cs
      thiscs = i ..< i + n
      pendingInU = false
      pendingInB = false
    i += n
  if pendingInU != inU:
    s &= (if inU: "</u>" else: "<u>")
    inU = pendingInU
  if pendingInB != inB:
    s &= (if inB: "</b>" else: "<b>")
    inB = pendingInB
  if thiscs.len > 0:
    let cs = case line[thiscs.a]
    of '&': "&amp;"
    of '<': "&lt;"
    of '>': "&gt;"
    else: line.substr(thiscs.a, thiscs.b)
    s &= cs
  if inU: s &= "</u>"
  if inB: s &= "</b>"
  move(s)

proc isCommand(paths: seq[string]; name, s: string): bool =
  for p in paths:
    if p & name == s:
      return true
  false

proc readErrorMsg(efile: AChaFile; line: var string): string =
  var msg = ""
  while true:
    # try to get the error message into an acceptable format
    if line.startsWith("man: "):
      line.delete(0..4)
    line = line.toLowerAscii().strip().replaceControls()
    if line.len > 0 and line[^1] == '.':
      line.setLen(line.high)
    if msg != "":
      msg &= ' '
    msg &= line
    if not efile.readLine(line).get(false):
      break
  move(msg)

const RegexStr =
  r"((https?|ftp)://[\w/~.-]+)|" &              # link, 1
  r"((mailto:|)(\w[\w.-]*@[\w-]+\.[\w.-]*))|" & # mail, 3
  r"((file:)?[/~][\w/~.-]+[\w/])|" &            # file, 6
  r"(#include((</?[bu]>|\s)*)&lt;([\w./-]+))|" &  # include, 8-11
  r"((</?[bu]>)*(\w[\w.-]*)(</?[bu]>)*(\([0-9nlx]\w*\)))" # man, 12-16

proc processManpage(ofile, efile: AChaFile; header, keyword: string):
    Opt[void] =
  let stdout = cast[ChaFile](stdout)
  var line = ""
  # The "right thing" would be to check for the error code and output error
  # messages accordingly. Unfortunately that would prevent us from streaming
  # the output, so what we do instead is:
  # * read first line
  # * if EOF, probably an error; read all of stderr and print it
  # * if not EOF, probably not an error; print stdout as a document and ignore
  #   stderr
  # This may break in some edge cases, e.g. if man writes a long error
  # message to stdout. But it's much better (faster) than not streaming the
  # output.
  if not ofile.readLine(line).get(false):
    var wstatus: cint
    discard wait(addr wstatus)
    if not WIFEXITED(wstatus) or WEXITSTATUS(wstatus) != 0:
      cgiDie(ceFileNotFound, efile.readErrorMsg(line))
  # skip formatting of line 0, like w3mman does
  # this is useful because otherwise the header would get caught in the man
  # regex, and that makes navigation slightly more annoying
  ?stdout.write(header)
  ?stdout.writeLine(line.processBackspace())
  var wasBlank = false
  # regexes partially from w3mman2html
  var re: Regex
  if not RegexStr.compileRegex({LRE_FLAG_GLOBAL, LRE_FLAG_UNICODE}, re):
    discard cast[ChaFile](stderr).writeLine(re.bytecode)
    quit(1)
  var paths: seq[string] = @[]
  var ignoreMan = keyword.toUpperAscii()
  if ignoreMan == keyword or keyword.len == 1:
    ignoreMan = ""
  for p in getEnvEmpty("PATH").split(':'):
    var i = p.high
    while i > 0 and p[i] == '/':
      dec i
    paths.add(p.substr(0, i) & "/")
  while ?ofile.readLine(line):
    if line == "":
      if wasBlank:
        continue
      wasBlank = true
    else:
      wasBlank = false
    let line = line.processBackspace()
    var oline = ""
    var offset = 0
    var ctx = initContext(re)
    for ret in ctx.exec(line):
      if ret != 1:
        oline &= line.substr(offset)
        break
      let cap = ctx.cap(0)
      oline &= line[offset..<cap.s]
      offset = cap.e
      var s = line[cap.s..<cap.e]
      if ctx.cap(1).s >= 0: # link
        oline &= "<a href='" & s & "'>" & s & "</a>"
      elif ctx.cap(3).s >= 0: # mail
        oline &= "<a href='mailto:" & s & "'>" & s & "</a>"
      elif ctx.cap(6).s >= 0: # file
        let target = s.expandTilde()
        if not fileExists(target) and not symlinkExists(target) and
            not dirExists(target):
          oline &= s
          continue
        let name = target.afterLast('/')
        if paths.isCommand(name, target):
          oline &= "<a href='man:" & name & "'>" & s & "</a>"
        else:
          oline &= "<a href='file:" & target & "'>" & s & "</a>"
      elif ctx.cap(8).s >= 0: # include
        const includePaths = [
          "/usr/include/",
          "/usr/local/include/",
          "/usr/X11R6/include/",
          "/usr/X11/include/",
          "/usr/X/include/",
          "/usr/include/X11/"
        ]
        block notFound:
          let fileCap = ctx.cap(11)
          let file = line[fileCap.s..<fileCap.e]
          let paddingCap = ctx.cap(9)
          for dir in includePaths:
            let path = dir & file
            if fileExists(path):
              oline &= "#include" & line[paddingCap.s..<paddingCap.e] &
               "&lt;<a href='file:" & path & "'>" & file & "</a>"
              break notFound
          oline &= s
      elif ctx.cap(12).s >= 0:
        let manCap = ctx.cap(14)
        let sectionCap = ctx.cap(16)
        let man = line[manCap.s..<manCap.e]
        # ignore footers like MYPAGE(1)
        # (just to be safe, we also check if it's in paths too)
        if man == ignoreMan and not paths.isCommand(man.afterLast('/'), man):
          oline &= s
          continue
        let section = line[sectionCap.s..<sectionCap.e]
        let cat = man & section
        s.setLen(s.len - section.len)
        oline &= "<a href='man:" & cat & "'>" & s & "</a>" & section
    ?stdout.writeLine(oline)
  ok()

proc myOpen(cmd: string): Opt[tuple[ofile, efile: AChaFile]] =
  var opipe = array[2, cint].default
  var epipe = array[2, cint].default
  if pipe(opipe) == -1 or pipe(epipe) == -1:
    return err()
  case fork()
  of -1: # fail
    return err()
  of 0: # child
    discard close(opipe[0])
    discard close(epipe[0])
    discard dup2(opipe[1], stdout.getFileHandle())
    discard dup2(epipe[1], stderr.getFileHandle())
    discard execl("/bin/sh", "sh", "-c", cstring(cmd), nil)
    exitnow(1)
  else: # parent
    discard close(opipe[1])
    discard close(epipe[1])
    if ofile := newPosixStream(opipe[0]).afdopen("r"):
      if efile := newPosixStream(epipe[0]).afdopen("r"):
        return ok((ofile, efile))
    discard close(opipe[0])
    discard close(epipe[0])
    return err()

proc doMan(man, keyword, section: string) =
  let sectionOpt = if section == "": "" else: ' ' & quoteShellPosix(section)
  let cmd = "MANCOLOR=1 GROFF_NO_SGR=1 MAN_KEEP_FORMATTING=1 " &
    man & sectionOpt & ' ' & quoteShellPosix(keyword)
  let (ofile, efile) = myOpen(cmd).orDie(ceInternalError, "failed to run man")
  var manword = keyword
  if section != "":
    manword &= '(' & section & ')'
  discard ofile.processManpage(efile, header = """Content-Type: text/html

<title>man """ & manword & """</title>
<pre>""", keyword = keyword)

proc doLocal(man, path: string) =
  # Note: we intentionally do not use -l, because it is not supported on
  # various systems (at the very least FreeBSD, NetBSD, macOS).
  var cmd = "MANCOLOR=1 GROFF_NO_SGR=1 MAN_KEEP_FORMATTING=1 " & man & ' '
  # ...and this worked fine until OpenBSD intentionally broke it in 7.8,
  # saying this provides "better consistency with POSIX and traditional UNIX
  # and BSD man(1)".
  #
  # Note that POSIX hardly specifies anything about man, and certainly
  # doesn't talk about whether this case should be supported.  And while
  # "traditional UNIX" might not have supported raw paths, this was the
  # *only* portable way to do it until OBSD finally ruined it.
  #
  # Anyway, we add a platform-specific ifdef and move on.
  when defined(openbsd):
    cmd &= "-l "
  cmd &= quoteShellPosix(path)
  let (ofile, efile) = myOpen(cmd).orDie(ceInternalError, "failed to run man")
  discard ofile.processManpage(efile, header = """Content-Type: text/html

<title>man -l """ & path & """</title>
<pre>""", keyword = path.afterLast('/').until('.'))

proc doKeyword(man, keyword, section: string): Opt[void] =
  let stdout = cast[ChaFile](stdout)
  let sectionOpt = if section == "": "" else: " -s " & quoteShellPosix(section)
  let cmd = man & sectionOpt & " -k " & quoteShellPosix(keyword)
  let (ofile, efile) = myOpen(cmd).orDie(ceInternalError, "failed to run man")
  var line: string
  if not ofile.readLine(line).get(false):
    var wstatus = cint(0)
    if wait(addr wstatus) >= 0 and not WIFEXITED(wstatus) or
        WEXITSTATUS(wstatus) != 0:
      cgiDie(ceFileNotFound, efile.readErrorMsg(line))
  ?stdout.write("Content-Type: text/html\n\n")
  ?stdout.write("<title>man" & sectionOpt & " -k " & keyword & "</title>\n")
  ?stdout.write("<h1>man" & sectionOpt & " -k <b>" & keyword & "</b></h1>\n")
  ?stdout.write("<ul>")
  while true:
    if line.len == 0:
      ?stdout.write("\n")
      if not ofile.readLine(line).get(false):
        break
      continue
    # collect titles
    var titles: seq[string] = @[]
    var i = 0
    while true:
      let title = line.until({'(', ','}, i)
      i += title.len
      titles.add(title)
      if i >= line.len or line[i] == '(':
        break
      i = line.skipBlanks(i + 1)
    # collect section
    if line[i] != '(':
      discard cast[ChaFile](stderr).writeLine("Error parsing line! " & line)
      quit(1)
    let sectionText = line.substr(i, line.find(')', i))
    i += sectionText.len
    # create line
    var section = sectionText.until(',') # for multiple sections, take first
    if section[^1] != ')':
      section &= ')'
    var s = "<li>"
    for i, title in titles:
      let title = title.htmlEscape()
      s &= "<a href='man:" & title & section & "'>" & title & "</a>"
      if i < titles.high:
        s &= ", "
    s &= sectionText
    s &= line.substr(i)
    ?stdout.writeLine(s)
    if not ofile.readLine(line).get(false):
      break
  ok()

proc main() =
  var man = getEnvEmpty("MANCHA_MAN")
  if man == "":
    block notfound:
      for s in ["/usr/bin/man", "/bin/man", "/usr/local/bin/man"]:
        if fileExists(s) or symlinkExists(s):
          man = s
          break notfound
      man = "/usr/bin/env man"
  var apropos = getEnvEmpty("MANCHA_APROPOS")
  if apropos == "":
    # on most systems, man is compatible with apropos (using -s syntax for
    # specifying sections).
    # ...not on FreeBSD :( here we have -S and MANSECT for specifying man
    # sections, and both are silently ignored when searching with -k. hooray.
    when not defined(freebsd):
      apropos = man
    else:
      apropos = "/usr/bin/apropos" # this is where it should be.
  let path = getEnvEmpty("MAPPED_URI_PATH")
  let scheme = getEnvEmpty("MAPPED_URI_SCHEME")
  if scheme == "man":
    let (keyword, section) = parseSection(path)
    doMan(man, keyword, section)
  elif scheme == "man-k":
    let (keyword, section) = parseSection(path)
    discard doKeyword(apropos, keyword, section)
  elif scheme == "man-l":
    doLocal(man, path)
  else:
    let stdout = cast[ChaFile](stdout)
    discard stdout.writeLine("Cha-Control: ConnectionError 1 invalid scheme")

main()

{.pop.} # raises: []
