// Test harness for .opencode/tools/pipeline-status.ts.
//
// Why this file: there is no bun/tsx/esbuild on the CI runner (only node +
// pytest). The pipeline-status.ts tool is a TypeScript module (Bun runtime
// for opencode, not runnable with plain `node`). To exercise the tool's
// execute() function in pytest, we strip TS-only syntax (import type
// annotations + ESM import -> CJS require) and load the resulting JS as a
// CommonJS module. The tool() factory from @opencode-ai/plugin is identity
// (returns its argument), so we can replace it with a passthrough shim
// without changing the tool semantics.
//
// This file is consumed by tests/test_pipeline_status_tool.py via:
//   node tests/_ts_loader.mjs <mode> [args...]
// where <mode> is one of: load, exec, exec_real.
//
// - load: print the {description, args keys} of the tool — sanity check.
// - exec: call execute({pr_number: <int>}) with a stubbed spawnSync that
//   returns whatever argv it received, plus a fixture stdout. Used by unit
//   tests to verify args passed and output trimming.
// - exec_real: call execute({pr_number: <int>}) with the REAL spawnSync,
//   used by the integration test against the real pipeline-status.py.

import { readFileSync } from "node:fs"
import * as nodeFs from "node:fs"
import { fileURLToPath } from "node:url"
import path from "node:path"
import { spawnSync } from "node:child_process"

const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), "..", "..")
const DEFAULT_TS_FILE = path.join(REPO_ROOT, ".opencode", "tools", "pipeline-status.ts")
// Parameterize via env var TS_FILE (relative to REPO_ROOT) so other TS tool
// wrappers (e.g. spec-status.ts) can be loaded by the same harness without
// breaking existing callers that don't set TS_FILE (default: pipeline-status.ts).
const TS_FILE = process.env.TS_FILE
  ? path.resolve(REPO_ROOT, process.env.TS_FILE)
  : DEFAULT_TS_FILE

// Minimal zod shim covering the methods the tool actually uses:
//   tool.schema.number().describe("...")
// At runtime (in opencode/Bun) `tool.schema` is the real zod. Here we
// only need a chainable builder that returns an object with .describe()
// — no validation occurs in execute().
function makeZodShim() {
  const chain = () => {
    const obj = {
      describe() { return obj },
      optional() { return obj },
      // Add more methods as future tool definitions need them.
    }
    return obj
  }
  return {
    number: chain,
    string: chain,
    boolean: chain,
    array: chain,
    object: chain,
    // enum(values) — used by post-review.ts for
    // verdict validation. Like the other methods, the shim returns a
    // chainable builder without actually validating the value (validation
    // happens at the opencode zod layer, not inside execute()).
    enum: () => chain(),
  }
}
const zodShim = makeZodShim()

function stripTs(src) {
  // Minimal TS -> JS for this specific file:
  //   1) `import { spawnSync } from "child_process"` -> `const { spawnSync } = require("child_process")`
  //   2) `import { tool } from "@opencode-ai/plugin"` -> `const tool = (x) => x`
  //   3) `import path from "path"` -> `const path = require("path")`
  //   4) `import.meta.dir` -> a stub pointing at .opencode/tools (so the script
  //      path resolves to .opencode/scripts/pipeline-status.py)
  //   5) `args: z.ZodObject` -> the args are referenced inside execute as
  //      `args.pr_number`; the schema itself is unused at runtime here.
  //   6) Strip `: type` annotations and `async execute(args)` stays.
  //   7) Strip `as const` assertions (TS-only, used by post-review.ts
  //      for tuple literal types) -> plain array literal.
  //   8) Strip `type <Name> = ...;` type alias declarations (TS-only) -> removed.
  //   9) Strip relative imports (`import { X } from "./_shared"`) — the
  //      referenced module is inlined by loadTool() via inlineShared(). The
  //      import line is removed here; the bindings come from the prepended
  //      shared-module code.
  //  10) Strip `export ` keyword on top-level `function`/`const` declarations
  //      (shared modules use `export function parseRepo`). After stripping,
  //      the declarations become plain statements in the sandbox scope.
  //  11) Strip type annotations on function parameters and return types:
  //      `function foo(a: Type, b?: Type2): RetType {` -> `function foo(a, b) {`.
  //      Needed for _shared.ts which declares typed function params.
  let out = src
  out = out.replace(/^import\s+\{\s*spawnSync\s*\}\s+from\s+["']child_process["'];?\s*$/m, 'const { spawnSync } = require("child_process");')
  out = out.replace(/^import\s+path\s+from\s+["']path["'];?\s*$/m, 'const path = require("path");')
  out = out.replace(/^import\s+\{\s*tool\s*\}\s+from\s+["']@opencode-ai\/plugin["'];?\s*$/m, 'const tool = (x) => x;')
  // fs imports (create-readme.ts uses readFileSync/writeFileSync in local mode).
  // Convert to CJS require so the sandbox can inject the fs module.
  out = out.replace(/^import\s+\{\s*([^}]+)\s*\}\s+from\s+["']fs["'];?\s*$/m, 'const { $1 } = require("fs");')
  // Strip relative imports (`./_shared`, `../foo`, etc.) — loadTool inlines them.
  out = out.replace(/^import\s+\{[^}]*\}\s+from\s+["']\.[^"']+["'];?\s*$/m, "")
  // Replace `import.meta.dir` with the directory of the TS file.
  out = out.replace(/import\.meta\.dir/g, JSON.stringify(path.dirname(TS_FILE)))
  // Strip `as const` assertions: `["APPROVE", ...] as const` -> `["APPROVE", ...]`
  out = out.replace(/\bas\s+const\b/g, "")
  // Strip `type <Name> = ...` type alias declarations. Covers both single-line
  // (`type Foo = string`) and multi-line object types (`type Bar = {\n  a: T\n  b?: U\n}`)
  // used by create-readme.ts (Feature, CustomSection, CreateArgs). Use `gs`
  // (`.` matches newlines) for the multi-line case anchored at `^type ... = {`
  // through the closing `}` on its own line.
  out = out.replace(/^type\s+\w+\s*=\s*\{[^}]*\}\s*;?\s*$/gms, "")
  out = out.replace(/^type\s+\w+\s*=\s*.+\s*;?\s*$/gm, "")
  // Strip `export ` keyword on top-level declarations (shared modules).
  out = out.replace(/^export\s+(function|const|let|var)\b/gm, "$1")
  // Strip type annotations on function params + return type:
  //   `function foo(a: Type, b?: Type2): RetType {`  ->  `function foo(a, b) {`
  // Handles single-line function signatures (used by _shared.ts). Object
  // types like `{ cwd?: string }` are matched non-greedily within a param.
  // Return type may itself be an object literal type (e.g. create-readme.ts
  // `validateReadme(content: string): { ok: boolean; issues: string[] }`),
  // so match greedily from `):` up to the final ` {` that opens the body.
  out = out.replace(/^(\s*function\s+\w+\s*\()([^)]*)\)(\s*:\s*.+?)?\s*\{/gm, (line, head, params, _ret) => {
    const cleaned = params
      .split(",")
      .map((p) => p.replace(/^\s*\w+/, (n) => n).replace(/:.*/, "").replace(/\?$/, "").trim())
      .filter((p) => p.length > 0)
      .join(", ")
    return `${head}${cleaned}) {`
  })
  // Strip type annotations on variable declarations:
  //   `const issues: string[] = []`  ->  `const issues = []`
  //   `let content: string`           ->  `let content`
  // Handles common TS types (string, boolean, number, arrays, generics like
  // Record<...>, union types `A | B`, and `undefined`). Matches the `: <Type>`
  // segment between the binding name and `=` or end-of-statement.
  out = out.replace(/^(\s*(?:const|let|var)\s+\w+)\s*:\s*[^=;\n]+?(\s*=|\s*$)/gm, "$1$2")
  // Strip TS non-null assertions (`foo!`): post-fix `!` after an identifier
  // or closing bracket, used to assert non-null in TS. Must not strip `!`
  // in logical operators (`!=`, `!==`, unary `!foo`). Match `!` immediately
  // after a word char or `]`/`)` that is NOT followed by `=`.
  out = out.replace(/([\w\])])!(?!=)/g, "$1")
  return out
}

function inlineShared(rawSrc, spawnSyncImpl) {
  // Detect relative imports (`./_shared`, `../foo`, etc.) in the ORIGINAL
  // source (before stripTs blanks the line) and inline the shared module so
  // its exported functions are in scope inside the new Function sandbox. We
  // load and strip the shared module the same way as the tool, then strip its
  // own `require("child_process")` / `require("path")` lines so they pick up
  // the sandbox bindings instead of re-declaring identifiers.
  const sharedMatch = rawSrc.match(/^import\s+\{[^}]*\}\s+from\s+["'](\.[^"']+)["'];?\s*$/m)
  if (!sharedMatch) return { sharedCode: "" }
  const rel = sharedMatch[1]
  // Resolve the shared module relative to the tool's directory. The import
  // specifier in TS omits the `.ts` extension (e.g. `./_shared`), so append it
  // when the specifier has no extension.
  const spec = rel.endsWith(".ts") ? rel : rel + ".ts"
  const sharedPath = path.resolve(path.dirname(TS_FILE), spec)
  let sharedSrc = stripTs(readFileSync(sharedPath, "utf-8"))
  // Strip the same sandbox-replaced declarations so no duplicate identifiers
  // clash with the new Function parameters / require shim.
  sharedSrc = sharedSrc.replace(/^const\s+\{\s*spawnSync\s*\}\s*=\s*require\(["']child_process["']\);?\s*$/m, "")
  sharedSrc = sharedSrc.replace(/^const\s+path\s*=\s*require\(["']path["']\);?\s*$/m, "")
  sharedSrc = sharedSrc.replace(/^const\s+tool\s*=\s*\(x\)\s*=>\s*x;?\s*$/m, "")
  // Remove any relative import line from the shared module (defensive — _shared
  // has none today, but keeps the path open for deeper nesting).
  sharedSrc = sharedSrc.replace(/^import\s+\{[^}]*\}\s+from\s+["']\.[^"']+["'];?\s*$/m, "")
  return { sharedCode: sharedSrc + "\n" }
}

function loadTool(spawnSyncImpl, fsImpl) {
  // Provide a CommonJS module sandbox so the tool file's `export default`
  // becomes accessible via `module.exports.default`.
  const rawSrc = readFileSync(TS_FILE, "utf-8")
  let src = stripTs(rawSrc)
  // Inline relative imports (e.g. `./_shared`) so the tool's imported
  // functions (parseRepo/runGh/formatResult) are in scope inside the sandbox.
  const { sharedCode } = inlineShared(rawSrc, spawnSyncImpl)
  // Strip the require/const declarations we replace with sandbox args, so
  // we don't get "Identifier already declared" between Function args and
  // the in-source `const { spawnSync } = require(...)` lines.
  src = src.replace(/^const\s+\{\s*spawnSync\s*\}\s*=\s*require\(["']child_process["']\);?\s*$/m, "")
  src = src.replace(/^const\s+path\s*=\s*require\(["']path["']\);?\s*$/m, "")
  src = src.replace(/^const\s+tool\s*=\s*\(x\)\s*=>\s*x;?\s*$/m, "")
  // NOTE: do NOT strip `const { ... } = require("fs")` — the require shim below
  // returns the fs module, so the destructuring binds readFileSync/writeFileSync
  // in-scope inside the sandbox. Stripping would drop the bindings.
  // Convert `export default tool({...})` into `module.exports.default = tool({...})`
  const cjs = src.replace(/^export default /m, "module.exports.default = ")
  // tool shim with .schema = zodShim (since pipeline-status.ts uses tool.schema.number())
  const toolShim = (x) => x
  toolShim.schema = zodShim
  const fs = fsImpl || nodeFs
  const fn = new Function(
    "module",
    "require",
    "spawnSync",
    "path",
    "tool",
    "fs",
    sharedCode + cjs + "\nreturn module.exports.default;",
  )
  return fn({ exports: {} }, (name) => {
    if (name === "child_process") return { spawnSync: spawnSyncImpl }
    if (name === "path") return path
    if (name === "fs") return fs
    throw new Error("unexpected require: " + name)
  }, spawnSyncImpl, path, toolShim, fs)
}

function buildExecArgs(tool, rawValue) {
  // Interpret ``rawValue`` (argv string) as the tool's declared args.
  // - pipeline-status.ts: ``pr_number`` (int) → parseInt
  // - spec-status.ts: ``validate`` (bool) → /true/i match
  // - project-status.ts: ``check`` (bool) + ``fast`` (bool) + ``repo`` (str) —
  //   multi-arg tool. ``rawValue`` encodes the booleans as "check|fast" and an
  //   optional third pipe-separated part carries the ``repo`` string:
  //   "true|false|/path/to/repo". Detected dynamically: if the tool declares
  //   ``check`` AND ``fast``, split on ``|`` and map the first two to booleans;
  //   if a third part exists and the tool declares ``repo``, pass it as string.
  // Detection is dynamic so the harness works for any single-arg tool
  // without hardcoding tool names. If the tool declares no args, return {}.
  const keys = Object.keys(tool.args || {})
  if (keys.length === 0) return {}
  // Multi-arg tool (project-status.ts: check + fast [+ repo]).
  if (keys.includes("check") && keys.includes("fast")) {
    const parts = String(rawValue || "").split("|")
    const args = {
      check: /^true$/i.test(parts[0] || ""),
      fast: /^true$/i.test(parts[1] || ""),
    }
    if (keys.includes("repo") && parts[2] !== undefined && parts[2] !== "") {
      args.repo = parts[2]
    }
    return args
  }
  const first = keys[0]
  if (first === "pr_number") return { pr_number: parseInt(rawValue, 10) }
  if (first === "validate") return { validate: /^true$/i.test(rawValue || "") }
  // Fallback heuristic: numeric → int, else bool-ish.
  return { [first]: rawValue }
}

// Multi-arg tools (commit.ts, create-pr.ts, create-issue.ts) declare several
// args (message, title, body, issue_number, labels). The single-arg
// ``buildExecArgs`` can't handle them. ``exec_stub_json`` mode passes the
// full args object as a JSON string + a JSON array of sequential stub
// responses (one per spawnSync call — commit.ts makes 2: git diff, git commit).
function buildExecArgsFromJson(rawValue) {
  return JSON.parse(rawValue)
}

function buildStubSequencer(responses) {
  // Return a stub function that returns responses[callIndex] for each call,
  // cycling through the list if there are more calls than responses.
  let idx = 0
  return (cmd, args, opts) => {
    const r = responses[idx % responses.length]
    idx++
    return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }
  }
}

function main() {
  const mode = process.argv[2]
  if (!mode) {
    console.error("usage: node _ts_loader.mjs <mode> [args...]")
    process.exit(2)
  }
  if (mode === "load") {
    const t = loadTool(spawnSync)
    console.log(JSON.stringify({ description: t.description, args: Object.keys(t.args) }))
    return
  }
  if (mode === "exec_stub") {
    // Args: <first_arg_value> <stub_status> <stub_stdout> <stub_stderr>
    // The first arg is interpreted based on which tool is loaded:
    // - pipeline-status.ts declares ``pr_number`` (int)
    // - spec-status.ts declares ``validate`` (bool)
    // Detected dynamically from ``t.args`` keys so the harness stays generic.
    const stubStatus = parseInt(process.argv[4], 10)
    const stubStdout = process.argv[5]
    const stubStderr = process.argv[6] || ""
    const callLog = []
    const stub = (cmd, args, opts) => {
      callLog.push({ cmd, args, opts })
      return { status: stubStatus, stdout: stubStdout, stderr: stubStderr }
    }
    const t = loadTool(stub)
    const execArgs = buildExecArgs(t, process.argv[3])
    t.execute(execArgs, {
      // ToolContext — only fields the tool actually touches. Our tool
      // touches none of the ctx fields, so this can be empty-ish.
      sessionID: "test", messageID: "test", agent: "test",
      directory: REPO_ROOT, worktree: REPO_ROOT,
      abort: new AbortController().signal,
      metadata() {}, async ask() {},
    }).then(
      (result) => {
        console.log(JSON.stringify({ result, calls: callLog }))
      },
      (err) => {
        console.log(JSON.stringify({ error: String(err), calls: callLog }))
      },
    )
    return
  }
  if (mode === "exec_stub_json") {
    // Multi-arg tools (commit.ts, create-pr.ts, create-issue.ts).
    // Args: <args_json> <responses_json>
    //   args_json — JSON string of the args object, e.g. {"message":"feat(x): y"}
    //   responses_json — JSON array of {status, stdout, stderr} stub
    //     responses, returned sequentially per spawnSync call. Tools that
    //     make N spawnSync calls need N entries (extra calls cycle back).
    const execArgs = buildExecArgsFromJson(process.argv[3])
    const responses = JSON.parse(process.argv[4])
    const callLog = []
    const stub = (cmd, args, opts) => {
      callLog.push({ cmd, args, opts })
      const r = responses[callLog.length - 1] || responses[responses.length - 1]
      return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" }
    }
    const t = loadTool(stub)
    t.execute(execArgs, {
      sessionID: "test", messageID: "test", agent: "test",
      directory: REPO_ROOT, worktree: REPO_ROOT,
      abort: new AbortController().signal,
      metadata() {}, async ask() {},
    }).then(
      (result) => {
        console.log(JSON.stringify({ result, calls: callLog }))
      },
      (err) => {
        console.log(JSON.stringify({ error: String(err), calls: callLog }))
      },
    )
    return
  }
  if (mode === "exec_real") {
    const t = loadTool(spawnSync) // use real spawnSync
    const execArgs = buildExecArgs(t, process.argv[3])
    t.execute(execArgs, {
      sessionID: "test", messageID: "test", agent: "test",
      directory: REPO_ROOT, worktree: REPO_ROOT,
      abort: new AbortController().signal,
      metadata() {}, async ask() {},
    }).then(
      (result) => console.log(JSON.stringify({ result })),
      (err) => console.log(JSON.stringify({ error: String(err) })),
    )
    return
  }
  console.error("unknown mode: " + mode)
  process.exit(2)
}

main()