import { join } from 'node:path';
import { serviceDir } from './paths';

// Running a sidecar's setup.sh, streaming what it prints, and collecting what it hands back.
//
// The script contract lives in `templates/README.md`. Two halves of it are implemented here: answers go
// in as environment (never as prompts, which behind a web form is a hang with no output), and results
// come back as `OFFICER_RESULT_<KEY>=value` lines on stdout.
//
// ── Why results are a line protocol and not JSON on stdout ──
//
// The same stream is the user's live log — it goes to a terminal panel while the install runs. A script
// that must emit clean JSON cannot also narrate, and one that emits both needs a framing convention
// anyway. A prefixed line is that convention, it survives being interleaved with `docker compose`
// output, and a human reading the log can see exactly what was handed back.
//
// This mirrors the progress sentinel the job runner already uses (`@@officer:progress@@`), for the same
// reason and with the same rule: the marker lines are plucked out of the stream, and everything else is
// passed through as narration.

const RESULT_PREFIX = 'OFFICER_RESULT_';

/**
 * Pull `OFFICER_RESULT_*` assignments out of a script's output.
 *
 * Pure, so the protocol is testable without spawning anything.
 *
 * Later lines win. A script that reports a value twice has changed its mind — a retry inside the script
 * that finally succeeded, say — and the last word is the one that reflects reality.
 */
export function parseResults(output: string): Record<string, string> {
  const results: Record<string, string> = {};
  for (const raw of output.split('\n')) {
    const line = raw.trim();
    if (!line.startsWith(RESULT_PREFIX)) continue;
    const eq = line.indexOf('=');
    // A prefix with no `=` is a script bug, not a value. Skipping beats storing a key with an empty
    // string, which would look like a deliberate blank later.
    if (eq <= RESULT_PREFIX.length) continue;
    const key = line.slice(RESULT_PREFIX.length, eq).toLowerCase();
    results[key] = line.slice(eq + 1);
  }
  return results;
}

export type RunScriptParams = {
  sidecarId: string;
  /** Directory holding the template's `setup.sh`, i.e. `app-store/templates/<template>`. */
  templateDir: string;
  /** Answers from the install form. Keys are used verbatim as environment variable names. */
  env: Record<string, string>;
  /** Called per line, as it happens — this is what the terminal panel renders. */
  log: (line: string) => void;
  /** Guard against a script that hangs rather than fails. */
  timeoutMs?: number;
};

export type RunScriptOutcome =
  | { ok: true; results: Record<string, string>; serviceDir: string }
  | { ok: false; error: string };

/** Ten minutes: long enough to pull a large image on a slow line, short enough to not hang a UI forever. */
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;

export async function runSetupScript(params: RunScriptParams): Promise<RunScriptOutcome> {
  const dir = serviceDir(params.sidecarId);
  const script = join(params.templateDir, 'setup.sh');

  const proc = Bun.spawn(['bash', script], {
    // The script's own working directory is its template dir; where it WRITES is OFFICER_SERVICE_DIR.
    // Keeping those separate is what stops a script from accidentally writing into the repo.
    cwd: params.templateDir,
    env: {
      ...process.env,
      ...params.env,
      OFFICER_SERVICE_DIR: dir,
      // A form cannot answer a prompt, so a script that would block must fail loudly instead. This is
      // the flag that turns a hang into a diagnosable error.
      OFFICER_NONINTERACTIVE: '1',
      OFFICER_UID: String(process.getuid?.() ?? 1000),
      OFFICER_GID: String(process.getgid?.() ?? 1000),
    },
    stdout: 'pipe',
    stderr: 'pipe',
  });

  const timeout = setTimeout(() => {
    params.log(`✗ timed out after ${(params.timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000}s — killing`);
    proc.kill();
  }, params.timeoutMs ?? DEFAULT_TIMEOUT_MS);

  // Line-buffered so the panel updates as things happen rather than in one dump at the end — the whole
  // reason for streaming. Both streams are forwarded: `docker compose` writes its progress to stderr,
  // so dropping it would hide most of what a user wants to watch.
  const collected: string[] = [];
  const pump = async (stream: ReadableStream<Uint8Array>, prefix = '') => {
    const decoder = new TextDecoder();
    let buffer = '';
    for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) {
      buffer += decoder.decode(chunk, { stream: true });
      let nl: number;
      while ((nl = buffer.indexOf('\n')) >= 0) {
        const line = buffer.slice(0, nl);
        buffer = buffer.slice(nl + 1);
        collected.push(line);
        params.log(prefix + line);
      }
    }
    if (buffer) {
      collected.push(buffer);
      params.log(prefix + buffer);
    }
  };

  await Promise.all([pump(proc.stdout), pump(proc.stderr, '  ')]);
  const code = await proc.exited;
  clearTimeout(timeout);

  if (code !== 0) {
    // The last few lines are almost always the reason; the whole log is already in the panel above.
    //
    // `collected` can interleave differently from real time: the two streams are pumped concurrently and
    // append as they arrive, so a stderr line can land before a stdout line that was printed first. The
    // LIVE log is correctly ordered — each `log()` fires as its line arrives — and only this summary can
    // read out of order. Kept concurrent deliberately: serialising the pumps would make a script that
    // writes a lot to one stream block on the other.
    const tail = collected.slice(-5).join('\n').trim();
    return { ok: false, error: `setup.sh exited ${code}${tail ? `: ${tail}` : ''}` };
  }

  return { ok: true, results: parseResults(collected.join('\n')), serviceDir: dir };
}
