import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"

import type { Command, ShoutFile } from "./parse.ts"
import { trimTrailingEmpty } from "./utils.ts"

export type CommandResult = {
  command: Command
  actual: string[]
  exitCode: number
}

export type FileResult = {
  file: ShoutFile
  results: CommandResult[]
  tmpDir: string
  error?: string
}

type RunOptions = {
  cleanEnv: boolean
  pathDirs?: string[]
  envVars?: Record<string, string>
  sourceDir?: string
  projectDir?: string
  timeout: number
  verbose?: boolean
  onCommand?: (cmd: Command) => void
  onCommandResult?: (index: number, result: CommandResult) => void
}

function killTree(pid: number): void {
  // Find any processes that escaped the process group (e.g. via setsid).
  // This assumes pid === pgid, which holds because the child is spawned
  // with detached: true (making it a process group leader).
  try {
    const result = Bun.spawnSync(["ps", "-eo", "pid,pgid"])
    const output = result.stdout.toString()
    const pgid = String(pid)
    for (const line of output.split("\n")) {
      const parts = line.trim().split(/\s+/)
      if (parts[1] === pgid) {
        const p = parseInt(parts[0]!, 10)
        if (!isNaN(p) && p !== pid && p > 1) {
          try { process.kill(p, "SIGKILL") } catch {}
        }
      }
    }
  } catch {}
  // Kill the process group
  try { process.kill(-pid, "SIGKILL") } catch {}
}

const SENTINEL_PREFIX = "__SHOUT_SENTINEL_"
const VERBOSE_MARKER = "__SHOUT_CMD_"

function buildScript(commands: Command[], sentinel: string, verbose: boolean): string {
  const lines: string[] = []

  if (verbose) {
    // Save original stderr to fd 3 before merging stderr into stdout
    // Save the pipe on fd 9 so we can restore after each command
    lines.push("exec 3>&2 2>&1 9>&1")
  } else {
    lines.push("exec 2>&1 9>&1")
  }

  for (let i = 0; i < commands.length; i++) {
    const cmd = commands[i]!
    if (verbose) {
      lines.push(`printf '${VERBOSE_MARKER}${i}\\n' >&3`)
    }
    // Redirect stdout+stderr to a temp file so background processes
    // from previous commands can't pollute this command's output.
    // Background processes keep their fd pointing at the old temp file,
    // which becomes orphaned after rm — their output goes nowhere.
    lines.push(`__shout_out=$(mktemp)`)
    lines.push(`exec 1>"$__shout_out" 2>&1`)
    lines.push(cmd.command)
    lines.push(`__shout_ec=$?`)
    lines.push(`exec 1>&9 2>&1`)
    lines.push(`cat "$__shout_out"`)
    lines.push(`rm -f "$__shout_out"`)
    // Sentinel: printf to avoid echo interpretation issues
    // Format: __SHOUT_SENTINEL_<exitcode>_<index>__
    lines.push(
      `printf '\\n${sentinel}%s_${i}__\\n' "$__shout_ec"`,
    )
  }

  return lines.join("\n") + "\n"
}

function parseSentinelOutput(
  raw: string,
  commandCount: number,
): { outputs: string[][]; exitCodes: number[] } {
  const outputs: string[][] = []
  const exitCodes: number[] = []

  // Split by sentinel lines
  const sentinelRegex = new RegExp(
    `${SENTINEL_PREFIX}(\\d+)_(\\d+)__`,
  )

  let remaining = raw
  for (let i = 0; i < commandCount; i++) {
    const match = remaining.match(sentinelRegex)
    if (!match) {
      // No sentinel found — rest is output for this command
      const lines = remaining.split("\n")
      // Remove leading empty line (from printf \n prefix)
      if (lines.length > 0 && lines[0] === "") lines.shift()
      outputs.push(trimTrailingEmpty(lines))
      exitCodes.push(1) // assume failure
      break
    }

    const idx = remaining.indexOf(match[0])
    const before = remaining.slice(0, idx)
    const afterSentinel = remaining.slice(idx + match[0].length)

    // Parse output lines
    let lines = before.split("\n")
    // Remove leading empty line from previous sentinel's trailing \n
    if (lines.length > 0 && lines[0] === "") lines.shift()
    // Remove trailing empty lines (from printf's \n prefix)
    lines = trimTrailingEmpty(lines)
    outputs.push(lines.length === 1 && lines[0] === "" ? [] : lines)

    exitCodes.push(parseInt(match[1]!, 10))

    // Skip past sentinel line (including trailing newline)
    remaining = afterSentinel.startsWith("\n")
      ? afterSentinel.slice(1)
      : afterSentinel
  }

  // Fill missing entries
  while (outputs.length < commandCount) {
    outputs.push([])
    exitCodes.push(1)
  }

  return { outputs, exitCodes }
}

// eslint-disable-next-line no-control-regex
const ANSI_REGEX = /[\u001b\u009b][\[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g

function stripAnsi(line: string): string {
  return line.replace(ANSI_REGEX, "")
}

function streamVerboseMarkers(
  stderr: ReadableStream<Uint8Array>,
  commands: Command[],
  onCommand: (cmd: Command) => void,
): void {
  const reader = stderr.getReader()
  const decoder = new TextDecoder()
  let buffer = ""

  const pump = (): void => {
    reader.read().then(({ done, value }) => {
      if (done) return
      if (value) buffer += decoder.decode(value, { stream: true })
      let nlIdx: number
      while ((nlIdx = buffer.indexOf("\n")) !== -1) {
        const line = buffer.slice(0, nlIdx)
        buffer = buffer.slice(nlIdx + 1)
        if (line.startsWith(VERBOSE_MARKER)) {
          const i = parseInt(line.slice(VERBOSE_MARKER.length), 10)
          if (i >= 0 && i < commands.length) {
            onCommand(commands[i]!)
          }
        }
      }
      pump()
    }).catch(() => {})
  }
  pump()
}

export async function runFile(
  file: ShoutFile,
  options: RunOptions,
): Promise<FileResult> {
  const tmpDir = await mkdtemp(join(tmpdir(), "shout-"))

  if (file.commands.length === 0) {
    return { file, results: [], tmpDir }
  }

  const sentinel = SENTINEL_PREFIX
  const verbose = !!(options.verbose && options.onCommand)
  const script = buildScript(file.commands, sentinel, verbose)

  const env: Record<string, string> = options.cleanEnv
    ? {}
    : { ...process.env as Record<string, string> }

  env["HOME"] = tmpDir
  env["SHOUT_DIR"] = tmpDir
  if (options.sourceDir) {
    env["SHOUT_SOURCE_DIR"] = options.sourceDir
  }
  if (options.projectDir) {
    env["SHOUT_PROJECT_DIR"] = options.projectDir
  }

  if (options.envVars) {
    Object.assign(env, options.envVars)
  }

  if (options.pathDirs?.length) {
    env["PATH"] = options.pathDirs.join(":") + ":" + (env["PATH"] ?? "")
  }

  const proc = Bun.spawn(["/bin/sh"], {
    detached: true,
    stdin: "pipe",
    stdout: "pipe",
    stderr: "pipe",
    cwd: tmpDir,
    env,
  })

  try {
    if (verbose) {
      // Stream stderr for verbose command markers before writing script
      streamVerboseMarkers(proc.stderr, file.commands, options.onCommand!)
    }

    proc.stdin.write(script)
    proc.stdin.end()

    const totalTimeout = options.timeout * file.commands.length
    const lastSentinelSuffix = `_${file.commands.length - 1}__`
    const onSentinel = options.onCommandResult
      ? (index: number, exitCode: number, rawOutput: string) => {
          let lines = rawOutput.split("\n")
          if (lines.length > 0 && lines[0] === "") lines.shift()
          lines = trimTrailingEmpty(lines)
          if (lines.length === 1 && lines[0] === "") lines = []
          const result: CommandResult = {
            command: file.commands[index]!,
            actual: lines.map(stripAnsi),
            exitCode,
          }
          options.onCommandResult!(index, result)
        }
      : undefined
    const stdout = await readUntilSentinel(proc.stdout, sentinel, lastSentinelSuffix, totalTimeout, onSentinel)
    if (!verbose) {
      await readWithTimeout(proc.stderr, 1000).catch(() => "")
    }

    const { outputs, exitCodes } = parseSentinelOutput(
      stdout,
      file.commands.length,
    )

    const results: CommandResult[] = file.commands.map((cmd, i) => ({
      command: cmd,
      actual: (outputs[i] ?? []).map(stripAnsi),
      exitCode: exitCodes[i] ?? 1,
    }))

    return { file, results, tmpDir }
  } catch (err) {
    return {
      file,
      results: [],
      tmpDir,
      error: err instanceof Error ? err.message : String(err),
    }
  } finally {
    if (proc.pid) {
      killTree(proc.pid)
    }
  }
}

async function readUntilSentinel(
  stream: ReadableStream<Uint8Array>,
  sentinelPrefix: string,
  sentinelSuffix: string,
  timeoutMs: number,
  onSentinel?: (index: number, exitCode: number, outputBefore: string) => void,
): Promise<string> {
  const reader = stream.getReader()
  const decoder = new TextDecoder()
  let accumulated = ""
  let sentinelsReported = 0
  let lastSentinelEnd = 0

  let timerId: ReturnType<typeof setTimeout>
  const timeout = new Promise<never>((_, reject) =>
    timerId = setTimeout(() => reject(new Error("Timeout reading output")), timeoutMs),
  )

  try {
    while (true) {
      const { done, value } = await Promise.race([reader.read(), timeout]) as ReadableStreamReadResult<Uint8Array>
      if (done) break
      if (value) {
        accumulated += decoder.decode(value, { stream: true })

        // Detect new sentinels incrementally for streaming results
        if (onSentinel) {
          const regex = new RegExp(`${sentinelPrefix}(\\d+)_(\\d+)__`, "g")
          regex.lastIndex = lastSentinelEnd
          let match
          while ((match = regex.exec(accumulated)) !== null) {
            const idx = parseInt(match[2]!, 10)
            if (idx >= sentinelsReported) {
              const exitCode = parseInt(match[1]!, 10)
              const output = accumulated.slice(lastSentinelEnd, match.index)
              onSentinel(idx, exitCode, output)
              sentinelsReported = idx + 1
              lastSentinelEnd = match.index + match[0].length
              if (accumulated[lastSentinelEnd] === "\n") lastSentinelEnd++
            }
          }
        }

        // Check if the last sentinel has appeared (prefix + exitcode + suffix)
        const prefixIdx = accumulated.lastIndexOf(sentinelPrefix)
        if (prefixIdx !== -1 && accumulated.indexOf(sentinelSuffix, prefixIdx) !== -1) break
      }
    }
  } finally {
    clearTimeout(timerId!)
    reader.releaseLock()
  }

  return accumulated + decoder.decode()
}

async function readWithTimeout(
  stream: ReadableStream<Uint8Array>,
  timeoutMs: number,
): Promise<string> {
  const reader = stream.getReader()
  const chunks: Uint8Array[] = []

  let timerId: ReturnType<typeof setTimeout>
  const timeout = new Promise<never>((_, reject) =>
    timerId = setTimeout(() => reject(new Error("Timeout reading output")), timeoutMs),
  )

  try {
    while (true) {
      const { done, value } = await Promise.race([reader.read(), timeout]) as ReadableStreamReadResult<Uint8Array>
      if (done) break
      if (value) chunks.push(value)
    }
  } finally {
    clearTimeout(timerId!)
    reader.releaseLock()
  }

  const decoder = new TextDecoder()
  return chunks.map(c => decoder.decode(c, { stream: true })).join("") +
    decoder.decode()
}

export async function cleanupTmpDir(dir: string): Promise<void> {
  await rm(dir, { recursive: true, force: true })
}
