## docgen.nim — Extract `///` (and adjacent `/* */`) docs into Markdown.
## Used by `bux doc [path...]`.

import std/[os, strutils, strformat, algorithm]

type
  DocItem* = object
    kind*: string       ## module | func | struct | enum | interface | extern
    name*: string
    signature*: string  ## first declaration line (trimmed)
    docs*: string
    file*: string
    line*: int

proc isDeclStart(line: string): bool =
  let s = line.strip()
  if s.len == 0: return false
  # Leading attributes @[Checked] etc. — not a decl by themselves
  if s.startsWith("@["): return false
  if s.startsWith("func ") or s.startsWith("pub func ") or
     s.startsWith("extern func ") or s.startsWith("const func ") or
     s.startsWith("async func "):
    return true
  if s.startsWith("struct ") or s.startsWith("pub struct ") or
     s.startsWith("enum ") or s.startsWith("pub enum ") or
     s.startsWith("union ") or s.startsWith("interface ") or
     s.startsWith("module ") or s.startsWith("type "):
    return true
  return false

proc declKindAndName(line: string): tuple[kind, name: string] =
  var s = line.strip()
  # Strip leading pub/extern/const/async
  for prefix in ["pub ", "extern ", "const ", "async "]:
    if s.startsWith(prefix):
      s = s[prefix.len .. ^1].strip()
  var kind = "item"
  if s.startsWith("func "):
    kind = "func"
    s = s["func ".len .. ^1]
  elif s.startsWith("struct "):
    kind = "struct"
    s = s["struct ".len .. ^1]
  elif s.startsWith("enum "):
    kind = "enum"
    s = s["enum ".len .. ^1]
  elif s.startsWith("union "):
    kind = "union"
    s = s["union ".len .. ^1]
  elif s.startsWith("interface "):
    kind = "interface"
    s = s["interface ".len .. ^1]
  elif s.startsWith("module "):
    kind = "module"
    s = s["module ".len .. ^1]
  elif s.startsWith("type "):
    kind = "type"
    s = s["type ".len .. ^1]
  # Name: until `<` `(` `{` `:` space
  var name = ""
  for ch in s:
    if ch in {' ', '<', '(', '{', ':', ';'}:
      break
    name.add(ch)
  if name.len == 0:
    name = s
  # extern funcs already stripped "extern "
  if kind == "func" and line.strip().startsWith("extern"):
    kind = "extern"
  return (kind, name)

proc extractDocsFromSource*(source, path: string): seq[DocItem] =
  result = @[]
  var pending: seq[string] = @[]
  var inBlockComment = false
  var blockDoc: seq[string] = @[]
  var lineNo = 0
  for rawLine in source.splitLines():
    inc lineNo
    var line = rawLine
    let stripped = line.strip()

    # Block comment handling (/* ... */ used in stdlib today)
    if inBlockComment:
      let endIdx = stripped.find("*/")
      if endIdx >= 0:
        let before = stripped[0 ..< endIdx].strip()
        if before.len > 0:
          blockDoc.add(before)
        inBlockComment = false
        # Treat completed block as pending doc if non-empty
        if blockDoc.len > 0:
          pending = blockDoc
          blockDoc = @[]
        continue
      else:
        blockDoc.add(stripped)
        continue
    if stripped.startsWith("/*") and not stripped.startsWith("/***"):
      let rest = stripped["/*".len .. ^1]
      let endIdx = rest.find("*/")
      if endIdx >= 0:
        let body = rest[0 ..< endIdx].strip()
        if body.len > 0:
          pending = @[body]
      else:
        inBlockComment = true
        blockDoc = @[]
        let body = rest.strip()
        if body.len > 0:
          blockDoc.add(body)
      continue

    # Triple-slash doc comments
    if stripped.startsWith("///"):
      var body = stripped["///".len .. ^1]
      if body.startsWith(" "):
        body = body[1 .. ^1]
      pending.add(body)
      continue

    # Empty line: keep pending docs (allow blank lines inside doc blocks)
    if stripped.len == 0:
      continue

    # Attributes immediately before decl: keep pending
    if stripped.startsWith("@["):
      continue

    if isDeclStart(line):
      if pending.len > 0:
        let (kind, name) = declKindAndName(line)
        result.add(DocItem(
          kind: kind,
          name: name,
          signature: stripped,
          docs: pending.join("\n"),
          file: path,
          line: lineNo
        ))
      pending = @[]
      continue

    # Other code clears pending (except plain // comments)
    if stripped.startsWith("//"):
      continue
    pending = @[]

proc collectBuxFilesForDoc*(root: string): seq[string] =
  result = @[]
  if fileExists(root) and root.endsWith(".bux"):
    result.add(root)
    return
  if not dirExists(root):
    return
  for path in walkDirRec(root):
    if path.endsWith(".bux"):
      result.add(path)
  result.sort(system.cmp)

proc renderMarkdown*(items: seq[DocItem], title: string = "API Reference"): string =
  var sb: string
  sb.add(&"# {title}\n\n")
  sb.add("Generated by `bux doc` from `///` and `/* */` documentation comments.\n\n")
  if items.len == 0:
    sb.add("_No documented items found._\n")
    return sb

  # Group by file
  var byFile: seq[string] = @[]
  for it in items:
    if it.file notin byFile:
      byFile.add(it.file)
  byFile.sort(system.cmp)

  for f in byFile:
    let base = splitFile(f).name
    sb.add(&"## `{base}`\n\n")
    sb.add(&"_Source: `{f}`_\n\n")
    for it in items:
      if it.file != f:
        continue
      sb.add(&"### `{it.name}` _{it.kind}_\n\n")
      sb.add("```bux\n")
      sb.add(it.signature)
      sb.add("\n```\n\n")
      if it.docs.len > 0:
        sb.add(it.docs)
        sb.add("\n\n")
  return sb

proc generateDocs*(paths: seq[string]): seq[DocItem] =
  result = @[]
  var files: seq[string] = @[]
  for p in paths:
    for f in collectBuxFilesForDoc(p):
      if f notin files:
        files.add(f)
  files.sort(system.cmp)
  for f in files:
    let src = readFile(f)
    result.add(extractDocsFromSource(src, f))
