#!/usr/bin/env bun

import { readdir, readFile, writeFile } from "node:fs/promises"
import { resolve, relative, dirname } from "node:path"
import { program } from "commander"
import ansis from "ansis"

import { parse, parseSetup, type Command, type ShoutFile } from "../parse.ts"
import { runFile, cleanupTmpDir, type CommandResult } from "../run.ts"
import { evaluateFile, formatFailure, formatSummary } from "../format.ts"
import type { TestResult } from "../format.ts"
import { matchOutput } from "../match.ts"
import { parseDuration } from "../duration.ts"
import { rewriteFile } from "../update.ts"

async function filterGitignored(files: string[]): Promise<string[]> {
  if (files.length === 0) return files
  try {
    const proc = Bun.spawn(["git", "check-ignore", "--stdin"], {
      stdin: new Blob([files.join("\n")]),
      stdout: "pipe",
      stderr: "ignore",
    })
    const output = await new Response(proc.stdout).text()
    await proc.exited
    const ignored = new Set(output.trim().split("\n").filter(Boolean))
    return files.filter(f => !ignored.has(f))
  } catch {
    return files
  }
}

async function findShoutFiles(paths: string[]): Promise<string[]> {
  const explicit: string[] = []
  const discovered: string[] = []

  for (const p of paths) {
    const abs = resolve(p)
    const stat = await Bun.file(abs).exists()
      ? Bun.file(abs)
      : null

    if (stat && abs.endsWith(".shout")) {
      explicit.push(abs)
      continue
    }

    // Try as directory
    try {
      const entries = await readdir(abs, { recursive: true })
      for (const entry of entries) {
        if (entry.endsWith(".shout")) {
          discovered.push(resolve(abs, entry))
        }
      }
    } catch {
      // If not a directory, try as file anyway
      if (abs.endsWith(".shout")) explicit.push(abs)
    }
  }

  const filtered = await filterGitignored(discovered)
  return [...explicit, ...filtered].sort()
}

import pkg from "../../package.json"

program
  .name("shout")
  .description("$ shell output tester")
  .version(pkg.version)

program
  .command("test")
  .description("Run .shout test files")
  .argument("[files...]", "Files or directories to test")
  .option("-u, --update", "Rewrite expected output in-place with actual output")
  .option("-k, --keep", "Keep temp directories after run")
  .option("--clean-env", "Start with empty environment")
  .option("--path <path>", "Prepend <path> to PATH (repeatable)", (val: string, acc: string[]) => [...acc, val], [])
  .option("--timeout <dur>", "Per-command timeout", "10s")
  .option("-v, --verbose", "Print each command as it runs")
  .option("--port-from <n>", "Auto-assign $PORT starting from <n>", "5400")
  .option("-t, --filter <pattern>", "Only run files matching <pattern> (substring match)")
  .option("--parallel", "Run files in parallel")
  .action(async (fileArgs: string[], opts) => {
    const timeoutMs = parseDuration(opts.timeout)
    const paths = fileArgs.length > 0 ? fileArgs : ["."]
    let files = await findShoutFiles(paths)

    const start = performance.now()
    const results: TestResult[] = []
    const cwd = process.cwd()

    if (opts.filter) {
      const pattern = opts.filter
      files = files.filter(f => relative(cwd, f).includes(pattern))
    }

    if (files.length === 0) {
      console.error(opts.filter ? `No .shout files matching "${opts.filter}"` : "No .shout files found")
      process.exit(1)
    }
    const portFrom = parseInt(opts.portFrom, 10)
    if (Number.isNaN(portFrom)) {
      console.error("--port-from must be an integer")
      process.exit(1)
    }
    let nextPort = portFrom

    const runOne = async (filePath: string, port: number) => {
      const content = await readFile(filePath, "utf-8")
      const parsed = parse(relative(cwd, filePath), content)

      // Resolve directives in a single pass. Setup @env is collected separately
      // so that the user file's @env always takes precedence.
      const envVars: Record<string, string> = {}
      const setupEnvVars: Record<string, string> = {}
      const userEnvVars: Record<string, string> = {}
      const setupCommands: Command[] = []
      const teardownCommands: Command[] = [...parsed.teardownCommands]
      for (const d of parsed.directives) {
        if (d.type === "setup") {
          const setupPath = resolve(dirname(filePath), d.path)
          const setupContent = await readFile(setupPath, "utf-8")
          const setupParsed = parseSetup(relative(cwd, setupPath), setupContent)
          for (const sd of setupParsed.directives) {
            if (sd.type === "env") setupEnvVars[sd.key] = sd.value
          }
          setupCommands.push(...setupParsed.commands)
          teardownCommands.push(...setupParsed.teardownCommands)
        } else if (d.type === "env") {
          userEnvVars[d.key] = d.value
        }
      }
      Object.assign(envVars, setupEnvVars, userEnvVars)
      if (!("PORT" in userEnvVars) && !("PORT" in setupEnvVars)) {
        envVars["PORT"] = String(port)
      }
      const merged: ShoutFile = { ...parsed, commands: [...setupCommands, ...parsed.commands, ...teardownCommands] }

      const setupLen = setupCommands.length
      const userLen = parsed.commands.length
      const printDot = (result: CommandResult) => {
        const { command, actual, exitCode } = result
        const outputMatches = matchOutput(command.expected, actual)
        let exitCodeMismatch = false
        if (command.exitCode === null) {
          exitCodeMismatch = exitCode !== 0
        } else if (command.exitCode === "*") {
          exitCodeMismatch = exitCode === 0
        } else {
          exitCodeMismatch = exitCode !== command.exitCode
        }
        if (outputMatches && !exitCodeMismatch) {
          process.stdout.write(ansis.green("."))
        } else {
          process.stdout.write(ansis.red("F"))
        }
      }

      const fileResult = await runFile(merged, {
        cleanEnv: opts.cleanEnv ?? false,
        pathDirs: opts.path,
        envVars,
        sourceDir: resolve(dirname(filePath)),
        projectDir: cwd,
        timeout: timeoutMs,
        onCommand: opts.verbose
          ? (cmd) => process.stderr.write(ansis.dim(`  $ ${cmd.command}\n`))
          : undefined,
        onCommandResult: (index, result) => {
          if (index >= setupLen && index < setupLen + userLen) {
            printDot(result)
          }
        },
      })

      // Check setup commands for failures
      for (let i = 0; i < setupCommands.length; i++) {
        const r = fileResult.results[i]
        const expected = setupCommands[i]!.exitCode
        const ok = expected === null
          ? r?.exitCode === 0
          : expected === "*"
            ? r?.exitCode !== 0
            : r?.exitCode === expected
        if (!ok) {
          if (opts.keep) {
            process.stderr.write(`${fileResult.tmpDir}\n`)
          } else {
            await cleanupTmpDir(fileResult.tmpDir)
          }
          return evaluateFile(
            parsed.path,
            [],
            `setup command failed (exit ${r?.exitCode ?? "?"}): $ ${setupCommands[i]!.command}`,
          )
        }
      }

      const fileOwnResults = fileResult.results.slice(
        setupCommands.length,
        setupCommands.length + parsed.commands.length,
      )

      // Warn on teardown failures
      const teardownResults = fileResult.results.slice(setupCommands.length + parsed.commands.length)
      for (let i = 0; i < teardownResults.length; i++) {
        const r = teardownResults[i]
        if (r && r.exitCode !== 0) {
          process.stderr.write(
            ansis.yellow(`warning: teardown command failed (exit ${r.exitCode}): $ ${teardownCommands[i]!.command}\n`),
          )
        }
      }

      const testResult = evaluateFile(
        parsed.path,
        fileOwnResults,
        fileResult.error,
      )

      if (opts.update && fileOwnResults.length > 0) {
        const updated = rewriteFile(parsed, fileOwnResults, content)
        if (updated !== content) {
          await writeFile(filePath, updated)
        }
      }

      if (opts.keep) {
        process.stderr.write(`${fileResult.tmpDir}\n`)
      } else {
        await cleanupTmpDir(fileResult.tmpDir)
      }

      return testResult
    }

    const printErrorDot = (r: TestResult) => {
      if (r.error) {
        process.stdout.write(ansis.red("F"))
      }
    }

    if (opts.parallel) {
      const promises = files.map(async f => {
        const r = await runOne(f, nextPort++)
        printErrorDot(r)
        return r
      })
      results.push(...await Promise.all(promises))
      process.stdout.write("\n")
    } else {
      for (const filePath of files) {
        const r = await runOne(filePath, nextPort++)
        printErrorDot(r)
        results.push(r)
      }
      process.stdout.write("\n")
    }

    // Print failures
    const failures = results.filter(r => !r.passed)
    if (failures.length > 0) {
      console.log()
      for (const f of failures) {
        console.log(formatFailure(f))
        console.log()
      }
    }

    const elapsed = performance.now() - start
    const singleFile = files.length === 1 ? relative(cwd, files[0]) : undefined
    console.log(formatSummary(results, elapsed, singleFile))

    process.exit(failures.length > 0 ? 1 : 0)
  })

program
  .command("version")
  .description("Print the version")
  .action(() => {
    console.log(pkg.version)
  })

program
  .command("example")
  .description("Print an example .shout file")
  .action(() => {
    console.log(`# Example .shout file
$ echo hello
hello

$ echo "one"; echo "two"; echo "three"
one
...
three

$ cat nonexistent
cat: nonexistent: ...
[1]

$ true
[0]`)
  })

program
  .command("upgrade")
  .description("Upgrade to the latest version")
  .action(async () => {
    const result = await Bun.spawn(["bun", "install", "-g", "@because/shout@latest"], {
      stdin: "inherit",
      stdout: "inherit",
      stderr: "inherit",
    }).exited
    process.exit(result)
  })

program.parse()
