/**
 * Bevy test launcher.
 *
 * Spawns `cargo run` inside a run's worktree game directory so the operator can
 * playtest an agent's branch straight from the board. Build/runtime output is
 * batched and appended to the run's event log as `bevy` events (phase: start |
 * output | end), so it streams through the same SSE feed as the agent's work.
 *
 * The process is run in its own group so it can be torn down cleanly (cargo
 * plus the spawned game window) when the operator stops the test.
 */

import { spawn, type ChildProcess } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { appendRunEvent } from './events.js';

/** Game package path within a worktree. */
const GAME_REL = 'apps/game';

/** `cargo` (or override) used to build + run the Bevy client. */
const BEVY_CMD = process.env.BEVY_CMD ?? 'cargo';

/** How often buffered output is flushed as a single event. */
const FLUSH_MS = 250;

/** Flush early once this much output has accumulated. */
const MAX_BUFFER_CHARS = 4_000;

/** Soft cap on the number of output events per run, to protect the log. */
const MAX_OUTPUT_EVENTS = 2_000;

/** A live `cargo run` for one run/worktree. Not tied to the agent runner — a
 * Bevy test typically starts after the agent has settled. */
export class BevyProcess {
  readonly runId: string;
  private proc: ChildProcess | null = null;
  private buffer = '';
  private timer: ReturnType<typeof setInterval> | null = null;
  private outputEvents = 0;
  private cappedNotice = false;

  get running(): boolean {
    return this.proc !== null && !this.proc.killed;
  }

  constructor(runId: string) {
    this.runId = runId;
  }

  /** Spawn cargo run in the worktree's game dir. No-op if already running;
   * emits an `end` event if the worktree has no game dir. */
  start(wtPath: string): void {
    if (this.proc) return;
    const gameDir = path.join(wtPath, GAME_REL);
    if (!fs.existsSync(gameDir)) {
      appendRunEvent(this.runId, 'bevy', {
        phase: 'end',
        exitCode: -1,
        text: `No ${GAME_REL} in this worktree — cannot run Bevy.`,
      });
      return;
    }

    appendRunEvent(this.runId, 'bevy', { phase: 'start', text: `cargo run (in ${GAME_REL})` });

    this.proc = spawn(BEVY_CMD, ['run'], {
      cwd: gameDir,
      detached: true, // own process group → clean teardown of cargo + game window
      stdio: ['ignore', 'pipe', 'pipe'],
      env: { ...process.env },
    });
    this.proc.stdout?.setEncoding('utf8');
    this.proc.stderr?.setEncoding('utf8');
    this.proc.stdout?.on('data', (d: string) => this.consume(d));
    this.proc.stderr?.on('data', (d: string) => this.consume(d));
    this.proc.on('exit', (code, signal) => this.onExit(code, signal));
    this.timer = setInterval(() => this.flush(), FLUSH_MS);
  }

  /** Stop the test: signal the whole process group. The `exit` handler emits
   * the final `end` event. */
  stop(): void {
    if (!this.proc || !this.proc.pid) return;
    try {
      process.kill(-this.proc.pid, 'SIGTERM');
    } catch {
      try {
        this.proc.kill('SIGTERM');
      } catch {
        /* already gone */
      }
    }
  }

  private consume(chunk: string): void {
    this.buffer += chunk;
    if (this.buffer.length >= MAX_BUFFER_CHARS) this.flush();
  }

  private flush(): void {
    const text = this.buffer;
    this.buffer = '';
    if (!text) return;
    if (this.outputEvents >= MAX_OUTPUT_EVENTS) {
      if (!this.cappedNotice) {
        this.cappedNotice = true;
        appendRunEvent(this.runId, 'bevy', {
          phase: 'output',
          text: '…(output truncated to protect the log; the game is still running)',
        });
      }
      return;
    }
    this.outputEvents++;
    appendRunEvent(this.runId, 'bevy', { phase: 'output', text });
  }

  private onExit(code: number | null, signal: NodeJS.Signals | null): void {
    if (this.timer) {
      clearInterval(this.timer);
      this.timer = null;
    }
    this.flush();
    const reason = signal ? `signal ${signal}` : `exit code ${code}`;
    appendRunEvent(this.runId, 'bevy', {
      phase: 'end',
      exitCode: code ?? -1,
      text: `Bevy process ended (${reason}).`,
    });
    this.proc = null;
  }
}
