import { describe, expect, it } from 'vitest';
import type { FeatureNode, FeatureSpec } from '../src/core/types';
import {
  boundaryViolations,
  depthOf,
  dependentsOf,
  extractCases,
  isSpecCollapse,
  mapLimit,
  mergeStubsByPath,
  offendingFeatureIds,
  preSanitizeSpec,
  topoOrder,
  validateSpec,
  validateTestFile,
} from '../src/core/pipeline';

function feature(partial: Partial<FeatureNode> & { id: string }): FeatureNode {
  const base: FeatureNode = {
    name: partial.id,
    description: 'x',
    parentId: null,
    deps: [],
    files: [`src/${partial.id.toLowerCase()}.ts`],
    testFile: `tests/${partial.id.toLowerCase()}.test.ts`,
    edgeCases: ['e1'],
    exports: [],
    envVars: [],
    stubs: [],
    ...partial,
  };
  if (!partial.stubs) base.stubs = base.files.map(p => ({ path: p, content: '' }));
  return base;
}

describe('feature tree', () => {
  it('topo-orders dependencies first', () => {
    const f = [feature({ id: 'C', deps: ['A', 'B'] }), feature({ id: 'A' }), feature({ id: 'B', deps: ['A'] })];
    expect(topoOrder(f).map(x => x.id)).toEqual(['A', 'B', 'C']);
  });

  it('rejects cycles', () => {
    const f = [feature({ id: 'A', deps: ['B'] }), feature({ id: 'B', deps: ['A'] })];
    expect(() => topoOrder(f)).toThrow(/cycle/);
  });

  it('computes dependency depth (smallest-node ordering for RHA)', () => {
    const f = [feature({ id: 'A' }), feature({ id: 'B', deps: ['A'] }), feature({ id: 'C', deps: ['B'] })];
    const d = depthOf(f);
    expect(d.get('A')).toBe(0);
    expect(d.get('C')).toBe(2);
  });

  it('finds dependents (the parent-recheck set)', () => {
    const f = [feature({ id: 'A' }), feature({ id: 'B', deps: ['A'] }), feature({ id: 'C', deps: ['A'] })];
    expect(dependentsOf('A', f).map(x => x.id).sort()).toEqual(['B', 'C']);
  });
});

describe('mapLimit (fan-out with bounded concurrency)', () => {
  it('preserves input order regardless of completion order', async () => {
    const out = await mapLimit([30, 5, 20, 1], 4, async ms => {
      await new Promise(r => setTimeout(r, ms));
      return ms;
    });
    expect(out).toEqual([30, 5, 20, 1]);
  });

  it('never exceeds the concurrency cap', async () => {
    let inFlight = 0;
    let peak = 0;
    await mapLimit(Array.from({ length: 9 }, (_, i) => i), 3, async () => {
      inFlight++;
      peak = Math.max(peak, inFlight);
      await new Promise(r => setTimeout(r, 10));
      inFlight--;
    });
    expect(peak).toBeLessThanOrEqual(3);
    expect(peak).toBeGreaterThan(1); // it did actually overlap
  });

  it('propagates task failures', async () => {
    await expect(
      mapLimit([1, 2, 3], 2, async n => {
        if (n === 2) throw new Error('boom');
        return n;
      }),
    ).rejects.toThrow('boom');
  });
});

describe('spec validation', () => {
  it('accepts a well-formed spec', () => {
    const spec: FeatureSpec = {
      title: 't', prompt: 'p',
      features: [feature({ id: 'A' }), feature({ id: 'B', deps: ['A'] })],
    };
    expect(validateSpec(spec)).toEqual([]);
  });

  it('rejects overlapping ownership and unknown deps', () => {
    const spec: FeatureSpec = {
      title: 't', prompt: 'p',
      features: [
        feature({ id: 'A', files: ['src/x.ts'] }),
        feature({ id: 'B', files: ['src/x.ts'], deps: ['ZZ'] }),
      ],
    };
    const problems = validateSpec(spec);
    expect(problems.some(p => p.includes('owned by both'))).toBe(true);
    expect(problems.some(p => p.includes('unknown ZZ'))).toBe(true);
  });

  it('flags duplicate stub paths on one feature (T2)', () => {
    const f = feature({
      id: 'F2',
      files: ['src/revision_manager.ts'],
      stubs: [
        { path: 'src/revision_manager.ts', content: 'export interface Revision {}' },
        { path: 'src/revision_manager.ts', content: 'export class RevisionManager {}' },
      ],
    });
    const problems = validateSpec({ title: 't', prompt: 'p', features: [f] });
    expect(problems.some(p => p.includes('duplicate stub for src/revision_manager.ts'))).toBe(true);
  });

  it('mergeStubsByPath collapses duplicates and keeps both declarations (T2)', () => {
    const f = feature({
      id: 'F2',
      files: ['src/revision_manager.ts'],
      stubs: [
        { path: 'src/revision_manager.ts', content: 'export interface Revision {}\n' },
        { path: 'src/revision_manager.ts', content: 'export class RevisionManager {}' },
      ],
    });
    f.stubs = mergeStubsByPath(f.stubs);
    expect(f.stubs).toHaveLength(1);
    expect(f.stubs[0].content).toContain('export interface Revision {}');
    expect(f.stubs[0].content).toContain('export class RevisionManager {}');
    expect(validateSpec({ title: 't', prompt: 'p', features: [f] }).some(p => p.includes('duplicate stub'))).toBe(false);
  });
});

describe('write-boundary enforcement', () => {
  const f = feature({ id: 'F1', files: ['src/codec.ts'] });

  it('accepts files inside the ownership set', () => {
    expect(boundaryViolations([{ path: 'src/codec.ts', content: '' }], f)).toEqual([]);
  });

  it('rejects writes to the frozen IR', () => {
    const v = boundaryViolations([{ path: 'tests/f1-codec.test.ts', content: '' }], f);
    expect(v[0]).toContain('frozen IR');
  });

  it('rejects writes outside the node boundary', () => {
    const v = boundaryViolations([{ path: 'src/other.ts', content: '' }], f);
    expect(v[0]).toContain('outside the ownership boundary');
  });
});

describe('IR gate', () => {
  it('accepts a syntactically valid vitest file', () => {
    const ok = `import { describe, it, expect } from 'vitest';\ndescribe('x', () => { it('works', () => { expect(1).toBe(1); }); });`;
    expect(validateTestFile(ok)).toEqual([]);
  });

  it('rejects files without cases or with syntax errors', () => {
    expect(validateTestFile(`import { describe } from 'vitest'; describe('x', () => {});`).length).toBeGreaterThan(0);
    expect(validateTestFile(`import { it } from 'vitest'; it('x', ( => {});`).some(p => p.startsWith('syntax'))).toBe(true);
  });

  it('extracts and classifies test cases', () => {
    const src = `it('does a thing', () => {}); it('edge: boundary', () => {}); it('integration: wired', () => {});`;
    const cases = extractCases('F1', src);
    expect(cases.map(c => c.kind)).toEqual(['behavior', 'edge', 'integration']);
  });
});

describe('Part P — spec gate collapse / pre-sanitize', () => {
  it('preSanitizeSpec strips tests/ from files, normalizes testFile, fills missing stubs', () => {
    const spec: FeatureSpec = {
      title: 't',
      prompt: 'p',
      features: [feature({
        id: 'F1',
        files: ['src/a.ts', 'tests/f1.test.ts', 'src/a.ts'],
        testFile: 'f1-wrong.ts',
        stubs: [],
      })],
    };
    preSanitizeSpec(spec);
    expect(spec.features[0].files).toEqual(['src/a.ts']);
    expect(spec.features[0].testFile.startsWith('tests/')).toBe(true);
    expect(spec.features[0].stubs.map(s => s.path)).toEqual(['src/a.ts']);
    expect(validateSpec(spec).filter(p => p.includes('no interface stub'))).toEqual([]);
  });

  it('preSanitizeSpec rewrites bare dep tokens onto unique feature ids', () => {
    const spec: FeatureSpec = {
      title: 't',
      prompt: 'p',
      features: [
        feature({ id: 'F2_shell', name: 'shell', files: ['src/shell.ts'] }),
        feature({ id: 'F10_render', name: 'render', files: ['src/render.ts'] }),
        feature({
          id: 'F40_x',
          name: 'x',
          deps: ['F2', 'F10'],
          files: ['src/x.ts'],
        }),
      ],
    };
    preSanitizeSpec(spec);
    const f40 = spec.features.find(f => f.id === 'F40_x')!;
    expect(f40.deps).toEqual(['F2_shell', 'F10_render']);
    expect(validateSpec(spec).filter(p => /depends on unknown/.test(p))).toEqual([]);
  });

  it('isSpecCollapse rejects any shrink below the floor', () => {
    expect(isSpecCollapse(40, 1)).toBe(true);
    expect(isSpecCollapse(40, 40)).toBe(false);
    expect(isSpecCollapse(4, 4)).toBe(false);
  });

  it('offendingFeatureIds maps problem strings to feature ids', () => {
    expect(offendingFeatureIds(
      ['F2: no edge cases', 'file src/x.ts owned by both F1 and F3', 'F4 depends on unknown F9'],
      ['F1', 'F2', 'F3', 'F4'],
    ).sort()).toEqual(['F1', 'F2', 'F3', 'F4']);
  });
});
