/**
 * src/http/editor.ts (v0.6) — launch a desktop editor on the HOST for a project.
 *
 * POST /open-in-editor runs `<cfg.editorCmd> <repoPath>` (default `code`) so the
 * Projects panel's VS Code button opens the repo where the files actually live —
 * the host machine, regardless of which device clicked. This is a state-changing
 * route, so server.ts guards it with the Origin whitelist (CSRF defence).
 *
 * Safety: execFile (NOT a shell) — the path is passed as an argv element, never
 * interpolated into a command line, so no shell-injection surface. The path is
 * validated to be an existing absolute directory before spawning. This adds no
 * privilege beyond what the app already grants (a full shell on the host).
 */

import { execFile } from 'node:child_process'
import fs from 'node:fs/promises'
import path from 'node:path'
import type { Config } from '../types.js'

export interface OpenEditorResult {
  ok: boolean
  status: number // HTTP status to return
  error?: string // user-facing reason on failure
}

/**
 * Validate `rawPath` (must be an absolute, existing directory) then launch the
 * configured editor on it (fire-and-forget; the GUI process is detached). Never
 * throws — returns a structured result the route maps to an HTTP response.
 */
export async function openInEditor(cfg: Config, rawPath: unknown): Promise<OpenEditorResult> {
  if (typeof rawPath !== 'string' || rawPath.trim() === '') {
    return { ok: false, status: 400, error: 'path is required' }
  }
  if (!path.isAbsolute(rawPath)) {
    return { ok: false, status: 400, error: 'path must be absolute' }
  }

  let stat
  try {
    stat = await fs.stat(rawPath)
  } catch {
    return { ok: false, status: 404, error: 'path not found' }
  }
  if (!stat.isDirectory()) {
    return { ok: false, status: 400, error: 'path is not a directory' }
  }

  try {
    // No shell: argv array, so the path can never be interpreted as a command.
    const child = execFile(cfg.editorCmd, [rawPath], { windowsHide: true })
    // A ChildProcess 'error' (e.g. editor binary not in PATH) is emitted async;
    // handle it so it never bubbles to an uncaughtException, and don't keep the
    // server alive waiting on the GUI process.
    child.on('error', (err) => {
      console.error('[editor] failed to launch', JSON.stringify(cfg.editorCmd), '-', err.message)
    })
    child.unref()
    return { ok: true, status: 204 }
  } catch (err) {
    return { ok: false, status: 500, error: err instanceof Error ? err.message : String(err) }
  }
}

// ── W1: open a specific FILE at a line (clickable terminal paths) ──────────────
//
// Additive to openInEditor (which only opens directories). The frontend link
// provider POSTs {file, line} here so a click on `src/app.ts:42` jumps straight
// to that line in the host's editor. Same execFile (no shell) safety model.

/** Editors whose CLI accepts `--goto <file>:<line>` to jump to a line. */
const GOTO_EDITORS: ReadonlySet<string> = new Set([
  'code', 'code-insiders', 'codium', 'vscodium', 'cursor', 'windsurf',
])

const MIN_LINE = 1
const MAX_LINE = 1_000_000

/**
 * True iff `editorCmd`'s basename is an editor that understands `--goto file:line`.
 * Unknown editors get the bare file (never a stray `--goto` argv they'd misread
 * as a filename). A trailing .cmd/.exe/.bat (Windows shim) is stripped first.
 */
export function isGotoEditor(editorCmd: string): boolean {
  const base = path.basename(editorCmd).toLowerCase().replace(/\.(cmd|exe|bat)$/, '')
  return GOTO_EDITORS.has(base)
}

/**
 * Validate `rawFile` (absolute, existing regular file) and optional `rawLine`
 * (integer 1..1_000_000), then launch the editor at that file:line. Never throws
 * — returns a structured result the route maps to an HTTP response. The line is
 * validated to an integer before interpolation into `${file}:${line}`, so it can
 * never smuggle shell metacharacters (and there's no shell anyway — execFile argv).
 */
export async function openFileInEditor(
  cfg: Config,
  rawFile: unknown,
  rawLine?: unknown,
): Promise<OpenEditorResult> {
  if (typeof rawFile !== 'string' || rawFile.trim() === '') {
    return { ok: false, status: 400, error: 'file is required' }
  }
  if (!path.isAbsolute(rawFile)) {
    return { ok: false, status: 400, error: 'file must be absolute' }
  }

  let line: number | undefined
  if (rawLine !== undefined && rawLine !== null) {
    if (
      typeof rawLine !== 'number' ||
      !Number.isInteger(rawLine) ||
      rawLine < MIN_LINE ||
      rawLine > MAX_LINE
    ) {
      return { ok: false, status: 400, error: `line must be an integer ${MIN_LINE}..${MAX_LINE}` }
    }
    line = rawLine
  }

  let stat
  try {
    stat = await fs.stat(rawFile)
  } catch {
    return { ok: false, status: 404, error: 'file not found' }
  }
  if (!stat.isFile()) {
    return { ok: false, status: 400, error: 'path is not a file' }
  }

  const args =
    isGotoEditor(cfg.editorCmd) && line !== undefined
      ? ['--goto', `${rawFile}:${line}`]
      : [rawFile]

  try {
    const child = execFile(cfg.editorCmd, args, { windowsHide: true })
    child.on('error', (err) => {
      console.error('[editor] failed to launch', JSON.stringify(cfg.editorCmd), '-', err.message)
    })
    child.unref()
    return { ok: true, status: 204 }
  } catch (err) {
    return { ok: false, status: 500, error: err instanceof Error ? err.message : String(err) }
  }
}
