import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { registerFleetCommand, type CommandResult, type CommandRunner } from './fleet.js';

/**
 * #1237: the v1-only commands (`ps`, `install`, `install-systemd`, `add`,
 * `remove`) rejected a roster-v2 fleet outright, so a greenfield v2 box could
 * never get its units placed. These tests pin the three behaviours that fix
 * gives it, and the two it deliberately does NOT give it.
 *
 * The load-bearing negative is that `install` on v2 writes no generated env:
 * the reconciler owns that file through projectRosterV2AgentGeneratedEnv, and a
 * second writer here — necessarily through the v1 mapping — is exactly the
 * drift the #791 single-SSOT invariant exists to prevent.
 */

const rosterV2 = `
version: 2
generation: 4
transport: tmux
tmux:
  socket_name: mosaic-fleet
  holder_session: _holder
defaults:
  working_directory: /srv/mosaic
  runtime: pi
runtimes:
  pi:
    reset_command: /new
agents:
  - name: coder0
    alias: Coder 0
    class: code
    runtime: pi
    provider: openai
    model: gpt-5.6-sol
    reasoning: high
    tool_policy: code
    working_directory: /srv/mosaic
    persistent_persona: false
    reset_between_tasks: true
    lifecycle:
      enabled: true
      desired_state: stopped
    launch:
      yolo: true
  - name: coder1
    alias: Coder 1
    class: code
    runtime: pi
    provider: openai
    model: gpt-5.6-sol
    reasoning: medium
    tool_policy: code
    working_directory: /srv/other
    persistent_persona: false
    reset_between_tasks: true
    lifecycle:
      enabled: true
      desired_state: stopped
    launch:
      yolo: true
`;

let tempHome: string | undefined;
const savedHome = process.env.HOME;
const savedMosaicHome = process.env.MOSAIC_HOME;

afterEach(async (): Promise<void> => {
  vi.restoreAllMocks();
  process.exitCode = undefined;
  if (savedHome === undefined) delete process.env.HOME;
  else process.env.HOME = savedHome;
  if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME;
  else process.env.MOSAIC_HOME = savedMosaicHome;
  if (tempHome) await rm(tempHome, { recursive: true, force: true });
  tempHome = undefined;
});

/**
 * A HOME with a roster-v2 fleet and nothing else — the greenfield shape, before
 * anything has been installed, applied or started.
 */
async function v2Home(): Promise<string> {
  tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-'));
  process.env.HOME = tempHome;
  delete process.env.MOSAIC_HOME;
  const mosaicHome = join(tempHome, '.config', 'mosaic');
  for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
    await mkdir(join(mosaicHome, directory), { recursive: true, mode: 0o700 });
  }
  await writeFile(join(mosaicHome, 'fleet', 'roster.yaml'), rosterV2, { mode: 0o600 });
  await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', {
    mode: 0o600,
  });
  return mosaicHome;
}

/**
 * Stands in for a box where nothing is running: every systemctl and tmux probe
 * fails the way it does before the holder has ever started. `ps` must survive
 * this — it is the command an operator reaches for to find out *why* there is
 * no seat, so it has to report the emptiness rather than fail on it.
 */
const greenfieldRunner: CommandRunner = async (command): Promise<CommandResult> => {
  if (command === 'tmux') {
    return { stdout: '', stderr: 'no server running on /tmp/tmux-1000/mosaic-fleet', exitCode: 1 };
  }
  return { stdout: '', stderr: '', exitCode: 1 };
};

function program(runner: CommandRunner = greenfieldRunner): Command {
  const result = new Command();
  result.exitOverride();
  registerFleetCommand(result, { runner, frameworkRoot: resolve(process.cwd(), 'framework') });
  return result;
}

function capture(): string[] {
  const lines: string[] = [];
  vi.spyOn(console, 'log').mockImplementation((value: string): void => {
    lines.push(value);
  });
  return lines;
}

async function exists(path: string): Promise<boolean> {
  try {
    await stat(path);
    return true;
  } catch {
    return false;
  }
}

describe('mosaic fleet ps — roster v2', (): void => {
  it('lists every v2 agent on a greenfield box with nothing running, and does not throw', async (): Promise<void> => {
    await v2Home();
    const lines = capture();

    await expect(
      program().parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']),
    ).resolves.toBeDefined();

    const rows = JSON.parse(lines.join('\n')) as {
      name: string;
      runtime: string;
      alias?: string;
      paneAlive: boolean;
      source: string;
    }[];
    expect(rows.map((row) => row.name).sort()).toEqual(['coder0', 'coder1']);
    // The v2 roster's per-agent fields must survive the read model, not be
    // flattened into defaults.
    expect(rows.every((row) => row.runtime === 'pi')).toBe(true);
    expect(rows.find((row) => row.name === 'coder0')?.alias).toBe('Coder 0');
    // Nothing is running, and that is a report, not an error.
    expect(rows.every((row) => row.paneAlive === false)).toBe(true);
    expect(rows.every((row) => row.source === 'roster')).toBe(true);
    expect(process.exitCode ?? 0).toBe(0);
  });
});

describe('mosaic fleet install — roster v2', (): void => {
  it('places the tool files and unit templates', async (): Promise<void> => {
    const mosaicHome = await v2Home();
    capture();

    await expect(
      program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']),
    ).resolves.toBeDefined();

    // Units live in the systemd user dir, not under the Mosaic home.
    const systemdUserDir = join(tempHome!, '.config', 'systemd', 'user');
    for (const unit of [
      'mosaic-tmux-holder.service',
      'mosaic-agent@.service',
      'mosaic-interaction-agent@.service',
    ]) {
      expect(await exists(join(systemdUserDir, unit))).toBe(true);
    }
    const launcher = join(mosaicHome, 'tools', 'fleet', 'start-agent-session.sh');
    expect(await exists(launcher)).toBe(true);
    expect((await stat(launcher)).mode & 0o777).toBe(0o755);
  });

  it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise<void> => {
    const mosaicHome = await v2Home();
    capture();

    await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);

    const agentDir = join(mosaicHome, 'fleet', 'agents');
    expect(await readdir(agentDir)).toEqual([]);
  });

  it('tells the operator which command does own the env', async (): Promise<void> => {
    await v2Home();
    const lines = capture();

    await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);

    expect(lines.join('\n')).toContain('mosaic fleet apply');
  });
});

describe('mosaic-agent@.service', (): void => {
  const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', 'mosaic-agent@.service');

  /**
   * Every `ConditionPathExists=` value declared by the unit template, in file
   * order, `|` triggering prefix included.
   *
   * Until #1410 this helper pinned `toHaveLength(1)` — a count assertion, not
   * a content assertion, and the count pin was itself the defect: when #1408
   * required a second triggering line (the `%h/.mosaic` brain-home shape),
   * this spec was a second consumer of the unit template that the shell suite
   * and the enumeration guard could not see, so the fix failed here first
   * (CI 2651 — the installation-documentation.spec.ts lesson again). Assert
   * content per line, never count.
   */
  async function conditionPaths(): Promise<string[]> {
    const unit = await readFile(unitPath, 'utf8');
    const matches = unit.match(/^ConditionPathExists=(.+)$/gm) ?? [];
    expect(matches.length).toBeGreaterThan(0);
    return matches.map((line) => line.slice('ConditionPathExists='.length).trim());
  }

  it('will not attempt a seat before the reconciler has written its env', async (): Promise<void> => {
    // The pairing that makes "install writes no env" safe: install enables the
    // unit (WantedBy=default.target) but does not start it, so without this
    // condition a reboot between `install` and the first `apply` would run
    // ExecStart against an absent env file and fail every seat unit.
    //
    // Two lines since #1408: the env projection lives under %h/.config/mosaic
    // on framework-home hosts and under %h/.mosaic on brain-home hosts. Both
    // carry the `|` triggering prefix — systemd ANDs same-type conditions
    // unless every line is triggering (then they OR), and a bare spelling
    // would demand BOTH home shapes on one host, which is never true, so
    // every seat would silently skip.
    expect(await conditionPaths()).toEqual([
      '|%h/.config/mosaic/fleet/agents/%i.env.generated',
      '|%h/.mosaic/fleet/agents/%i.env.generated',
    ]);
  });

  it('refuses the bare ANDed spelling on every condition line', async (): Promise<void> => {
    // Invariant ported from test-fleet-units.sh, held separately from the
    // literal pin above so it survives future edits to the path set: every
    // ConditionPathExists line must stay triggering (`|`).
    for (const value of await conditionPaths()) {
      expect(value.startsWith('|')).toBe(true);
    }
  });

  /**
   * The two halves of the guard's *effect*, which no assertion on the literal
   * string can cover on its own.
   *
   * Measured end to end on a real box (canary, 2026-08-16) rather than inferred:
   * with the condition, `systemctl --user start mosaic-agent@<name>` on an agent
   * with no generated env returns rc=0, `Result=success`, `ConditionResult=no`,
   * and journals "skipped, unmet condition check". With the condition removed by
   * drop-in and nothing else changed, the same start returns rc=1,
   * `Result=exit-code`, `ExecMainStatus=64`, and the unit enters `failed`.
   *
   * systemd is not available in this suite, so these two tests pin the parts
   * that can drift in code: the condition naming a *different* file than the one
   * the fleet actually writes, and the launcher quietly becoming tolerant of an
   * absent env — either of which turns the condition into decoration while the
   * literal-string assertion above still passes.
   */
  it('guards exactly the file the fleet writes, so the two cannot drift apart', async (): Promise<void> => {
    const mosaicHome = await v2Home();
    const rendered = (await conditionPaths()).map((value) =>
      value.replace(/^\|/, '').replace('%h', tempHome!).replace('%i', 'coder0'),
    );

    // Per home shape, the guard must render to exactly the file the
    // reconciler writes there: mosaicHome (%h/.config/mosaic) on
    // framework-home hosts, %h/.mosaic on brain-home hosts (#1408) — each
    // line pinned to its file, not merely present.
    expect(rendered).toEqual([
      join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'),
      join(tempHome!, '.mosaic', 'fleet', 'agents', 'coder0.env.generated'),
    ]);
  });

  it('guards a real failure — the launcher rejects an absent generated env', async (): Promise<void> => {
    await v2Home();
    await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);

    // Exactly what ExecStart runs, against the state the condition exists to
    // catch: unit enabled, reconciler has not written env yet.
    const launched = await new Promise<{ code: number | null; stderr: string }>((settle) => {
      const child = execFile(
        '/bin/bash',
        [
          '--noprofile',
          '--norc',
          join(tempHome!, '.config', 'mosaic', 'tools', 'fleet', 'start-agent-session.sh'),
          'coder0',
        ],
        { env: { HOME: tempHome!, MOSAIC_AGENT_NAME: 'coder0', PATH: '/usr/bin:/bin' } },
        (_error, _stdout, stderr) => {
          settle({ code: child.exitCode, stderr });
        },
      );
    });

    expect(launched.code).not.toBe(0);
    expect(launched.stderr).toContain('missing-file');
  });
});

describe('mosaic fleet add / remove — roster v2', (): void => {
  it('add refuses, and names the two-step v2 sequence instead of inventing defaults', async (): Promise<void> => {
    await v2Home();

    await expect(
      program().parseAsync([
        'node',
        'mosaic',
        'fleet',
        'add',
        'coder2',
        '--runtime',
        'pi',
        '--class',
        'code',
      ]),
    ).rejects.toThrow(/mosaic fleet create[\s\S]*mosaic fleet apply/);
  });

  it('remove refuses, and names delete plus apply', async (): Promise<void> => {
    await v2Home();

    await expect(
      program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']),
    ).rejects.toThrow(/mosaic fleet delete coder1[\s\S]*mosaic fleet apply/);
  });

  // Note: this one passes on the unmodified tree too — there `remove` throws in
  // the v1 parser, before it can touch anything. It is a regression guard on the
  // ordering of the new guard clause, not evidence that the fix works.
  it('refuses BEFORE mutating the roster', async (): Promise<void> => {
    const mosaicHome = await v2Home();
    const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
    const before = await readFile(rosterPath, 'utf8');

    await expect(
      program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']),
    ).rejects.toThrow();

    expect(await readFile(rosterPath, 'utf8')).toBe(before);
  });
});
