/**
 * P9.1: SSH config editor for llama-swap hosts.
 *
 * Pipeline (design §5, stackctl flow with the tests stackctl never had):
 *   SFTP/SSH read -> schema-validated edit (config-schema.json from the fork)
 *   -> diff preview -> timestamped backup -> write -> restart -> health-wait.
 *
 * SSH I/O is shelled out via `ssh` (matching the booterm precedent — no ssh2
 * dependency, key from `secrets/`), injected as `SshExec` so every failure path
 * is unit-testable without a live host. The pure helpers (validate, diff,
 * backup filename) carry the logic and are tested directly.
 */

import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
import { load as loadYaml } from 'js-yaml';
import type { ValidateFunction } from 'ajv';

// ajv + ajv-formats are CJS. Under NodeNext ESM the default-import interop binds
// the namespace, not the constructable class, so load them via createRequire to
// get the real module.exports (class / plugin fn) at both type and runtime.
const require = createRequire(import.meta.url);
const Ajv = require('ajv') as typeof import('ajv').default;
const addFormats = require('ajv-formats') as typeof import('ajv-formats').default;

export interface SshTarget {
  host: string;
  user: string;
  keyPath: string;
}

export interface ExecResult {
  code: number;
  stdout: string;
  stderr: string;
}

/** Injectable SSH executor. `stdin`, when present, is piped to the remote command. */
export type SshExec = (target: SshTarget, command: string, stdin?: string) => Promise<ExecResult>;

export interface ValidationResult {
  valid: boolean;
  errors: string[];
  /** Parsed config object when YAML is syntactically valid. */
  parsed?: unknown;
}

let cachedValidator: ValidateFunction | null = null;
let cachedSchemaRef: object | null = null;

function getValidator(schema: object): ValidateFunction {
  if (cachedValidator && cachedSchemaRef === schema) return cachedValidator;
  const ajv = new Ajv({ allErrors: true, strict: false });
  addFormats(ajv);
  const validate = ajv.compile(schema);
  cachedValidator = validate;
  cachedSchemaRef = schema;
  return validate;
}

/**
 * Validate a llama-swap config YAML string against the fork's
 * config-schema.json. Catches YAML syntax errors first, then schema errors.
 * Pure — no I/O; the schema object is passed in.
 */
export function validateLlamaConfig(yamlText: string, schema: object): ValidationResult {
  let parsed: unknown;
  try {
    parsed = loadYaml(yamlText);
  } catch (err) {
    return { valid: false, errors: [`YAML parse error: ${(err as Error).message}`] };
  }
  if (parsed === null || typeof parsed !== 'object') {
    return { valid: false, errors: ['config must be a YAML mapping'], parsed };
  }

  const validate = getValidator(schema);
  const ok = validate(parsed);
  if (ok) return { valid: true, errors: [], parsed };

  const errors = (validate.errors ?? []).map((e) => {
    const path = e.instancePath || '(root)';
    return `${path} ${e.message ?? 'invalid'}`;
  });
  return { valid: false, errors: errors.length ? errors : ['schema validation failed'], parsed };
}

/**
 * Produce a compact line diff between two texts. Trims a common prefix/suffix
 * and marks the changed middle with -/+ lines. Sufficient for a preview; not a
 * minimal-edit Myers diff.
 */
export function computeDiff(oldText: string, newText: string): string {
  const oldLines = oldText.split('\n');
  const newLines = newText.split('\n');

  let start = 0;
  while (start < oldLines.length && start < newLines.length && oldLines[start] === newLines[start]) {
    start++;
  }
  let endOld = oldLines.length - 1;
  let endNew = newLines.length - 1;
  while (endOld >= start && endNew >= start && oldLines[endOld] === newLines[endNew]) {
    endOld--;
    endNew--;
  }

  if (endOld < start && endNew < start) return ''; // identical

  const out: string[] = [];
  out.push(`@@ lines ${start + 1}..${endOld + 1} -> ${start + 1}..${endNew + 1} @@`);
  for (let i = start; i <= endOld; i++) out.push(`- ${oldLines[i]}`);
  for (let i = start; i <= endNew; i++) out.push(`+ ${newLines[i]}`);
  return out.join('\n');
}

/** Timestamped backup path: `<configPath>.bak-YYYYMMDDTHHMMSSZ`. */
export function backupFilename(configPath: string, now: Date): string {
  const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
  return `${configPath}.bak-${stamp}`;
}

export type SshMode = 'shell' | 'wrapper';

export interface RemoteOps {
  read(): Promise<string>;
  backup(now: Date): Promise<string>;        // returns the backup path
  write(content: string): Promise<void>;
  restart(restartCmd: string): Promise<void>;
}

function fail(label: string, res: ExecResult): never {
  throw new Error(`${label} failed (exit ${res.code}): ${res.stderr.slice(0, 300)}`);
}

/** Raw-command ops (no wrapper on the host). */
export function shellOps(target: SshTarget, configPath: string, exec: SshExec): RemoteOps {
  return {
    async read() {
      const r = await exec(target, `cat ${shellQuote(configPath)}`);
      if (r.code !== 0) fail('read', r);
      return r.stdout;
    },
    async backup(now) {
      const backupPath = backupFilename(configPath, now);
      const r = await exec(target, `cp ${shellQuote(configPath)} ${shellQuote(backupPath)}`);
      if (r.code !== 0) fail('backup', r);
      return backupPath;
    },
    async write(content) {
      const r = await exec(target, `cat > ${shellQuote(configPath)}`, content);
      if (r.code !== 0) fail('write', r);
    },
    async restart(restartCmd) {
      const r = await exec(target, restartCmd);
      if (r.code !== 0) fail('restart', r);
    },
  };
}

/** Verb ops for a forced-command-locked key. The wrapper hardcodes the paths;
 *  the backup verb stamps and returns the backup path on stdout. */
export function wrapperOps(target: SshTarget, exec: SshExec): RemoteOps {
  return {
    async read() {
      const r = await exec(target, 'read');
      if (r.code !== 0) fail('read', r);
      return r.stdout;
    },
    async backup() {
      const r = await exec(target, 'backup');
      if (r.code !== 0) fail('backup', r);
      return r.stdout.trim();
    },
    async write(content) {
      const r = await exec(target, 'write', content);
      if (r.code !== 0) fail('write', r);
    },
    async restart() {
      const r = await exec(target, 'restart');
      if (r.code !== 0) fail('restart', r);
    },
  };
}

export function makeRemoteOps(mode: SshMode, target: SshTarget, configPath: string, exec: SshExec): RemoteOps {
  return mode === 'wrapper' ? wrapperOps(target, exec) : shellOps(target, configPath, exec);
}

/** Read the remote config file (mode-aware; defaults to shell for compat). */
export async function readRemoteConfig(
  target: SshTarget,
  configPath: string,
  exec: SshExec,
  mode: SshMode = 'shell',
): Promise<string> {
  return makeRemoteOps(mode, target, configPath, exec).read();
}

export interface ApplyResult {
  ok: boolean;
  step: 'validate' | 'backup' | 'write' | 'restart' | 'health' | 'done';
  backupPath?: string;
  diff?: string;
  error?: string;
}

export interface ApplyOptions {
  target: SshTarget;
  configPath: string;
  restartCmd: string;
  newConfig: string;
  schema: object;
  baseUrl: string;
  exec: SshExec;
  /** 'shell' (default) or 'wrapper'. */
  mode?: SshMode;
  fetcher?: typeof fetch;
  now?: Date;
  healthAttempts?: number;
  healthDelayMs?: number;
}

/**
 * The full apply pipeline. Aborts at the first failing step and reports which
 * one. Backup ALWAYS precedes write, so a failed write leaves the timestamped
 * backup intact for manual recovery. Mode selects the wire commands (raw shell
 * vs forced-command verbs); the pipeline is identical.
 */
export async function applyRemoteConfig(opts: ApplyOptions): Promise<ApplyResult> {
  const {
    target, configPath, restartCmd, newConfig, schema, baseUrl, exec,
    mode = 'shell', fetcher = fetch, now = new Date(),
    healthAttempts = 10, healthDelayMs = 2000,
  } = opts;

  const ops = makeRemoteOps(mode, target, configPath, exec);

  // 1. Validate before touching the host.
  const validation = validateLlamaConfig(newConfig, schema);
  if (!validation.valid) {
    return { ok: false, step: 'validate', error: validation.errors.join('; ') };
  }

  // Read current for diff + so an unreadable host fails before any write.
  let current = '';
  try {
    current = await ops.read();
  } catch (err) {
    return { ok: false, step: 'validate', error: `read current failed: ${(err as Error).message}` };
  }
  const diff = computeDiff(current, newConfig);

  // 2. Timestamped backup BEFORE write.
  let backupPath: string;
  try {
    backupPath = await ops.backup(now);
  } catch (err) {
    return { ok: false, step: 'backup', diff, error: (err as Error).message };
  }

  // 3. Write new config.
  try {
    await ops.write(newConfig);
  } catch (err) {
    return { ok: false, step: 'write', backupPath, diff, error: (err as Error).message };
  }

  // 4. Restart the service.
  try {
    await ops.restart(restartCmd);
  } catch (err) {
    return { ok: false, step: 'restart', backupPath, diff, error: (err as Error).message };
  }

  // 5. Health-wait: poll the provider until it serves /v1/models.
  const healthy = await healthWait(baseUrl, fetcher, healthAttempts, healthDelayMs);
  if (!healthy) {
    return { ok: false, step: 'health', backupPath, diff, error: 'health check did not pass after restart; backup retained' };
  }

  return { ok: true, step: 'done', backupPath, diff };
}

/** Poll the provider's /v1/models until it responds OK or attempts run out. */
export async function healthWait(
  baseUrl: string,
  fetcher: typeof fetch,
  attempts: number,
  delayMs: number,
): Promise<boolean> {
  for (let i = 0; i < attempts; i++) {
    try {
      const res = await fetcher(`${baseUrl.replace(/\/+$/, '')}/v1/models`, {
        signal: AbortSignal.timeout(5_000),
      });
      if (res.ok) return true;
    } catch {
      // not up yet
    }
    if (i < attempts - 1) await sleep(delayMs);
  }
  return false;
}

function sleep(ms: number): Promise<void> {
  return new Promise((r) => setTimeout(r, ms));
}

// Minimal POSIX single-quote shell escape for the remote command string.
function shellQuote(s: string): string {
  return `'${s.replace(/'/g, `'\\''`)}'`;
}

/**
 * Default SSH executor. Uses the system `ssh` with an explicit identity file and
 * IdentitiesOnly so the agent's default key is never offered (the boocode Gitea
 * lesson). BatchMode avoids interactive prompts hanging the service.
 */
export const sshExec: SshExec = (target, command, stdin) => {
  return new Promise<ExecResult>((resolve) => {
    const args = [
      '-i', target.keyPath,
      '-o', 'IdentitiesOnly=yes',
      '-o', 'BatchMode=yes',
      '-o', 'StrictHostKeyChecking=accept-new',
      '-o', 'ConnectTimeout=10',
      `${target.user}@${target.host}`,
      command,
    ];
    const child = spawn('ssh', args, { stdio: ['pipe', 'pipe', 'pipe'] });
    let stdout = '';
    let stderr = '';
    child.stdout.on('data', (d) => { stdout += d.toString(); });
    child.stderr.on('data', (d) => { stderr += d.toString(); });
    child.on('error', (err) => resolve({ code: 127, stdout, stderr: `${stderr}${(err as Error).message}` }));
    child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
    // Suppress EPIPE etc. if the remote exits before consuming stdin (e.g. auth
    // failure under BatchMode) — an unhandled stream 'error' would crash the process.
    child.stdin.on('error', () => {});
    if (stdin !== undefined) {
      child.stdin.write(stdin);
    }
    child.stdin.end();
  });
};
