import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
  listPersonaClasses,
  loadProfile,
  loadProfiles,
  parseProfile,
  validateProfile,
  type FleetProfile,
} from './fleet-profiles.js';

// The real, committed library: packages/mosaic/src/commands -> framework/fleet.
const frameworkFleet = resolve(
  dirname(fileURLToPath(import.meta.url)),
  '..',
  '..',
  'framework',
  'fleet',
);
const rolesDir = join(frameworkFleet, 'roles');
const profilesDir = join(frameworkFleet, 'profiles');

const realLib = { rolesDir, profilesDir };

const EXPECTED_IDS = [
  'business',
  'marketing',
  'personal-assistant',
  'research',
  'software-delivery',
];

describe('listPersonaClasses (real role library)', () => {
  it('extracts inline `class:` markers from the role contracts', async () => {
    const classes = await listPersonaClasses(rolesDir);
    // Personas that carry an inline `class: X` marker.
    expect(classes.has('code')).toBe(true);
    expect(classes.has('marketing-lead')).toBe(true);
    expect(classes.has('ceo')).toBe(true);
    // support-agent's marker wraps across a newline — must still resolve.
    expect(classes.has('support-agent')).toBe(true);
  });

  it('covers marker-less engineering personas via filename + LIBRARY index', async () => {
    const classes = await listPersonaClasses(rolesDir);
    // planner/decomposition have a role file but no inline marker — they resolve
    // from the filename + LIBRARY.md row.
    expect(classes.has('planner')).toBe(true);
    expect(classes.has('decomposition')).toBe(true);
    // The dedicated orchestrator persona resolves (inline marker + filename + row).
    expect(classes.has('orchestrator')).toBe(true);
  });

  it('returns an empty set for a missing roles dir (graceful)', async () => {
    const classes = await listPersonaClasses(join(tmpdir(), 'definitely-missing-roles-xyz'));
    expect(classes.size).toBe(0);
  });
});

describe('baseline profiles (real library)', () => {
  it('loads exactly the five baseline profiles, sorted by id', async () => {
    const profiles = await loadProfiles(realLib);
    expect(profiles.map((p) => p.id)).toEqual(EXPECTED_IDS);
  });

  it('every referenced class resolves against the real role library (drift guard)', async () => {
    // This is the key test: it fails if a profile drifts from the persona library.
    const profiles = await loadProfiles(realLib);
    const validClasses = await listPersonaClasses(rolesDir);
    for (const profile of profiles) {
      expect(validateProfile(profile, validClasses)).toEqual([]);
    }
  });

  it('software-delivery has the expected lead, floor, and roster shape', async () => {
    const profile = await loadProfile('software-delivery', realLib);
    expect(profile.lead).toBe('orchestrator');
    expect(profile.floor).toEqual(['orchestrator', 'enhancer']);
    const code = profile.roster.find((r) => r.class === 'code');
    expect(code?.multiplicity).toBe(2);
    expect(code?.reportsTo).toBe('decomposition');
    // The dedicated orchestrator is the lead seat (no reports_to); the planner is
    // now a distinct seat that reports to it.
    const orchestrator = profile.roster.find((r) => r.class === 'orchestrator');
    expect(orchestrator?.reportsTo).toBeUndefined();
    const planner = profile.roster.find((r) => r.class === 'planner');
    expect(planner?.reportsTo).toBe('orchestrator');
  });

  it('loadProfile throws on an unknown id', async () => {
    await expect(loadProfile('does-not-exist', realLib)).rejects.toThrow(/Unknown profile/);
  });
});

describe('parseProfile', () => {
  it('defaults multiplicity to 1 and omits reports_to for the lead', () => {
    const yaml = [
      'id: x',
      'title: X',
      'description: a system',
      'lead: ceo',
      'floor: [ceo]',
      'roster:',
      '  - class: ceo',
      '  - class: code',
      '    reports_to: ceo',
      '    multiplicity: 3',
      '',
    ].join('\n');
    const profile = parseProfile(yaml);
    expect(profile.roster[0]).toEqual({ class: 'ceo', multiplicity: 1 });
    expect(profile.roster[1]).toEqual({ class: 'code', reportsTo: 'ceo', multiplicity: 3 });
  });

  it('rejects a profile whose id mismatches its filename', () => {
    expect(() =>
      parseProfile(
        'id: other\ntitle: T\ndescription: d\nlead: ceo\nroster: [{class: ceo}]\n',
        'expected',
      ),
    ).toThrow(/does not match its filename/);
  });

  it('rejects a non-integer multiplicity', () => {
    const yaml =
      'id: x\ntitle: T\ndescription: d\nlead: ceo\nroster:\n  - class: ceo\n    multiplicity: 1.5\n';
    expect(() => parseProfile(yaml)).toThrow(/multiplicity/);
  });
});

describe('validateProfile', () => {
  const valid = new Set(['ceo', 'coo', 'code']);

  it('passes a well-formed profile', () => {
    const profile: FleetProfile = {
      id: 'x',
      title: 'X',
      description: 'd',
      lead: 'ceo',
      floor: ['ceo'],
      roster: [
        { class: 'ceo', multiplicity: 1 },
        { class: 'coo', reportsTo: 'ceo', multiplicity: 1 },
      ],
    };
    expect(validateProfile(profile, valid)).toEqual([]);
  });

  it('rejects an unknown roster class', () => {
    const profile: FleetProfile = {
      id: 'x',
      title: 'X',
      description: 'd',
      lead: 'ceo',
      floor: [],
      roster: [{ class: 'not-a-real-persona', multiplicity: 1 }],
    };
    const problems = validateProfile(profile, valid);
    expect(problems.some((p) => /not-a-real-persona.*not a known persona class/.test(p))).toBe(
      true,
    );
  });

  it('rejects a reports_to that names a class absent from the roster', () => {
    const profile: FleetProfile = {
      id: 'x',
      title: 'X',
      description: 'd',
      lead: 'ceo',
      floor: [],
      roster: [{ class: 'code', reportsTo: 'coo', multiplicity: 1 }], // coo valid but not in roster
    };
    const problems = validateProfile(profile, valid);
    expect(problems.some((p) => /reports_to.*not present in this roster/.test(p))).toBe(true);
  });

  it('rejects a reports_to that is not a known persona class at all', () => {
    const profile: FleetProfile = {
      id: 'x',
      title: 'X',
      description: 'd',
      lead: 'ceo',
      floor: [],
      roster: [
        { class: 'ceo', multiplicity: 1 },
        { class: 'code', reportsTo: 'ghost', multiplicity: 1 },
      ],
    };
    const problems = validateProfile(profile, valid);
    expect(problems.some((p) => /ghost.*not a known persona class/.test(p))).toBe(true);
  });
});

describe('loadProfiles with a temp override dir', () => {
  let dir: string;
  beforeEach(async () => {
    dir = await mkdtemp(join(tmpdir(), 'mosaic-profiles-'));
  });
  afterEach(async () => {
    await rm(dir, { recursive: true, force: true });
  });

  it('throws when a profile references an unknown class (validated against real roles)', async () => {
    await writeFile(
      join(dir, 'bad.yaml'),
      'id: bad\ntitle: Bad\ndescription: d\nlead: nope-not-real\nroster:\n  - class: nope-not-real\n',
    );
    await expect(loadProfiles({ profilesDir: dir, rolesDir })).rejects.toThrow(
      /is invalid|not a known persona class/,
    );
  });

  it('throws on duplicate profile ids across files', async () => {
    const body = 'title: Dup\ndescription: d\nlead: ceo\nroster:\n  - class: ceo\n';
    // Same declared id in two differently-named files -> id mismatches filename
    // first; use matching filenames+id to force the duplicate-id path instead.
    await writeFile(join(dir, 'dup.yaml'), `id: dup\n${body}`);
    await writeFile(join(dir, 'dup.yml'), `id: dup\n${body}`);
    await expect(loadProfiles({ profilesDir: dir, rolesDir })).rejects.toThrow(
      /Duplicate profile id/,
    );
  });
});
