import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import {
  ROSTER_V2_JSON_SCHEMA,
  RosterV2ValidationError,
  parseRosterV2,
  renderRosterV2Yaml,
  validateRosterV2Semantics,
} from './roster-v2.js';

const validRoster = `
version: 2
generation: 7
transport: tmux
tmux:
  socket_name: mosaic-fleet
  holder_session: _holder
defaults:
  working_directory: ~/src
  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: ~/src
    persistent_persona: false
    reset_between_tasks: true
    lifecycle:
      enabled: true
      desired_state: stopped
    launch:
      yolo: true
`;

let semanticTmp: string | undefined;

afterEach(async (): Promise<void> => {
  if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true });
  semanticTmp = undefined;
});

async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> {
  semanticTmp = await mkdtemp(join(tmpdir(), 'roster-v2-semantics-'));
  const rolesDir = join(semanticTmp, 'roles');
  const overrideDir = join(semanticTmp, 'roles.local');
  await mkdir(rolesDir, { recursive: true });
  await mkdir(overrideDir, { recursive: true });
  for (const klass of [
    'code',
    'review',
    'interaction',
    'orchestrator',
    'merge-gate',
    'validator',
    'team-leader',
  ]) {
    await writeFile(join(rolesDir, `${klass}.md`), `# ${klass}\n\n(\`class: ${klass}\`)\n`, 'utf8');
  }
  return { rolesDir, overrideDir };
}

function rosterWithClass(klass: string, toolPolicy = klass): string {
  return validRoster
    .replace('class: code', `class: ${klass}`)
    .replace('tool_policy: code', `tool_policy: ${toolPolicy}`);
}

describe('roster v2 semantic validation', (): void => {
  it.each([
    ['implementer', 'code'],
    ['reviewer', 'review'],
    ['operator-interaction', 'interaction'],
  ])(
    'canonicalizes requested alias %s while retaining requested and canonical class',
    async (requested: string, canonical: string) => {
      const dirs = await semanticDirs();
      const roster = parseRosterV2(rosterWithClass(requested, requested), 'yaml');
      const validated = await validateRosterV2Semantics(roster, dirs);
      expect(validated.agents[0]).toMatchObject({
        requestedClass: requested,
        canonicalClass: canonical,
        canonicalToolPolicy: canonical,
      });
    },
  );

  it.each(['worker', 'analyst', 'canary'])(
    'rejects %s when no genuine custom role exists',
    async (klass: string) => {
      const dirs = await semanticDirs();
      await expect(
        validateRosterV2Semantics(parseRosterV2(rosterWithClass(klass), 'yaml'), dirs),
      ).rejects.toThrow(/unresolved|readable persona/i);
    },
  );

  it('accepts a genuine custom roles.local class without protected authority', async () => {
    const dirs = await semanticDirs();
    await writeFile(join(dirs.overrideDir, 'worker.md'), '# worker\n\n(`class: worker`)\n', 'utf8');
    const validated = await validateRosterV2Semantics(
      parseRosterV2(rosterWithClass('worker'), 'yaml'),
      dirs,
    );
    expect(validated.agents[0]?.authority).toMatchObject({
      mayMerge: false,
      mayOrchestrate: false,
    });
  });

  it('rejects a LIBRARY-only class with no readable resolved persona', async () => {
    const dirs = await semanticDirs();
    await writeFile(
      join(dirs.rolesDir, 'LIBRARY.md'),
      '| Persona | Purpose |\n| --- | --- |\n| phantom | Missing |\n',
      'utf8',
    );
    await expect(
      validateRosterV2Semantics(parseRosterV2(rosterWithClass('phantom'), 'yaml'), dirs),
    ).rejects.toThrow(/readable persona/i);
  });

  it('rejects an unreadable resolved persona', async () => {
    const dirs = await semanticDirs();
    await mkdir(join(dirs.overrideDir, 'worker.md'));
    await expect(
      validateRosterV2Semantics(parseRosterV2(rosterWithClass('worker'), 'yaml'), dirs),
    ).rejects.toThrow(/readable persona/i);
  });

  it.each([
    ['merge-gate', 'code'],
    ['validator', 'merge-gate'],
    ['orchestrator', 'interaction'],
    ['team-leader', 'orchestrator'],
    ['interaction', 'orchestrator'],
    ['code', 'merge-gate'],
    ['worker', 'validator'],
  ])(
    'denies protected class/tool-policy mismatch %s with %s',
    async (klass: string, toolPolicy: string) => {
      const dirs = await semanticDirs();
      if (klass === 'worker') {
        await writeFile(
          join(dirs.overrideDir, 'worker.md'),
          '# worker\n\n(`class: worker`)\n',
          'utf8',
        );
      }
      await expect(
        validateRosterV2Semantics(parseRosterV2(rosterWithClass(klass, toolPolicy), 'yaml'), dirs),
      ).rejects.toThrow(/tool policy.*must match|mismatch/i);
    },
  );
});

describe('roster v2 structural compiler', (): void => {
  it('parses YAML into a normalized typed model and renders canonical YAML', (): void => {
    const roster = parseRosterV2(validRoster, 'yaml');

    expect(roster).toEqual({
      version: 2,
      generation: 7,
      transport: 'tmux',
      tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
      defaults: { workingDirectory: '~/src', runtime: 'pi' },
      runtimes: { pi: { resetCommand: '/new' } },
      agents: [
        {
          name: 'coder0',
          alias: 'Coder 0',
          className: 'code',
          runtime: 'pi',
          provider: 'openai',
          model: 'gpt-5.6-sol',
          reasoning: 'high',
          toolPolicy: 'code',
          workingDirectory: '~/src',
          persistentPersona: false,
          resetBetweenTasks: true,
          lifecycle: { enabled: true, desiredState: 'stopped' },
          launch: { yolo: true },
        },
      ],
    });
    expect(renderRosterV2Yaml(roster)).toBe(validRoster.trimStart());
  });

  it('parses JSON and produces the same normalized model', (): void => {
    const yaml = parseRosterV2(validRoster, 'yaml');
    const json = JSON.stringify({
      version: 2,
      generation: 7,
      transport: 'tmux',
      tmux: { socket_name: 'mosaic-fleet', holder_session: '_holder' },
      defaults: { working_directory: '~/src', 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: '~/src',
          persistent_persona: false,
          reset_between_tasks: true,
          lifecycle: { enabled: true, desired_state: 'stopped' },
          launch: { yolo: true },
        },
      ],
    });

    expect(parseRosterV2(json, 'json')).toEqual(yaml);
  });

  it('sorts runtime and agent maps in the deterministic renderer', (): void => {
    const roster = parseRosterV2(
      validRoster
        .replace(
          'runtimes:\n  pi:\n    reset_command: /new',
          'runtimes:\n  pi:\n    reset_command: /new\n  codex:\n    reset_command: /clear',
        )
        .replace(
          'agents:\n  - name: coder0',
          'agents:\n  - name: reviewer\n    alias: Reviewer\n    class: review\n    runtime: pi\n    provider: openai\n    model: gpt-5.6-sol\n    reasoning: medium\n    tool_policy: review\n    working_directory: ~/src\n    persistent_persona: false\n    reset_between_tasks: true\n    lifecycle:\n      enabled: true\n      desired_state: stopped\n    launch:\n      yolo: true\n  - name: coder0',
        ),
      'yaml',
    );

    const rendered = renderRosterV2Yaml(roster);
    expect(rendered.indexOf('  codex:')).toBeLessThan(rendered.indexOf('  pi:'));
    expect(rendered.indexOf('  - name: coder0')).toBeLessThan(
      rendered.indexOf('  - name: reviewer'),
    );
  });

  it.each([
    ['v1 document', validRoster.replace('version: 2', 'version: 1'), /v1.*existing v1 path/i],
    [
      'unknown connector',
      `${validRoster}\nconnector:\n  kind: matrix\n`,
      /unsupported field.*connector/i,
    ],
    [
      'remote host',
      validRoster.replace(
        'runtime: pi\n    provider',
        'runtime: pi\n    host: remote\n    provider',
      ),
      /unsupported field.*host/i,
    ],
    [
      'secret reference',
      validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n    secret_ref: vault://x'),
      /unsupported field.*secret_ref/i,
    ],
    [
      'channel override',
      validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n    channels: discord'),
      /unsupported field.*channels/i,
    ],
    [
      'arbitrary command',
      validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n    command: whoami'),
      /unsupported field.*command/i,
    ],
    [
      'gateway field',
      `${validRoster}\ngateway:\n  url: https://gateway.example\n`,
      /unsupported field.*gateway/i,
    ],
    [
      'missing required field',
      validRoster.replace('    model: gpt-5.6-sol\n', ''),
      /model.*required/i,
    ],
    [
      'invalid type',
      validRoster.replace('generation: 7', 'generation: seven'),
      /generation.*integer/i,
    ],
    [
      'unsafe generation',
      validRoster.replace('generation: 7', 'generation: 9007199254740992'),
      /generation.*integer/i,
    ],
    [
      'duplicate names',
      validRoster.replace(
        '  - name: coder0',
        '  - name: coder0\n    alias: Duplicate\n    class: code\n    runtime: pi\n    provider: openai\n    model: gpt-5.6-sol\n    reasoning: high\n    tool_policy: code\n    working_directory: ~/src\n    persistent_persona: false\n    reset_between_tasks: true\n    lifecycle:\n      enabled: true\n      desired_state: stopped\n    launch:\n      yolo: true\n  - name: coder0',
      ),
      /duplicate agent name/i,
    ],
    ['invalid name', validRoster.replace('name: coder0', 'name: ../coder0'), /invalid agent name/i],
    [
      'invalid transport',
      validRoster.replace('transport: tmux', 'transport: matrix'),
      /transport.*tmux/i,
    ],
    [
      'invalid runtime',
      validRoster.replace('runtime: pi', 'runtime: matrix'),
      /runtime.*supported/i,
    ],
    [
      'invalid reasoning',
      validRoster.replace('reasoning: high', 'reasoning: extreme'),
      /reasoning.*low.*medium.*high/i,
    ],
    [
      'ambiguous socket',
      validRoster.replace('socket_name: mosaic-fleet', 'socket_name: default/socket'),
      /socket_name/i,
    ],
    [
      'agent socket override',
      validRoster.replace(
        'runtime: pi\n    provider',
        'runtime: pi\n    socket: another\n    provider',
      ),
      /unsupported field.*socket/i,
    ],
  ])('rejects %s', (_name: string, source: string, expected: RegExp): void => {
    expect((): void => {
      parseRosterV2(source, 'yaml');
    }).toThrow(expected);
  });

  it('rejects malformed JSON as a validation error', (): void => {
    expect((): void => {
      parseRosterV2('{', 'json');
    }).toThrow(RosterV2ValidationError);
  });

  it('declares supported runtime map keys in the executable schema', (): void => {
    expect(ROSTER_V2_JSON_SCHEMA).toMatchObject({
      properties: {
        runtimes: { propertyNames: { enum: ['claude', 'codex', 'opencode', 'pi'] } },
      },
    });
  });

  it('keeps the checked-in documentation schema structurally identical to the executable schema', async (): Promise<void> => {
    const schemaPath = fileURLToPath(
      new URL('../../../../docs/fleet/reference/roster-v2.schema.json', import.meta.url),
    );
    const documented = await readFile(schemaPath, 'utf8');

    expect(JSON.parse(documented) as unknown).toEqual(ROSTER_V2_JSON_SCHEMA);
  });
});
