import std/[os, strutils, terminal, strformat, osproc, sets, algorithm, tables]
import lexer, parser, ast, sema, manifest, hir_lower, lir_lower, lir_c_backend
import source_location
import fmt
import docgen
import registry

type
  ColorMode* = enum
    cmAuto
    cmOn
    cmOff

  GlobalOptions* = object
    color*: ColorMode
    quiet*: bool
    verbose*: bool
    release*: bool   ## --release: -O2, no -g / no #line (E.4 dual)

proc printUsage*() =
  echo """Bux Programming Language (bootstrap compiler)

Usage: bux [options] <command> [command-options]

Commands:
  new <name>          Create a new Bux package
  init                Initialize a Bux package in the current directory
  add <name> [ver]    Add a dependency (--path, --git, or registry)
  install             Resolve and install dependencies
  search [query]      Search the package registry
  build               Build the current package
  run                 Build and run the current package
  test                Run tests in tests/ directory
  check               Type-check the current package
  fmt [path]          Format .bux sources (default: .)
  doc [path]          Generate Markdown API docs from /// comments
  clean               Remove build artifacts
  help                Show this help message
  version             Show version

Command options:
  test --filter <s>   Only run tests whose name contains <s>
  fmt  --check        Exit 1 if any file would be reformatted (CI)
  doc  --out <file>   Write docs to file (default: stdout)
  add  --path / --git Explicit source; else resolve via registry
  build --release     Optimized build (-O2, no debug / #line)

Registry:
  BUX_REGISTRY            Local path or http(s):// URL to registry.toml
  BUX_REGISTRY_REFRESH=1  Force re-download of HTTP index cache
  BUX_CFLAGS              Extra flags appended to the C compiler line

Global options:
  --color <auto|on|off>   Control colored output (default: auto)
  -q, --quiet             Suppress non-error output
  -v, --verbose           Verbose output
  --release               Optimize (-O2), omit -g and #line maps
"""

proc parseGlobalOptions(args: seq[string]): tuple[opts: GlobalOptions, rest: seq[string], ok: bool] =
  result.opts = GlobalOptions(color: cmAuto, quiet: false, verbose: false, release: false)
  result.rest = @[]
  result.ok = true
  var i = 0
  while i < args.len:
    let arg = args[i]
    if arg == "--color":
      if i + 1 >= args.len:
        stderr.writeLine("error: --color requires an argument")
        result.ok = false
        return
      inc i
      case args[i].toLowerAscii()
      of "auto": result.opts.color = cmAuto
      of "on": result.opts.color = cmOn
      of "off": result.opts.color = cmOff
      else:
        stderr.writeLine(&"error: unknown --color value '{args[i]}'")
        result.ok = false
        return
    elif arg == "-q" or arg == "--quiet":
      result.opts.quiet = true
    elif arg == "-v" or arg == "--verbose":
      result.opts.verbose = true
    elif arg == "--release":
      result.opts.release = true
    else:
      result.rest.add(arg)
    inc i

proc shouldUseColor(opts: GlobalOptions): bool =
  case opts.color
  of cmOn: true
  of cmOff: false
  of cmAuto: terminal.isatty(stdout)

proc printError(msg: string, useColor: bool) =
  if useColor:
    stdout.setForegroundColor(fgRed)
    stdout.write("error: ")
    stdout.resetAttributes()
    stdout.writeLine(msg)
  else:
    stderr.writeLine("error: " & msg)

proc printInfo(msg: string, useColor: bool) =
  if useColor:
    stdout.setForegroundColor(fgCyan)
    stdout.write("info: ")
    stdout.resetAttributes()
    stdout.writeLine(msg)
  else:
    echo("info: " & msg)

# ---------------------------------------------------------------------------
# Rust-style diagnostics (snippet + optional help hint)
# ---------------------------------------------------------------------------

proc getSourceLine(path: string, lineNum: uint32): string =
  ## Read a single 1-based line from path. Empty if unavailable.
  if path.len == 0 or lineNum == 0 or not fileExists(path):
    return ""
  try:
    let content = readFile(path)
    var n: uint32 = 1
    for line in content.splitLines():
      if n == lineNum:
        return line
      inc n
  except CatchableError:
    discard
  return ""

proc extractQuotedName(msg: string): string =
  ## Pull the first 'name' from messages like: undeclared identifier 'foo'
  let a = msg.find('\'')
  if a < 0: return ""
  let b = msg.find('\'', a + 1)
  if b <= a + 1: return ""
  return msg[a + 1 .. b - 1]

proc underlineLength(lineText: string, col: uint32, message: string): int =
  ## Multi-character underline under the token at `col` (1-based).
  ## Falls back to scanning a source token, or matching a quoted name in the message.
  if lineText.len == 0:
    return 1
  let start = if col > 0: int(col) - 1 else: 0
  if start < 0 or start >= lineText.len:
    return 1

  # Prefer highlighting the quoted identifier/token from the message when it
  # appears on this line (e.g. undeclared identifier 'foo').
  let quoted = extractQuotedName(message)
  if quoted.len > 0:
    let idx = lineText.find(quoted)
    if idx >= 0:
      # If caret is on/near that token, use its full length
      if abs(idx - start) <= quoted.len:
        return quoted.len

  let c0 = lineText[start]
  # String / char / backtick literals
  if c0 == '"' or c0 == '\'' or c0 == '`':
    let quote = c0
    var i = start + 1
    while i < lineText.len:
      if lineText[i] == '\\' and i + 1 < lineText.len:
        i += 2
        continue
      if lineText[i] == quote:
        return i - start + 1
      inc i
    return max(1, lineText.len - start)

  # Identifier or keyword
  if c0.isAlphaAscii or c0 == '_':
    var i = start
    while i < lineText.len and (lineText[i].isAlphaNumeric or lineText[i] == '_'):
      inc i
    return max(1, i - start)

  # Number literal
  if c0.isDigit:
    var i = start
    while i < lineText.len and (lineText[i].isDigit or lineText[i] in {'.', 'x', 'X', 'b', 'B', 'o', 'O', 'a'..'f', 'A'..'F', '_'}):
      inc i
    # optional type suffix: 42i64, 1.0f
    while i < lineText.len and lineText[i] in {'i', 'u', 'f', 'I', 'U', 'F', '0'..'9'}:
      inc i
    return max(1, i - start)

  # Multi-char operators starting at caret
  const multiOps = ["<<=", ">>=", "**", "++", "--", "==", "!=", "<=", ">=",
                    "&&", "||", "<<", ">>", "+=", "-=", "*=", "/=", "%=",
                    "&=", "|=", "^=", "=>", "..", "->"]
  for op in multiOps:
    if start + op.len <= lineText.len and lineText[start .. start + op.len - 1] == op:
      return op.len

  return 1

proc hintForMessage(msg: string): string =
  ## Actionable help text for common compiler errors.
  let m = msg.toLowerAscii()
  if "while it is mutably borrowed" in m:
    return "only one active '&mut' borrow is allowed at a time; end the borrow before reuse"
  if "cannot assign" in m:
    return "ensure the right-hand side type matches the left-hand side"
  if "undeclared identifier" in m:
    return "check the spelling, or import the symbol from the right module"
  if "too few arguments" in m or "too many arguments" in m:
    return "compare the call with the function's parameter list"
  if "missing argument for parameter" in m:
    return "provide the missing argument (positional or named)"
  if "use of moved value" in m:
    return "the value was moved; clone it or restructure ownership"
  if "cannot return reference to local" in m:
    return "return a value, or return a reference borrowed from a function parameter"
  if "lifetime elision failed" in m:
    return "add an explicit lifetime, e.g. func F<'a>(x: &'a T, y: &'a U) -> &'a T"
  if "lifetime mismatch" in m:
    return "returned reference must share a lifetime with the return type (annotate with 'a)"
  if "no input reference to borrow from" in m:
    return "add a '&T' parameter to borrow from, or return an owned value"
  if "shared reference" in m or "checked function" in m:
    return "use '&mut T' for mutation, or drop @[Checked] for unchecked code"
  if "double mutable borrow" in m or "already mutably borrowed" in m:
    return "only one active '&mut' borrow is allowed at a time; end the borrow before reuse"
  if "shared-borrow" in m or "shared-borrowed" in m:
    return "exclusive '&mut' and shared '&' cannot overlap on the same variable"
  if "expected expression" in m:
    return "the previous statement may be incomplete (missing value or ';')"
  if "expected type" in m:
    return "write a type name such as 'int', 'String', or 'Array<int>'"
  if "expected field name" in m:
    return "after '.' use an identifier or a tuple index (.0, .1, ...)"
  if "does not implement trait" in m:
    return "add an 'extend Type for Trait { ... }' block, or pick another type"
  if "duplicate symbol" in m:
    return "rename one of the definitions or remove the duplicate"
  if "unterminated" in m:
    return "check for a missing closing quote, backtick, or comment delimiter"
  return ""

proc printDiagnostic*(severity: string, message: string, loc: SourceLocation,
                      useColor: bool, fallbackFile: string = "") =
  ## Print a Rust-style diagnostic:
  ##   error: message
  ##     --> file:line:col
  ##      |
  ##   42 | source line
  ##      |        ^
  ##      = help: hint
  let isError = severity == "error"
  if useColor:
    stdout.setForegroundColor(if isError: fgRed else: fgYellow)
    stdout.write(severity & ": ")
    stdout.resetAttributes()
    stdout.writeLine(message)
  else:
    let stream = if isError: stderr else: stdout
    stream.writeLine(severity & ": " & message)

  let file = if loc.file.len > 0: loc.file else: fallbackFile
  if loc.line > 0:
    let locStr = if file.len > 0:
      &"{file}:{loc.line}:{loc.column}"
    else:
      &"{loc.line}:{loc.column}"
    if useColor:
      stdout.setForegroundColor(fgCyan)
      stdout.write("  --> ")
      stdout.resetAttributes()
      stdout.writeLine(locStr)
    else:
      stderr.writeLine("  --> " & locStr)

    let lineText = getSourceLine(file, loc.line)
    if lineText.len > 0:
      let gutter = $loc.line
      let pad = " ".repeat(max(gutter.len, 3))
      # If message names a quoted token, prefer caret at that token's start
      var col = loc.column
      let quoted = extractQuotedName(message)
      if quoted.len > 0:
        let idx = lineText.find(quoted)
        if idx >= 0:
          col = uint32(idx + 1)
      let ulen = underlineLength(lineText, col, message)
      stdout.writeLine(pad & " |")
      stdout.writeLine(" " & gutter & " | " & lineText)
      # Multi-char underline under the token (1-based column)
      var caretPad = ""
      if col > 0:
        caretPad = " ".repeat(int(col) - 1)
      let marks = "^".repeat(max(1, ulen))
      stdout.writeLine(pad & " | " & caretPad & marks)

  let hint = hintForMessage(message)
  if hint.len > 0:
    if useColor:
      stdout.setForegroundColor(fgGreen)
      stdout.write("   = help: ")
      stdout.resetAttributes()
      stdout.writeLine(hint)
    else:
      stdout.writeLine("   = help: " & hint)

proc printLexerDiags(diags: seq[LexerDiagnostic], useColor: bool, fallbackFile = "") =
  for d in diags:
    let sev = if d.severity == ldsError: "error" else: "warning"
    printDiagnostic(sev, d.message, d.loc, useColor, fallbackFile)

proc printParserDiags(diags: seq[ParserDiagnostic], useColor: bool, fallbackFile = "") =
  for d in diags:
    let sev = if d.severity == pdsError: "error" else: "warning"
    printDiagnostic(sev, d.message, d.loc, useColor, fallbackFile)

proc printSemaDiags(diags: seq[SemaDiagnostic], useColor: bool, fallbackFile = "") =
  for d in diags:
    let sev = if d.severity == sdsError: "error" else: "warning"
    printDiagnostic(sev, d.message, d.loc, useColor, fallbackFile)

# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------

proc cmdNew*(args: seq[string], opts: GlobalOptions): int =
  let useColor = shouldUseColor(opts)
  if args.len < 1:
    printError("'new' requires a package name", useColor)
    return 1
  let name = args[0]
  let root = getCurrentDir() / name
  if dirExists(root):
    printError(&"directory '{name}' already exists", useColor)
    return 1
  createDir(root / "src")
  writeFile(root / "bux.toml", &"""[Package]
Name    = "{name}"
Version = "0.1.0"
Type    = "bin"

[Build]
Output = "Bin"
""")
  writeFile(root / "src" / "Main.bux", """import Std::Io::PrintLine;

func Main() -> int {
    PrintLine(c8"Hello, Bux!");
    return 0;
}
""")
  if not opts.quiet:
    printInfo(&"Created Bux package '{name}'", useColor)
  return 0

proc cmdInit*(args: seq[string], opts: GlobalOptions): int =
  let useColor = shouldUseColor(opts)
  let root = getCurrentDir()
  if fileExists(root / "bux.toml"):
    printError("bux.toml already exists", useColor)
    return 1
  let name = splitPath(root).tail
  writeFile(root / "bux.toml", &"""[Package]
Name    = "{name}"
Version = "0.1.0"
Type    = "bin"

[Build]
Output = "Bin"
""")
  if not dirExists(root / "src"):
    createDir(root / "src")
  if not opts.quiet:
    printInfo(&"Initialized Bux package '{name}'", useColor)
  return 0

proc cmdAdd*(args: seq[string], opts: GlobalOptions): int =
  let useColor = shouldUseColor(opts)
  let root = getCurrentDir()
  let manifestPath = root / "bux.toml"
  if not fileExists(manifestPath):
    printError("no bux.toml found", useColor)
    return 1
  if args.len == 0:
    printError("usage: bux add <name> [version] [--path <path>] [--git <url>]", useColor)
    return 1
  let depName = args[0]
  var version = "*"
  var path = ""
  var gitUrl = ""
  var i = 1
  while i < args.len:
    case args[i]
    of "--path":
      if i + 1 < args.len:
        path = args[i + 1]
        inc i
      else:
        printError("--path requires a value", useColor)
        return 1
    of "--git":
      if i + 1 < args.len:
        gitUrl = args[i + 1]
        inc i
      else:
        printError("--git requires a value", useColor)
        return 1
    else:
      if not args[i].startsWith("-"):
        version = args[i]
      else:
        printError(&"unknown add option '{args[i]}'", useColor)
        return 1
    inc i
  # Append to bux.toml
  var depLine = ""
  if path.len > 0:
    depLine = &"{depName} = {{ Path = \"{path}\" }}"
  elif gitUrl.len > 0:
    depLine = &"{depName} = {{ Version = \"{version}\", Source = \"{gitUrl}\" }}"
  else:
    # Registry resolve (E.1 + HTTP URL)
    let reg = loadRegistry()
    if reg.path.len == 0:
      if reg.sourceUrl.len > 0:
        printError(&"failed to fetch registry from {reg.sourceUrl}", useColor)
        printError("hint: check network, curl/wget, or set BUX_REGISTRY to a local file", useColor)
      else:
        printError("no package registry found (set BUX_REGISTRY or install config/registry.toml)", useColor)
      return 1
    let pkg = registryLookup(reg, depName, version)
    if pkg.name.len == 0:
      printError(&"package '{depName}' not found in registry ({reg.path})", useColor)
      printError("hint: bux search  |  bux add name --git <url>  |  bux add name --path <dir>", useColor)
      return 1
    depLine = formatRegistryDepLine(depName, pkg)
    if not opts.quiet:
      let src = if reg.sourceUrl.len > 0: reg.sourceUrl else: reg.path
      printInfo(&"Resolved '{depName}' {pkg.version} from registry {src}", useColor)
  var content = readFile(manifestPath)
  # Ensure [Dependencies] section exists
  if content.find("[Dependencies]") < 0:
    content.add("\n[Dependencies]\n")
  # Append dependency line
  content.add(depLine & "\n")
  writeFile(manifestPath, content)
  if not opts.quiet:
    printInfo(&"Added dependency '{depName}' to bux.toml", useColor)
  return 0

proc cmdSearch*(args: seq[string], opts: GlobalOptions): int =
  let useColor = shouldUseColor(opts)
  let query = if args.len > 0: args[0] else: ""
  let reg = loadRegistry()
  if reg.path.len == 0:
    if reg.sourceUrl.len > 0:
      printError(&"failed to fetch registry from {reg.sourceUrl}", useColor)
      printError("hint: need curl or wget; or set BUX_REGISTRY to a local file", useColor)
    else:
      printError("no package registry found (set BUX_REGISTRY path or http(s) URL)", useColor)
    return 1
  if not opts.quiet:
    if reg.sourceUrl.len > 0:
      echo &"Registry: {reg.sourceUrl}"
      echo &"  (cached: {reg.path})"
    else:
      echo &"Registry: {reg.path}"
  let hits = registrySearch(reg, query)
  if hits.len == 0:
    if not opts.quiet:
      echo "No packages matched."
    return 1
  # Dedupe by name showing latest version
  var seen = initTable[string, RegistryPackage]()
  for p in hits:
    seen[p.name.toLowerAscii()] = p
  var names: seq[string] = @[]
  for k in seen.keys:
    names.add(k)
  names.sort(system.cmp)
  for k in names:
    let p = seen[k]
    let desc = if p.description.len > 0: p.description else: p.source
    echo &"  {p.name}  {p.version}  — {desc}"
  return 0

proc cmdInstall*(args: seq[string], opts: GlobalOptions): int =
  let useColor = shouldUseColor(opts)
  let root = getCurrentDir()
  let manifestPath = root / "bux.toml"
  if not fileExists(manifestPath):
    printError("no bux.toml found", useColor)
    return 1
  let man = loadManifest(manifestPath)
  var lock = Lockfile(entries: @[])
  let cacheDir = getHomeDir() / ".bux" / "packages"
  if not dirExists(cacheDir):
    createDir(cacheDir)
  let reg = loadRegistry()
  # Resolve each dependency
  for dep in man.dependencies:
    case dep.kind
    of dkPath:
      let absPath = if dep.path.isAbsolute: dep.path else: root / dep.path
      if not dirExists(absPath):
        printError(&"path dependency not found: {absPath}", useColor)
        return 1
      # Read dependency manifest
      let depManifestPath = absPath / "bux.toml"
      if fileExists(depManifestPath):
        let depMan = loadManifest(depManifestPath)
        lock.entries.add(LockEntry(name: dep.name, version: depMan.version, source: absPath))
      else:
        lock.entries.add(LockEntry(name: dep.name, version: "0.0.0", source: absPath))
      if not opts.quiet:
        printInfo(&"Resolved path dependency '{dep.name}' from {absPath}", useColor)
    of dkGit:
      let depDir = cacheDir / dep.name
      if not dirExists(depDir):
        if not opts.quiet:
          printInfo(&"Cloning '{dep.name}' from {dep.gitUrl}...", useColor)
        let (outp, code) = execCmdEx(&"git clone --quiet {quoteShell(dep.gitUrl)} {quoteShell(depDir)} 2>&1")
        if code != 0:
          printError(&"failed to clone {dep.gitUrl}: {outp}", useColor)
          return 1
      else:
        if not opts.quiet:
          printInfo(&"Using cached '{dep.name}' from {depDir}", useColor)
      # Lock stores git URL; build loads from cache by name
      lock.entries.add(LockEntry(name: dep.name, version: dep.gitVersion, source: dep.gitUrl))
    of dkVersion:
      # Registry lookup (E.1)
      if reg.path.len == 0:
        printError(&"cannot resolve '{dep.name}': no package registry (set BUX_REGISTRY)", useColor)
        return 1
      let pkg = registryLookup(reg, dep.name, dep.versionReq)
      if pkg.name.len == 0:
        printError(&"package '{dep.name}' not found in registry", useColor)
        return 1
      if pkg.resolvedPath.len > 0 and dirExists(pkg.resolvedPath):
        lock.entries.add(LockEntry(name: dep.name, version: pkg.version, source: pkg.resolvedPath))
        if not opts.quiet:
          printInfo(&"Resolved '{dep.name}' {pkg.version} → {pkg.resolvedPath}", useColor)
      elif isGitSource(pkg.source):
        let depDir = cacheDir / dep.name
        if not dirExists(depDir):
          if not opts.quiet:
            printInfo(&"Cloning '{dep.name}' from {pkg.source}...", useColor)
          let (outp, code) = execCmdEx(&"git clone --quiet {quoteShell(pkg.source)} {quoteShell(depDir)} 2>&1")
          if code != 0:
            printError(&"failed to clone {pkg.source}: {outp}", useColor)
            return 1
        lock.entries.add(LockEntry(name: dep.name, version: pkg.version, source: pkg.source))
        if not opts.quiet:
          printInfo(&"Resolved '{dep.name}' {pkg.version} → git {pkg.source}", useColor)
      else:
        printError(&"registry entry '{dep.name}' has unusable source '{pkg.source}'", useColor)
        return 1
  # Save lockfile
  let lockPath = root / "bux.lock"
  saveLockfile(lockPath, lock)
  if not opts.quiet:
    printInfo(&"Generated {lockPath}", useColor)
  return 0

proc collectStdlibDecls(stdlibDir: string): seq[Decl]
proc getDeclName(d: Decl): string
proc mergeDecls(stdlibDecls: seq[Decl], userDecls: seq[Decl]): seq[Decl]
proc collectDepDecls(lock: Lockfile, root: string, opts: GlobalOptions): seq[Decl]

proc findStdlibDir(root: string): string =
  let searchPaths = @[
    getAppDir() / ".." / "lib",
    getAppDir() / "lib",
    root / "lib",
  ]
  for path in searchPaths:
    if dirExists(path):
      return path
  return ""

type
  ProjectContext = object
    root: string
    man: Manifest
    stdlibDir: string
    stdlibDecls: seq[Decl]
    depDecls: seq[Decl]
    allModuleItems: seq[Decl]
    hasMain: bool

proc prepareProject(root: string, useColor: bool, opts: GlobalOptions): (ProjectContext, int) =
  var pctx: ProjectContext
  pctx.root = root
  let manifestPath = root / "bux.toml"
  if not fileExists(manifestPath):
    printError("no bux.toml found", useColor)
    return (pctx, 1)
  pctx.man = loadManifest(manifestPath)
  let srcDir = root / "src"
  if not dirExists(srcDir):
    printError("no src/ directory found", useColor)
    return (pctx, 1)

  pctx.stdlibDir = findStdlibDir(root)
  pctx.stdlibDecls = collectStdlibDecls(pctx.stdlibDir)
  let lock = loadLockfile(root / "bux.lock")
  pctx.depDecls = collectDepDecls(lock, root, opts)

  pctx.allModuleItems = @[]
  pctx.hasMain = false
  for kind, path in walkDir(srcDir):
    if kind == pcFile and path.endsWith(".bux"):
      let source = readFile(path)
      let lexRes = tokenize(source, path)
      if lexRes.hasErrors:
        printError(&"lex errors in {path}", useColor)
        printLexerDiags(lexRes.diagnostics, useColor, path)
        return (pctx, 1)
      let parseRes = parse(lexRes.tokens, path)
      if parseRes.diagnostics.len > 0:
        printError(&"parse errors in {path}", useColor)
        printParserDiags(parseRes.diagnostics, useColor, path)
        return (pctx, 1)
      for decl in parseRes.module.items:
        if decl.kind == dkModule:
          for sub in decl.declModuleItems:
            pctx.allModuleItems.add(sub)
        else:
          pctx.allModuleItems.add(decl)
      if splitFile(path).name == "Main":
        pctx.hasMain = true

  if not pctx.hasMain:
    printError("no Main.bux found in src/", useColor)
    return (pctx, 1)

  return (pctx, 0)

proc mergeProject(pctx: ProjectContext): Module =
  let stdlibAndDeps = mergeDecls(pctx.stdlibDecls, pctx.depDecls)
  let mergedItems = mergeDecls(stdlibAndDeps, pctx.allModuleItems)
  var unifiedModule = newModule("main")
  unifiedModule.items = mergedItems
  return unifiedModule

proc cmdCheck*(args: seq[string], opts: GlobalOptions): int =
  let useColor = shouldUseColor(opts)
  let root = if args.len > 0: absolutePath(args[0]) else: getCurrentDir()
  let (pctx, status) = prepareProject(root, useColor, opts)
  if status != 0:
    return status
  let unifiedModule = mergeProject(pctx)
  let semaRes = analyze(unifiedModule)
  if semaRes.hasErrors:
    printError("type errors in project", useColor)
    printSemaDiags(semaRes.diagnostics, useColor)
    return 1
  if not opts.quiet:
    printInfo("check passed", useColor)
  return 0

proc collectStdlibDecls(stdlibDir: string): seq[Decl] =
  result = @[]
  if not dirExists(stdlibDir): return
  for path in walkDirRec(stdlibDir):
    if path.endsWith(".bux"):
      let source = readFile(path)
      let lexRes = tokenize(source, path)
      if lexRes.hasErrors: continue
      let parseRes = parse(lexRes.tokens, path)
      if parseRes.diagnostics.len > 0: continue
      for item in parseRes.module.items:
        if item.kind == dkModule:
          for sub in item.declModuleItems:
            result.add(sub)
        else:
          result.add(item)

proc getDeclName(d: Decl): string =
  case d.kind
  of dkFunc: d.declFuncName
  of dkExternFunc: d.declExtFuncName
  of dkStruct: d.declStructName
  of dkEnum: d.declEnumName
  of dkUnion: d.declUnionName
  of dkInterface: d.declInterfaceName
  of dkConst: d.declConstName
  of dkTypeAlias: d.declAliasName
  else: ""

proc collectDepDecls(lock: Lockfile, root: string, opts: GlobalOptions): seq[Decl] =
  ## Collect declarations from all locked dependencies.
  let cacheDir = getHomeDir() / ".bux" / "packages"
  let useColor = shouldUseColor(opts)
  for entry in lock.entries:
    var depSrcDir = ""
    if dirExists(entry.source):
      # Path-based dependency
      depSrcDir = entry.source / "src"
    elif entry.source.startsWith("http") or entry.source.startsWith("git@"):
      # Git-based dependency in cache
      depSrcDir = cacheDir / entry.name / "src"
    if depSrcDir == "" or not dirExists(depSrcDir):
      continue
    for kind, path in walkDir(depSrcDir):
      if kind == pcFile and path.endsWith(".bux"):
        let source = readFile(path)
        let lexRes = tokenize(source, path)
        if lexRes.hasErrors:
          continue
        let parseRes = parse(lexRes.tokens, path)
        if parseRes.diagnostics.len > 0:
          continue
        for decl in parseRes.module.items:
          if decl.kind == dkModule:
            for sub in decl.declModuleItems:
              result.add(sub)
          else:
            result.add(decl)
    if not opts.quiet:
      printInfo(&"Loaded dependency '{entry.name}' from {depSrcDir}", useColor)

proc mergeDecls(stdlibDecls: seq[Decl], userDecls: seq[Decl]): seq[Decl] =
  ## Merge stdlib and user declarations.
  ## User funcs shadow stdlib funcs with the same name (simple overload avoidance).
  var userNames: HashSet[string]
  for d in userDecls:
    let name = getDeclName(d)
    if name != "":
      userNames.incl(name)
  result = @[]
  for d in stdlibDecls:
    let name = getDeclName(d)
    if name == "" or name notin userNames:
      result.add(d)
  for d in userDecls:
    result.add(d)

proc cmdBuild*(args: seq[string], opts: GlobalOptions): int =
  var opts = opts
  var pathArgs: seq[string] = @[]
  for a in args:
    if a == "--release":
      opts.release = true
    elif a.startsWith("-"):
      # ignore unknown flags for forward-compat; keep path-like later
      discard
    else:
      pathArgs.add(a)
  let useColor = shouldUseColor(opts)
  let root = if pathArgs.len > 0: absolutePath(pathArgs[0]) else: getCurrentDir()
  let (pctx, status) = prepareProject(root, useColor, opts)
  if status != 0:
    return status

  # Create build directory
  let buildDir = root / "build"
  if not dirExists(buildDir):
    createDir(buildDir)

  let unifiedModule = mergeProject(pctx)

  # Phase 3: Sema + HIR + C codegen on unified module
  let (semaRes, semaCtx) = analyzeFull(unifiedModule)
  if semaRes.hasErrors:
    printError("type errors in project", useColor)
    printSemaDiags(semaRes.diagnostics, useColor)
    return 1
  
  let hirMod = lowerModule(unifiedModule, semaCtx)
  let lirBuilder = lowerModuleToLir(hirMod)
  # Debug builds: #line maps into .bux for gdb. Release: skip maps + -O2.
  var lirCbe = initLirCBackend(emitDebugLines = not opts.release)
  var allCCode = lirCbe.emitModule(lirBuilder, hirMod)

  # Write C file
  let cFile = buildDir / "main.c"
  writeFile(cFile, allCCode)

  # Copy runtime files (rt/ is sibling of lib/)
  let stdlibDir = pctx.stdlibDir
  let runtimeDst = buildDir / "runtime.c"
  let ioDst = buildDir / "io.c"
  if stdlibDir == "":
    printError("stdlib directory not found", useColor)
    return 1

  let baseDir = stdlibDir.parentDir()
  let runtimeSrc = baseDir / "rt" / "runtime.c"
  if fileExists(runtimeSrc):
    copyFile(runtimeSrc, runtimeDst)
  else:
    printError("runtime.c not found in rt/", useColor)
    return 1

  let ioSrc = baseDir / "rt" / "io.c"
  if fileExists(ioSrc):
    copyFile(ioSrc, ioDst)
  else:
    printError("io.c not found in rt/", useColor)
    return 1

  # Compile with cc — debug default (-O0 -g) or --release (-O2)
  let outputName = if pctx.man.name != "": pctx.man.name else: "bux_out"
  let outputFile = buildDir / outputName
  let optFlags = if opts.release: "-O2 -DNDEBUG" else: "-O0 -g"
  let extraCflags = getEnv("BUX_CFLAGS")
  let cflags = if extraCflags.len > 0: optFlags & " " & extraCflags else: optFlags
  let ccCmd = &"cc {cflags} -pthread -Wl,--build-id=none -o {outputFile} {cFile} {runtimeDst} {ioDst} -lm -lcrypto 2>&1"
  if opts.verbose:
    printInfo(&"running: {ccCmd}", useColor)
  let (output, exitCode) = execCmdEx(ccCmd)
  if exitCode != 0:
    printError("C compilation failed:", useColor)
    echo output
    return 1

  if not opts.quiet:
    printInfo(&"build: {outputFile}", useColor)
  return 0

proc cmdRun*(args: seq[string], opts: GlobalOptions): int =
  let useColor = shouldUseColor(opts)
  let root = if args.len > 0: absolutePath(args[0]) else: getCurrentDir()
  let buildRes = cmdBuild(args, opts)
  if buildRes != 0:
    return buildRes
  let man = loadManifest(root / "bux.toml")
  let outputName = if man.name != "": man.name else: "bux_out"
  let outputFile = root / "build" / outputName
  if not fileExists(outputFile):
    printError("executable not found after build", useColor)
    return 1
  let exitCode = execCmd(outputFile)
  return exitCode

proc cmdClean*(args: seq[string], opts: GlobalOptions): int =
  let useColor = shouldUseColor(opts)
  let root = getCurrentDir()
  let buildDir = root / "build"
  if dirExists(buildDir):
    removeDir(buildDir)
  if not opts.quiet:
    printInfo("clean: build directory removed", useColor)
  return 0

proc parseTestArgs(args: seq[string]): tuple[filter: string, paths: seq[string], ok: bool] =
  ## Parse `test` args: optional `--filter <s>` / `--filter=<s>`, rest are ignored paths.
  result.filter = ""
  result.paths = @[]
  result.ok = true
  var i = 0
  while i < args.len:
    let a = args[i]
    if a == "--filter":
      if i + 1 >= args.len:
        stderr.writeLine("error: --filter requires an argument")
        result.ok = false
        return
      inc i
      result.filter = args[i]
    elif a.startsWith("--filter="):
      result.filter = a["--filter=".len .. ^1]
    elif a == "--help" or a == "-h":
      echo "Usage: bux test [--filter <name>] [project-dir]"
      echo "  --filter <name>   Only run tests whose filename contains <name>"
      result.ok = false  # treat as early exit without error in caller? use special
      # Signal help via empty filter and a sentinel path
      result.paths = @["__help__"]
      return
    elif a.startsWith("-"):
      stderr.writeLine(&"error: unknown test option '{a}'")
      result.ok = false
      return
    else:
      result.paths.add(a)
    inc i

proc parseFmtArgs(args: seq[string]): tuple[checkOnly: bool, paths: seq[string], ok: bool, help: bool] =
  result.checkOnly = false
  result.paths = @[]
  result.ok = true
  result.help = false
  var i = 0
  while i < args.len:
    let a = args[i]
    if a == "--check":
      result.checkOnly = true
    elif a == "--help" or a == "-h":
      result.help = true
      return
    elif a.startsWith("-"):
      stderr.writeLine(&"error: unknown fmt option '{a}'")
      result.ok = false
      return
    else:
      result.paths.add(a)
    inc i

proc cmdTest*(args: seq[string], opts: GlobalOptions): int =
  let useColor = shouldUseColor(opts)
  let (filter, paths, ok) = parseTestArgs(args)
  if not ok:
    if paths.len == 1 and paths[0] == "__help__":
      return 0
    return 1
  let root = if paths.len > 0: absolutePath(paths[0]) else: getCurrentDir()
  let testsDir = root / "tests"
  var testFiles: seq[string] = @[]
  if dirExists(testsDir):
    for kind, path in walkDir(testsDir):
      if kind == pcFile and path.endsWith(".bux"):
        let testName = splitFile(path).name
        if filter.len > 0 and filter notin testName:
          continue
        testFiles.add(path)
  testFiles.sort(system.cmp)
  if testFiles.len == 0:
    if filter.len > 0:
      printError(&"no tests matching filter '{filter}' in tests/", useColor)
    else:
      printError("no tests found in tests/ directory", useColor)
    return 1

  if not opts.quiet:
    if filter.len > 0:
      echo &"Running tests (filter: {filter}) in {testsDir}"
    else:
      echo &"Running tests in {testsDir}"
    echo "┌──────────────────────────────┬────────┐"
    echo "│ Test                         │ Status │"
    echo "├──────────────────────────────┼────────┤"

  var passed = 0
  var failed = 0
  for testFile in testFiles:
    let testName = splitFile(testFile).name
    let tmpDir = getTempDir() / "bux_test_" & testName
    removeDir(tmpDir)
    createDir(tmpDir / "src")
    copyFile(testFile, tmpDir / "src" / "Main.bux")
    writeFile(tmpDir / "bux.toml",
      "[Package]\nName    = \"" & testName & "\"\nVersion = \"0.1.0\"\nType    = \"bin\"\n\n[Build]\nOutput = \"Bin\"\n")
    let buildRes = cmdBuild(@[tmpDir], opts)
    var status: string
    var statusOk = false
    if buildRes != 0:
      status = "FAIL"
      failed += 1
    else:
      var execFile = tmpDir / "build" / testName
      if not fileExists(execFile):
        execFile = tmpDir / "build" / "bux_out"
      let exitCode = execCmd(execFile)
      if exitCode == 0:
        status = "PASS"
        statusOk = true
        passed += 1
      else:
        status = &"FAIL:{exitCode}"
        failed += 1
    removeDir(tmpDir)

    if not opts.quiet:
      # Pad name to 28 chars for the table column
      var nameCol = testName
      if nameCol.len > 28:
        nameCol = nameCol[0 .. 24] & "..."
      else:
        nameCol = nameCol & repeat(' ', 28 - nameCol.len)
      var stCol = status
      if stCol.len < 6:
        stCol = stCol & repeat(' ', 6 - stCol.len)
      if useColor:
        if statusOk:
          stdout.setForegroundColor(fgGreen)
        else:
          stdout.setForegroundColor(fgRed)
        stdout.writeLine(&"│ {nameCol} │ {stCol} │")
        stdout.resetAttributes()
      else:
        echo &"│ {nameCol} │ {stCol} │"

  if not opts.quiet:
    echo "└──────────────────────────────┴────────┘"
    echo &"\nResults: {passed} passed, {failed} failed, {testFiles.len} total"
  # CI-friendly exit codes: 0 = all pass, 1 = some failed
  return if failed > 0: 1 else: 0

proc cmdFmt*(args: seq[string], opts: GlobalOptions): int =
  let useColor = shouldUseColor(opts)
  let (checkOnly, paths, ok, help) = parseFmtArgs(args)
  if not ok:
    return 1
  if help:
    echo "Usage: bux fmt [--check] [path...]"
    echo "  --check   Do not write; exit 1 if any file would be reformatted"
    echo "  path      File or directory (default: .)"
    return 0

  let targets = if paths.len > 0: paths else: @["."]
  var files: seq[string] = @[]
  for t in targets:
    let collected = collectBuxFiles(t)
    for f in collected:
      if f notin files:
        files.add(f)
  files.sort(system.cmp)

  if files.len == 0:
    printError("no .bux files found", useColor)
    return 1

  var changed = 0
  var failed = 0
  var unchanged = 0
  for path in files:
    let (okf, didChange, msg) = formatFile(path, checkOnly)
    if not okf:
      printError(&"{path}: {msg}", useColor)
      failed += 1
      continue
    if didChange:
      changed += 1
      if not opts.quiet:
        if checkOnly:
          printError(&"  would reformat {path}", useColor)
        else:
          printInfo(&"  formatted {path}", useColor)
    else:
      unchanged += 1
      if opts.verbose and not opts.quiet:
        echo &"  ok {path}"

  if not opts.quiet:
    if checkOnly:
      echo &"\nfmt --check: {changed} would reformat, {unchanged} ok, {failed} errors"
    else:
      echo &"\nFormatted {changed}/{files.len} files ({unchanged} already clean)"

  if failed > 0:
    return 1
  if checkOnly and changed > 0:
    return 1
  return 0

proc cmdDoc*(args: seq[string], opts: GlobalOptions): int =
  ## Generate Markdown docs from `///` / adjacent `/* */` comments.
  var outPath = ""
  var paths: seq[string] = @[]
  var i = 0
  while i < args.len:
    let a = args[i]
    if a == "--out" or a == "-o":
      if i + 1 >= args.len:
        stderr.writeLine("error: --out requires a path")
        return 1
      inc i
      outPath = args[i]
    elif a.startsWith("--out="):
      outPath = a["--out=".len .. ^1]
    elif a == "--help" or a == "-h":
      echo "Usage: bux doc [--out file.md] [path...]"
      echo "  Scans .bux files for /// and /* */ docs preceding declarations."
      echo "  Default path: lib/ (stdlib) when omitted."
      return 0
    elif a.startsWith("-"):
      stderr.writeLine(&"error: unknown doc option '{a}'")
      return 1
    else:
      paths.add(a)
    inc i

  if paths.len == 0:
    # Prefer stdlib if present
    if dirExists("lib"):
      paths = @["lib"]
    else:
      paths = @["."]

  let items = generateDocs(paths)
  let title =
    if paths.len == 1 and paths[0] == "lib": "Bux Standard Library"
    else: "API Reference"
  let md = renderMarkdown(items, title)

  if outPath.len > 0:
    try:
      let parent = parentDir(outPath)
      if parent.len > 0 and not dirExists(parent):
        createDir(parent)
      writeFile(outPath, md)
      if not opts.quiet:
        echo &"Wrote {items.len} documented items → {outPath}"
    except CatchableError as e:
      stderr.writeLine("error: " & e.msg)
      return 1
  else:
    stdout.write(md)
  if items.len == 0 and not opts.quiet:
    stderr.writeLine("warning: no /// or /* */ documented declarations found")
  return 0

proc cmdVersion*(args: seq[string], opts: GlobalOptions): int =
  echo "bux 0.1.0 (bootstrap)"
  return 0

proc runCli*(args: seq[string]): int =
  let (opts, rest, ok) = parseGlobalOptions(args)
  if not ok:
    return 1
  if rest.len == 0:
    printUsage()
    return 0

  let cmd = rest[0]
  let cmdArgs = if rest.len > 1: rest[1..^1] else: @[]

  case cmd
  of "new": return cmdNew(cmdArgs, opts)
  of "init": return cmdInit(cmdArgs, opts)
  of "add": return cmdAdd(cmdArgs, opts)
  of "install": return cmdInstall(cmdArgs, opts)
  of "search": return cmdSearch(cmdArgs, opts)
  of "build": return cmdBuild(cmdArgs, opts)
  of "run": return cmdRun(cmdArgs, opts)
  of "check": return cmdCheck(cmdArgs, opts)
  of "test": return cmdTest(cmdArgs, opts)
  of "fmt": return cmdFmt(cmdArgs, opts)
  of "doc": return cmdDoc(cmdArgs, opts)
  of "clean": return cmdClean(cmdArgs, opts)
  of "version", "--version", "-v": return cmdVersion(cmdArgs, opts)
  of "help", "--help", "-h":
    printUsage()
    return 0
  else:
    let useColor = shouldUseColor(opts)
    printError(&"unknown command '{cmd}'", useColor)
    return 1
