/**
 * postInstall executor for `mcpctl skills sync`.
 *
 * Trust model: mcpctl runs scripts that mcpd has served. mcpd is the
 * corporate source of truth — content is reviewed at publish time. We
 * do NOT sandbox or signature-check on the client. The controls that
 * matter live on the publishing side (RBAC, audit, reviewer queue).
 *
 * What we DO provide is ops hygiene:
 *   - Hard timeout (default 60 s, per-skill override via
 *     `metadata.postInstallTimeoutSec`). Stops a runaway script from
 *     wedging Claude startup forever.
 *   - Hash-pinning: the script's sha256 is recorded in the skills state
 *     file so the next sync skips re-execution unless the hash changed.
 *     Saves churn; catches "the same skill at the same semver was
 *     re-published with a fixed script".
 *   - Curated env: MCPCTL_SKILL_NAME / _VERSION / _DIR / _PROJECT plus
 *     inherited PATH / HOME / USER / SHELL. Cron-style minimal env so
 *     scripts behave the same on every machine.
 *   - Per-skill install log under ~/.mcpctl/skills/<name>/install.log
 *     (rotated to keep the last 5 runs). Standard sysadmin reflex.
 *   - Audit event back to mcpd on every run. So mcpd's audit pipeline
 *     has both sides of the timeline (publish + per-machine execution).
 *
 * Failure semantics: a non-zero exit, a hang past the timeout, or a
 * spawn error is treated as a failed sync of THIS skill. The state
 * file's postInstallHash is NOT updated on failure, so the next sync
 * will retry. Other skills in the same sync run continue regardless.
 */
import { createHash } from 'node:crypto';
import { spawn } from 'node:child_process';
import { mkdir, readFile, writeFile, stat } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { hostname } from 'node:os';
import { setTimeout as delay } from 'node:timers/promises';

import type { ApiClient } from '../api-client.js';

export interface PostInstallInput {
  /** Full path of the materialised skill directory. The script path is resolved relative to this. */
  installDir: string;
  /** metadata.postInstall — relative path inside the skill bundle. */
  scriptPath: string;
  /** Name of the skill. Surfaces in audit + env + log path. */
  skillName: string;
  /** Skill version. Audit + env. */
  semver: string;
  /** Project name when the skill is project-scoped, else undefined. */
  projectName?: string | undefined;
  /** Per-skill override for the 60-s default. */
  timeoutSec?: number | undefined;
  /** Where to put the rolling install.log. Default: ~/.mcpctl/skills/<name>/install.log. */
  logsDir: string;
}

export interface PostInstallResult {
  exitCode: number | null;
  durationMs: number;
  scriptHash: string;
  timedOut: boolean;
  signal: NodeJS.Signals | null;
  stdoutTail: string;
  stderrTail: string;
}

const DEFAULT_TIMEOUT_SEC = 60;
const TAIL_BYTES = 4 * 1024;
const MAX_LOG_BYTES = 256 * 1024;

/**
 * Compute the sha256 of a script — used as the "have I already run this
 * version?" key in the skills state file. Caller passes the raw script
 * bytes; this just wraps the hash routine to stay consistent with the
 * `'sha256:'`-prefixed format used elsewhere (skills-state.ts).
 */
export function hashScript(content: string | Buffer): string {
  const buf = typeof content === 'string' ? Buffer.from(content, 'utf-8') : content;
  return 'sha256:' + createHash('sha256').update(buf).digest('hex');
}

/**
 * Run the post-install script. Returns a result regardless of success
 * or failure — caller inspects `exitCode`/`timedOut` to decide.
 *
 * Path validation: the resolved script path must remain inside
 * `installDir`. A skill that tries to point postInstall at
 * `../../../../etc/passwd-like` is rejected as a failed run, not
 * silently ignored.
 */
export async function runPostInstall(input: PostInstallInput): Promise<PostInstallResult> {
  const start = Date.now();
  const timeoutMs = (input.timeoutSec ?? DEFAULT_TIMEOUT_SEC) * 1000;

  const fullPath = resolve(input.installDir, input.scriptPath);
  // Defence in depth: the install dir is server-published content, but
  // a server with skill-write RBAC could still cause mischief. The
  // check makes our intent explicit: scripts may only live inside the
  // skill bundle.
  const installDirResolved = resolve(input.installDir);
  if (!fullPath.startsWith(installDirResolved + '/') && fullPath !== installDirResolved) {
    throw new Error(
      `postInstall path '${input.scriptPath}' escapes skill dir`,
    );
  }

  // Read script bytes for hashing (and to fail-fast if missing).
  const scriptBytes = await readFile(fullPath);
  const scriptHash = hashScript(scriptBytes);

  // Curated env. Cron-style minimum: keep PATH so the script can find
  // git/curl/python; keep HOME/USER/SHELL so scripts that touch dotfiles
  // work; drop everything else.
  const env: Record<string, string> = {
    PATH: process.env['PATH'] ?? '/usr/local/bin:/usr/bin:/bin',
    HOME: process.env['HOME'] ?? '',
    USER: process.env['USER'] ?? '',
    SHELL: process.env['SHELL'] ?? '/bin/sh',
    LANG: process.env['LANG'] ?? 'C.UTF-8',
    TERM: process.env['TERM'] ?? 'dumb',
    MCPCTL_SKILL_NAME: input.skillName,
    MCPCTL_SKILL_VERSION: input.semver,
    MCPCTL_SKILL_DIR: installDirResolved,
  };
  if (input.projectName) env['MCPCTL_PROJECT'] = input.projectName;

  // Make sure the script is executable. Some upstreams ship with mode
  // 0644 — if shebang exists, we can fall through to the interpreter;
  // otherwise spawn will EACCES.
  await ensureExecutable(fullPath, scriptBytes);

  await mkdir(input.logsDir, { recursive: true });
  const logPath = join(input.logsDir, 'install.log');

  // Rolling-append. Keep ~256 KB; old entries get truncated. The tail
  // returned to the caller is the last few KB regardless.
  const logHeader = `\n=== ${new Date().toISOString()} ${input.skillName}@${input.semver} ===\n`;

  // Cast through Buffer<ArrayBufferLike> — Node's typings split Buffer
  // into Buffer<ArrayBuffer> (from .alloc) and Buffer<ArrayBufferLike>
  // (from .subarray), which exactOptionalPropertyTypes refuses to
  // bridge. Explicit `Buffer` annotation widens to the union.
  let stdoutBuf: Buffer = Buffer.alloc(0);
  let stderrBuf: Buffer = Buffer.alloc(0);
  let timedOut = false;

  const child = spawn(fullPath, [], {
    cwd: installDirResolved,
    env,
    stdio: ['ignore', 'pipe', 'pipe'],
  });

  child.stdout.on('data', (chunk: Buffer) => {
    stdoutBuf = appendCapped(stdoutBuf, chunk);
  });
  child.stderr.on('data', (chunk: Buffer) => {
    stderrBuf = appendCapped(stderrBuf, chunk);
  });

  // Hard timeout via SIGTERM, then SIGKILL after 2 s grace.
  const timer = setTimeout(() => {
    timedOut = true;
    child.kill('SIGTERM');
    void (async () => {
      await delay(2000);
      if (child.exitCode === null) child.kill('SIGKILL');
    })();
  }, timeoutMs);

  const { exitCode, signal } = await new Promise<{ exitCode: number | null; signal: NodeJS.Signals | null }>((resolveProm) => {
    child.on('close', (code, sig) => resolveProm({ exitCode: code, signal: sig }));
    child.on('error', () => resolveProm({ exitCode: null, signal: null }));
  });
  clearTimeout(timer);

  const durationMs = Date.now() - start;
  const stdoutText = stdoutBuf.toString('utf-8');
  const stderrText = stderrBuf.toString('utf-8');

  // Append to the install log, truncating from the front if oversize.
  const trailer = `\n--- exit ${exitCode === null ? '?' : String(exitCode)}${signal ? ` (${signal})` : ''} in ${String(durationMs)}ms${timedOut ? ' [TIMEOUT]' : ''} ---\n`;
  const fullEntry = logHeader + 'STDOUT:\n' + stdoutText + '\nSTDERR:\n' + stderrText + trailer;
  await appendBoundedLog(logPath, fullEntry);

  return {
    exitCode,
    durationMs,
    scriptHash,
    timedOut,
    signal,
    stdoutTail: tailString(stdoutText, TAIL_BYTES),
    stderrTail: tailString(stderrText, TAIL_BYTES),
  };
}

/**
 * Best-effort audit emission — POSTs a structured event back to mcpd
 * so admins have fleet visibility. Failures are warned via the
 * provided logger but never thrown; the audit log is supplementary,
 * not load-bearing for sync correctness.
 *
 * The event includes machine fingerprint (hostname) so the operator
 * can tell which dev box ran the script — useful when triaging a
 * misbehaving update.
 */
export async function emitPostInstallAudit(
  client: ApiClient,
  input: PostInstallInput,
  result: PostInstallResult,
  warn: (msg: string) => void = () => {},
): Promise<void> {
  try {
    await client.post('/api/v1/audit-events', {
      eventKind: 'skill_postinstall',
      source: 'mcpctl-cli',
      verified: false,
      payload: {
        skillName: input.skillName,
        skillVersion: input.semver,
        projectName: input.projectName ?? null,
        scriptPath: input.scriptPath,
        scriptHash: result.scriptHash,
        exitCode: result.exitCode,
        durationMs: result.durationMs,
        timedOut: result.timedOut,
        signal: result.signal,
        machine: hostname(),
      },
    });
  } catch (err) {
    warn(`mcpctl: failed to emit postInstall audit event: ${err instanceof Error ? err.message : String(err)}`);
  }
}

// ── internals ──

function appendCapped(buf: Buffer, chunk: Buffer): Buffer {
  // Keep up to MAX_LOG_BYTES per stream; drop oldest bytes if over.
  if (buf.length + chunk.length <= MAX_LOG_BYTES) {
    return Buffer.concat([buf, chunk]);
  }
  const merged = Buffer.concat([buf, chunk]);
  // Buffer.from(...) here keeps Node's typing happy under
  // exactOptionalPropertyTypes — `subarray` on Buffer returns a
  // Buffer<ArrayBufferLike> which TS won't widen to the input type.
  return Buffer.from(merged.subarray(merged.length - MAX_LOG_BYTES));
}

function tailString(s: string, bytes: number): string {
  if (s.length <= bytes) return s;
  return '…' + s.slice(s.length - bytes + 1);
}

async function ensureExecutable(path: string, bytes: Buffer): Promise<void> {
  try {
    const st = await stat(path);
    // Owner execute bit. Skip if it's set already.
    if ((st.mode & 0o100) !== 0) return;
  } catch {
    return; // stat failed — let the spawn surface the real error
  }
  // Has shebang? Fine — many shells will still execute even without +x
  // when invoked as `<interpreter> <path>`, but we always spawn the
  // path directly so we need +x. Set 0755.
  void bytes; // (kept around in case we want to inspect shebang later)
  const { chmod } = await import('node:fs/promises');
  await chmod(path, 0o755);
}

async function appendBoundedLog(path: string, entry: string): Promise<void> {
  const max = 5 * MAX_LOG_BYTES;
  let existing = '';
  try {
    existing = await readFile(path, 'utf-8');
  } catch (err: unknown) {
    if (typeof err !== 'object' || err === null || (err as { code?: string }).code !== 'ENOENT') throw err;
  }
  const combined = existing + entry;
  // Keep last `max` bytes.
  const trimmed = combined.length > max ? '…[truncated]…\n' + combined.slice(combined.length - max) : combined;
  await mkdir(dirname(path), { recursive: true });
  await writeFile(path, trimmed, 'utf-8');
}
