import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync } from 'fs';
import { join } from 'path';
import { loadPiece, validatePieceDef, type PieceDef } from './piece-runner.js';
import { getToolDefs } from './tools/index.js';

/**
 * Structural / contract test suite for every built-in piece in `pieces/*.yaml`.
 *
 * The existing `piece-runner.test.ts` "all-load sweep" only asserts
 * `loadPiece(name)` does not throw — i.e. the SCHEMA parses. That catches
 * schema errors but NOT semantic ones: a typo'd `rules[].next`, a dropped
 * movement, an orphan movement, or a dead `allowed_tools` entry all parse
 * fine and only blow up at runtime ("Movement not found") or silently never
 * offer a tool to the LLM. This suite closes those gaps using the REAL
 * loaders (`loadPiece` / `validatePieceDef` / `getToolDefs`), not a
 * reimplemented parser.
 *
 * Gaps covered (see scratchpad inventory 03-pieces.md "Top gaps" #2 and #4):
 *   - transition-graph reachability + dangling-next detection
 *   - allowed_tools ↔ real tool registry consistency
 *   - COMPLETE/ABORT/ASK terminal-next rejection (Phase 6b invariant)
 */

const PIECES_DIR = 'pieces';

/** Engine-internal sentinels that are valid `next` values but are NOT movement names. */
const TRANSITION_SENTINELS: ReadonlySet<string> = new Set(['WAIT_SUBTASKS']);
/** Reserved terminal values: only allowed in `default_next`, never in `rules[].next`. */
const TERMINAL_NEXT_VALUES: ReadonlySet<string> = new Set(['COMPLETE', 'ABORT', 'ASK']);

/** Discover every built-in piece file, excluding the SCHEMA doc. */
function discoverPieceNames(): string[] {
  return readdirSync(PIECES_DIR)
    .filter((f) => f.endsWith('.yaml'))
    .map((f) => f.replace(/\.yaml$/, ''))
    .sort();
}

const PIECE_NAMES = discoverPieceNames();

/**
 * Dynamic MCP tools are addressed by the documented `mcp__*` wildcard and
 * `mcp__<server>__<tool>` prefix (see pieces/SCHEMA.md and the `mcp__` branch
 * in tools/index.ts). They are resolved at runtime against the per-job MCP
 * aggregator, so they legitimately do NOT appear in the static tool registry.
 */
function isDynamicMcpName(name: string): boolean {
  return name === 'mcp__*' || name.startsWith('mcp__');
}

describe('pieces contract — discovery', () => {
  it('finds the built-in pieces and excludes SCHEMA.md', () => {
    expect(PIECE_NAMES.length).toBeGreaterThan(0);
    expect(PIECE_NAMES).not.toContain('SCHEMA');
  });
});

describe.each(PIECE_NAMES)('piece "%s"', (pieceName) => {
  let piece: PieceDef;

  it('loads + validates without throwing (real loadPiece)', () => {
    expect(() => {
      piece = loadPiece(pieceName);
    }).not.toThrow();
    expect(piece).toBeTruthy();
    expect(Array.isArray(piece.movements)).toBe(true);
    expect(piece.movements.length).toBeGreaterThan(0);
    expect(typeof piece.initial_movement).toBe('string');
    expect(typeof piece.max_movements).toBe('number');
    expect(piece.max_movements).toBeGreaterThan(0);
  });

  it('initial_movement references an existing movement', () => {
    const p = loadPiece(pieceName);
    const moveNames = new Set(p.movements.map((m) => m.name));
    expect(moveNames.has(p.initial_movement)).toBe(true);
  });

  it('every rules[].next references an existing movement or a sentinel (no dangling next)', () => {
    const p = loadPiece(pieceName);
    const moveNames = new Set(p.movements.map((m) => m.name));
    const dangling: string[] = [];
    for (const m of p.movements) {
      for (const rule of m.rules || []) {
        const next = rule.next;
        if (TRANSITION_SENTINELS.has(next)) continue;
        // Terminal values are illegal in rules[].next — asserted separately.
        if (TERMINAL_NEXT_VALUES.has(next)) continue;
        if (!moveNames.has(next)) {
          dangling.push(`movement="${m.name}" rule.next="${next}"`);
        }
      }
    }
    expect(dangling, `dangling next targets in piece "${pieceName}"`).toEqual([]);
  });

  it('default_next (when set) is a movement, a sentinel, or a terminal value', () => {
    const p = loadPiece(pieceName);
    const moveNames = new Set(p.movements.map((m) => m.name));
    const bad: string[] = [];
    for (const m of p.movements) {
      const dn = m.default_next;
      if (dn === undefined) continue;
      if (TRANSITION_SENTINELS.has(dn) || TERMINAL_NEXT_VALUES.has(dn) || moveNames.has(dn)) continue;
      bad.push(`movement="${m.name}" default_next="${dn}"`);
    }
    expect(bad, `invalid default_next in piece "${pieceName}"`).toEqual([]);
  });

  it('has no orphan movements (every non-initial movement is reachable from a rule)', () => {
    const p = loadPiece(pieceName);
    const moveNames = new Set(p.movements.map((m) => m.name));
    const reachable = new Set<string>([p.initial_movement]);
    let changed = true;
    while (changed) {
      changed = false;
      for (const m of p.movements) {
        if (!reachable.has(m.name)) continue;
        for (const rule of m.rules || []) {
          if (moveNames.has(rule.next) && !reachable.has(rule.next)) {
            reachable.add(rule.next);
            changed = true;
          }
        }
        // default_next can also be a real movement and thus a reachability edge.
        if (m.default_next && moveNames.has(m.default_next) && !reachable.has(m.default_next)) {
          reachable.add(m.default_next);
          changed = true;
        }
      }
    }
    const orphans = p.movements.map((m) => m.name).filter((n) => !reachable.has(n));
    // NOTE: piece-runner does NOT reject orphans at load time — it would only
    // surface as a never-visited movement at runtime. All 16 shipped pieces
    // are currently orphan-free, so we assert the strong invariant. If a real
    // orphan is ever introduced, this fires (the engine treats it as a latent
    // bug, not a hard load error) — relax to a `// GAP:` documented expectation
    // only if the orphan is intentional.
    expect(orphans, `orphan (unreachable) movements in piece "${pieceName}"`).toEqual([]);
  });
});

describe('validatePieceDef — terminal next rejection (Phase 6b invariant)', () => {
  function basePiece(): PieceDef {
    return {
      name: 'bad-piece',
      description: 'in-memory fixture',
      max_movements: 10,
      initial_movement: 'execute',
      movements: [
        {
          name: 'execute',
          edit: false,
          persona: 'worker',
          instruction: 'do work',
          allowed_tools: ['Read'],
          rules: [{ condition: 'done', next: 'verify' }],
        },
        {
          name: 'verify',
          edit: false,
          persona: 'reviewer',
          instruction: 'verify',
          allowed_tools: ['Read'],
          rules: [],
          default_next: 'COMPLETE',
        },
      ],
    };
  }

  it.each(['COMPLETE', 'ABORT', 'ASK'])(
    'rejects a piece with rules[].next = %s',
    (terminal) => {
      const piece = basePiece();
      piece.movements[0]!.rules = [{ condition: 'done', next: terminal }];
      expect(() => validatePieceDef(piece)).toThrow(/reserved terminal next/i);
    },
  );

  it('accepts default_next = COMPLETE (engine-internal sentinel) in rules-free movement', () => {
    expect(() => validatePieceDef(basePiece())).not.toThrow();
  });

  it('accepts movement-to-movement and WAIT_SUBTASKS as rules[].next', () => {
    const piece = basePiece();
    piece.movements[0]!.rules = [
      { condition: 'a', next: 'verify' },
      { condition: 'b', next: 'WAIT_SUBTASKS' },
    ];
    expect(() => validatePieceDef(piece)).not.toThrow();
  });
});

/**
 * Codex follow-up #3: runtime validation (`validateCompleteArgs` in
 * terminal-control.ts) now rejects `complete({status:"needs_user_input"})` unless
 * BOTH `missing_info` and `why_no_default` are present. A shipped piece example
 * that demonstrates the old `missing_info`-only shape would teach the model the
 * rejected form and burn iterations on exactly the safety-park prompts that must
 * reach the user. Keep every concrete example in sync with the validator.
 */
describe('pieces contract — needs_user_input examples document why_no_default (Codex #3)', () => {
  for (const name of PIECE_NAMES) {
    it(`${name}: every concrete needs_user_input example also supplies why_no_default`, () => {
      const text = readFileSync(join(PIECES_DIR, `${name}.yaml`), 'utf8');
      // A "concrete example" is a line that pairs needs_user_input with missing_info
      // (i.e. a real complete(...) call snippet), not prose that merely mentions the
      // status. Such a line must also carry why_no_default.
      const offenders = text
        .split('\n')
        .filter((line) => line.includes('needs_user_input') && line.includes('missing_info') && !line.includes('why_no_default'));
      expect(offenders).toEqual([]);
    });
  }
});
