import { describe, it, expect, afterEach } from 'vitest';
import {
  mkdtempSync,
  mkdirSync,
  writeFileSync,
  readFileSync,
  rmSync,
  chmodSync,
  existsSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const SCRIPT = resolve(
  __dirname,
  '../../../understand-anything-plugin/skills/understand/scan-project.mjs',
);

/**
 * Build a project tree from a `{ relPath: contents }` object. Creates parent
 * directories as needed. Initializes a real git repo so the script's preferred
 * `git ls-files` enumeration path is exercised — tests that need the walker
 * fallback can set `gitInit=false`.
 */
function setupTree(files, { gitInit = true } = {}) {
  const root = mkdtempSync(join(tmpdir(), 'ua-scan-test-'));
  for (const [relPath, contents] of Object.entries(files)) {
    const abs = join(root, relPath);
    mkdirSync(dirname(abs), { recursive: true });
    writeFileSync(abs, contents, 'utf-8');
  }
  if (gitInit) {
    // `git ls-files -co --exclude-standard` returns BOTH cached and others
    // (modulo gitignore), so an `add` is unnecessary for our tests — the
    // bare repo init is enough for ls-files to enumerate.
    const init = spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf-8' });
    if (init.status !== 0) {
      // CI without git: continue without it; the walker fallback will fire.
    }
  }
  return root;
}

/**
 * Tracks every temp output dir created by runScript() so the global
 * cleanup can sweep them between tests. The output file must live
 * OUTSIDE projectRoot because the project's default ignore patterns
 * do NOT exclude `.understand-anything/` (the dir is reserved for
 * persistent state, not transient scratch). If we wrote inside
 * projectRoot, the second call in the determinism test would
 * enumerate the first call's output file and produce drift.
 */
const _runScriptOutputDirs = [];

/**
 * Run scan-project.mjs against `projectRoot`. Returns
 * { status, stdout, stderr, output } where `output` is the parsed JSON
 * written by the script (or null on failure).
 */
function runScript(projectRoot) {
  const outputDir = mkdtempSync(join(tmpdir(), 'ua-scan-out-'));
  _runScriptOutputDirs.push(outputDir);
  const outputPath = join(outputDir, 'scan-output.json');
  const result = spawnSync('node', [SCRIPT, projectRoot, outputPath], {
    encoding: 'utf-8',
  });
  let output = null;
  try {
    output = JSON.parse(readFileSync(outputPath, 'utf-8'));
  } catch {
    /* output missing on hard failure */
  }
  return { status: result.status, stdout: result.stdout, stderr: result.stderr, output };
}

/**
 * Look up the `files[]` entry for a given path. Returns undefined if not
 * present — callers should `expect(byPath('x')).toBeDefined()` first.
 */
function byPath(output, path) {
  return output.files.find(f => f.path === path);
}

// Sweep every output dir created during a test back to disk-empty between
// tests. The top-level afterEach fires after each `it()` regardless of which
// describe block it lives in, so a single hook covers the whole file.
afterEach(() => {
  while (_runScriptOutputDirs.length) {
    const d = _runScriptOutputDirs.pop();
    rmSync(d, { recursive: true, force: true });
  }
});

describe('scan-project.mjs — language detection', () => {
  let projectRoot;

  afterEach(() => {
    if (projectRoot) {
      rmSync(projectRoot, { recursive: true, force: true });
      projectRoot = null;
    }
  });

  it('maps TypeScript/JavaScript extensions to typescript/javascript', () => {
    projectRoot = setupTree({
      'a.ts': 'export const a = 1;\n',
      'b.tsx': 'export const B = () => null;\n',
      'c.js': 'module.exports = {};\n',
      'd.jsx': 'export default () => null;\n',
      'e.mjs': 'export const e = 1;\n',
      'f.cjs': 'module.exports = 1;\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(byPath(r.output, 'a.ts').language).toBe('typescript');
    expect(byPath(r.output, 'b.tsx').language).toBe('typescript');
    expect(byPath(r.output, 'c.js').language).toBe('javascript');
    expect(byPath(r.output, 'd.jsx').language).toBe('javascript');
    expect(byPath(r.output, 'e.mjs').language).toBe('javascript');
    expect(byPath(r.output, 'f.cjs').language).toBe('javascript');
  });

  it('maps Python, Go, Rust, Java, Kotlin, C#, Swift to their language ids', () => {
    projectRoot = setupTree({
      'a.py': 'x = 1\n',
      'b.go': 'package main\n',
      'c.rs': 'fn main() {}\n',
      'd.java': 'class D {}\n',
      'e.kt': 'fun main() {}\n',
      'f.cs': 'class F {}\n',
      'g.swift': 'struct G {}\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(byPath(r.output, 'a.py').language).toBe('python');
    expect(byPath(r.output, 'b.go').language).toBe('go');
    expect(byPath(r.output, 'c.rs').language).toBe('rust');
    expect(byPath(r.output, 'd.java').language).toBe('java');
    expect(byPath(r.output, 'e.kt').language).toBe('kotlin');
    expect(byPath(r.output, 'f.cs').language).toBe('csharp');
    expect(byPath(r.output, 'g.swift').language).toBe('swift');
  });

  it('maps Ruby, PHP, C, C++ to their language ids', () => {
    projectRoot = setupTree({
      'a.rb': 'puts 1\n',
      'b.php': '<?php echo 1;\n',
      'c.c': 'int main() { return 0; }\n',
      'd.h': 'void f();\n',
      'e.cpp': 'int main() {}\n',
      'f.hpp': 'class F {};\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(byPath(r.output, 'a.rb').language).toBe('ruby');
    expect(byPath(r.output, 'b.php').language).toBe('php');
    expect(byPath(r.output, 'c.c').language).toBe('c');
    expect(byPath(r.output, 'd.h').language).toBe('c');
    expect(byPath(r.output, 'e.cpp').language).toBe('cpp');
    expect(byPath(r.output, 'f.hpp').language).toBe('cpp');
  });

  it('maps web markup (HTML, CSS) to their language ids', () => {
    projectRoot = setupTree({
      'a.html': '<!doctype html><html></html>\n',
      'b.htm': '<html></html>\n',
      'c.css': '.a { }\n',
      'd.scss': '$x: 1;\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(byPath(r.output, 'a.html').language).toBe('html');
    expect(byPath(r.output, 'b.htm').language).toBe('html');
    expect(byPath(r.output, 'c.css').language).toBe('css');
    expect(byPath(r.output, 'd.scss').language).toBe('css');
  });

  it('maps configuration formats (YAML, JSON, JSONC, TOML, XML, Markdown) to their language ids', () => {
    projectRoot = setupTree({
      'a.yaml': 'x: 1\n',
      'b.yml': 'x: 1\n',
      'c.json': '{}\n',
      'd.jsonc': '{ /* c */ }\n',
      'e.toml': 'x = 1\n',
      'f.xml': '<x/>\n',
      'g.md': '# h\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(byPath(r.output, 'a.yaml').language).toBe('yaml');
    expect(byPath(r.output, 'b.yml').language).toBe('yaml');
    expect(byPath(r.output, 'c.json').language).toBe('json');
    expect(byPath(r.output, 'd.jsonc').language).toBe('jsonc');
    expect(byPath(r.output, 'e.toml').language).toBe('toml');
    expect(byPath(r.output, 'f.xml').language).toBe('xml');
    expect(byPath(r.output, 'g.md').language).toBe('markdown');
  });

  it('maps shell + batch + Dockerfile (no extension) to their language ids', () => {
    projectRoot = setupTree({
      'a.sh': 'echo 1\n',
      'b.bat': '@echo off\n',
      Dockerfile: 'FROM node:22\n',
      'Dockerfile.dev': 'FROM node:22\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(byPath(r.output, 'a.sh').language).toBe('shell');
    expect(byPath(r.output, 'b.bat').language).toBe('batch');
    expect(byPath(r.output, 'Dockerfile').language).toBe('dockerfile');
    expect(byPath(r.output, 'Dockerfile.dev').language).toBe('dockerfile');
  });

  it('falls back to "unknown" for files with no extension and no filename match', () => {
    projectRoot = setupTree({
      WEIRD_FILE: 'mystery contents\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(byPath(r.output, 'WEIRD_FILE').language).toBe('unknown');
  });

  it('falls back to bare extension (without dot) for unknown extensions', () => {
    projectRoot = setupTree({
      'data.weirdext': 'some data\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(byPath(r.output, 'data.weirdext').language).toBe('weirdext');
  });
});

describe('scan-project.mjs — category assignment (project-scanner.md Step 4)', () => {
  let projectRoot;

  afterEach(() => {
    if (projectRoot) {
      rmSync(projectRoot, { recursive: true, force: true });
      projectRoot = null;
    }
  });

  it('assigns code to TypeScript, JavaScript, Python, Go, Rust, Swift source files', () => {
    projectRoot = setupTree({
      'src/a.ts': 'export const a = 1;\n',
      'src/b.py': 'def b(): pass\n',
      'src/c.go': 'package main\n',
      'src/d.rs': 'fn main() {}\n',
      'src/e.swift': 'struct E {}\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(byPath(r.output, 'src/a.ts').fileCategory).toBe('code');
    expect(byPath(r.output, 'src/b.py').fileCategory).toBe('code');
    expect(byPath(r.output, 'src/c.go').fileCategory).toBe('code');
    expect(byPath(r.output, 'src/d.rs').fileCategory).toBe('code');
    expect(byPath(r.output, 'src/e.swift').fileCategory).toBe('code');
  });

  it('assigns config to JSON/YAML/TOML/INI/XML', () => {
    projectRoot = setupTree({
      'package.json': '{}\n',
      'tsconfig.json': '{}\n',
      'pyproject.toml': '[project]\nname = "p"\n',
      'config.yaml': 'x: 1\n',
      'app.ini': '[s]\nk=v\n',
      'data.xml': '<x/>\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(byPath(r.output, 'package.json').fileCategory).toBe('config');
    expect(byPath(r.output, 'tsconfig.json').fileCategory).toBe('config');
    expect(byPath(r.output, 'pyproject.toml').fileCategory).toBe('config');
    expect(byPath(r.output, 'config.yaml').fileCategory).toBe('config');
    expect(byPath(r.output, 'app.ini').fileCategory).toBe('config');
    expect(byPath(r.output, 'data.xml').fileCategory).toBe('config');
  });

  it('assigns docs to .md / .rst / .txt (but NOT to LICENSE)', () => {
    projectRoot = setupTree({
      'README.md': '# x\n',
      'docs/guide.rst': 'Guide\n=====\n',
      'NOTES.txt': 'notes\n',
      LICENSE: 'Apache-2.0\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(byPath(r.output, 'README.md').fileCategory).toBe('docs');
    expect(byPath(r.output, 'docs/guide.rst').fileCategory).toBe('docs');
    expect(byPath(r.output, 'NOTES.txt').fileCategory).toBe('docs');
    // LICENSE exception: must NOT be docs. The default ignore filter
    // normally drops LICENSE entirely, so we re-include it via
    // `!LICENSE` so the category test can fire.
    writeFileSync(join(projectRoot, '.understandignore'), '!LICENSE\n');
    const r2 = runScript(projectRoot);
    const license = byPath(r2.output, 'LICENSE');
    expect(license).toBeDefined();
    expect(license.fileCategory).not.toBe('docs');
  });

  it('assigns infra to Dockerfile, docker-compose, .gitlab-ci.yml, .tf, .github/workflows/, Makefile, Jenkinsfile, k8s paths', () => {
    projectRoot = setupTree({
      Dockerfile: 'FROM node:22\n',
      'docker-compose.yml': 'services: {}\n',
      '.gitlab-ci.yml': 'stages: []\n',
      'infra/main.tf': 'resource "x" "y" {}\n',
      '.github/workflows/ci.yml': 'name: ci\n',
      Makefile: 'all:\n\t@echo hi\n',
      Jenkinsfile: 'pipeline { }\n',
      'k8s/deploy.yaml': 'kind: Deployment\n',
      'kubernetes/svc.yaml': 'kind: Service\n',
      'foo.k8s.yaml': 'kind: ConfigMap\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(byPath(r.output, 'Dockerfile').fileCategory).toBe('infra');
    expect(byPath(r.output, 'docker-compose.yml').fileCategory).toBe('infra');
    expect(byPath(r.output, '.gitlab-ci.yml').fileCategory).toBe('infra');
    expect(byPath(r.output, 'infra/main.tf').fileCategory).toBe('infra');
    expect(byPath(r.output, '.github/workflows/ci.yml').fileCategory).toBe('infra');
    expect(byPath(r.output, 'Makefile').fileCategory).toBe('infra');
    expect(byPath(r.output, 'Jenkinsfile').fileCategory).toBe('infra');
    expect(byPath(r.output, 'k8s/deploy.yaml').fileCategory).toBe('infra');
    expect(byPath(r.output, 'kubernetes/svc.yaml').fileCategory).toBe('infra');
    expect(byPath(r.output, 'foo.k8s.yaml').fileCategory).toBe('infra');
  });

  it('assigns data to SQL, GraphQL, Proto, Prisma, CSV', () => {
    projectRoot = setupTree({
      'db/schema.sql': 'CREATE TABLE x (id INT);\n',
      'api/schema.graphql': 'type X { id: ID! }\n',
      'api/types.proto': 'syntax = "proto3";\n',
      'prisma/schema.prisma': 'model X { id Int @id }\n',
      'data/seed.csv': 'a,b\n1,2\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(byPath(r.output, 'db/schema.sql').fileCategory).toBe('data');
    expect(byPath(r.output, 'api/schema.graphql').fileCategory).toBe('data');
    expect(byPath(r.output, 'api/types.proto').fileCategory).toBe('data');
    expect(byPath(r.output, 'prisma/schema.prisma').fileCategory).toBe('data');
    expect(byPath(r.output, 'data/seed.csv').fileCategory).toBe('data');
  });

  it('assigns script to shell + batch files (.sh, .bash, .ps1, .bat)', () => {
    projectRoot = setupTree({
      'scripts/build.sh': '#!/bin/bash\necho 1\n',
      'scripts/run.bash': '#!/bin/bash\necho run\n',
      'scripts/build.ps1': 'Write-Output 1\n',
      'scripts/setup.bat': '@echo off\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(byPath(r.output, 'scripts/build.sh').fileCategory).toBe('script');
    expect(byPath(r.output, 'scripts/run.bash').fileCategory).toBe('script');
    expect(byPath(r.output, 'scripts/build.ps1').fileCategory).toBe('script');
    expect(byPath(r.output, 'scripts/setup.bat').fileCategory).toBe('script');
  });

  it('assigns markup to HTML + CSS variants', () => {
    projectRoot = setupTree({
      'public/index.html': '<!doctype html>\n',
      'public/page.htm': '<html></html>\n',
      'styles/app.css': 'body { }\n',
      'styles/app.scss': '$x: 1;\n',
      'styles/app.less': '@x: 1;\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(byPath(r.output, 'public/index.html').fileCategory).toBe('markup');
    expect(byPath(r.output, 'public/page.htm').fileCategory).toBe('markup');
    expect(byPath(r.output, 'styles/app.css').fileCategory).toBe('markup');
    expect(byPath(r.output, 'styles/app.scss').fileCategory).toBe('markup');
    expect(byPath(r.output, 'styles/app.less').fileCategory).toBe('markup');
  });

  it('priority: docker-compose.yml maps to infra, not config', () => {
    // The .yml extension would normally route to `config`, but the
    // docker-compose.* filename rule fires first per Step 4 priority.
    projectRoot = setupTree({
      'docker-compose.yml': 'services: {}\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(byPath(r.output, 'docker-compose.yml').fileCategory).toBe('infra');
    expect(byPath(r.output, 'docker-compose.yml').language).toBe('yaml');
  });

  // Regression: path.extname returns '' for `.env` and the second segment
  // for `.env.local` — neither hits CATEGORY_BY_EXT['.env']. Dotfile-style
  // configs were falling through to `code` / `unknown`. Caught by Codex
  // review on PR #204.
  it('dotfile configs (.env, .env.local, .env.production) map to config + env language', () => {
    projectRoot = setupTree({
      '.env': 'API_KEY=abc\n',
      '.env.local': 'LOCAL=1\n',
      '.env.production': 'PROD=1\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    for (const p of ['.env', '.env.local', '.env.production']) {
      expect(byPath(r.output, p).fileCategory).toBe('config');
      // LANGUAGE_BY_EXT['.env'] -> 'config' (the language id itself; not
      // a typo — the language for env files is the 'config' bucket).
      expect(byPath(r.output, p).language).toBe('config');
    }
  });
});

describe('scan-project.mjs — .understandignore handling', () => {
  let projectRoot;

  afterEach(() => {
    if (projectRoot) {
      rmSync(projectRoot, { recursive: true, force: true });
      projectRoot = null;
    }
  });

  it('respects .understandignore patterns and increments filteredByIgnore', () => {
    // `**/*.log` is NOT in the hardcoded defaults at the recursive level
    // — wait, `*.log` is. Use a custom pattern to exercise user-driven drops.
    projectRoot = setupTree({
      '.understandignore': 'fixtures/\n',
      'src/index.ts': 'export const x = 1;\n',
      'fixtures/snap1.json': '{ "a": 1 }\n',
      'fixtures/snap2.json': '{ "b": 2 }\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    // fixtures/ files dropped
    expect(byPath(r.output, 'fixtures/snap1.json')).toBeUndefined();
    expect(byPath(r.output, 'fixtures/snap2.json')).toBeUndefined();
    // Counted as user-driven
    expect(r.output.filteredByIgnore).toBe(2);
  });

  it('supports `!pattern` negation to re-include defaults-excluded files', () => {
    // `*.log` is in the hardcoded defaults; the user re-includes a
    // specific file with `!keep.log`. After the override, keep.log MUST
    // appear in the output. It is NOT counted in filteredByIgnore (it
    // was re-included, not additionally filtered).
    projectRoot = setupTree({
      '.understandignore': '!keep.log\n',
      'src/index.ts': 'export const x = 1;\n',
      'keep.log': 'important diagnostic\n',
      'drop.log': 'noise\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(byPath(r.output, 'keep.log')).toBeDefined();
    // drop.log still excluded by defaults (no negation for it)
    expect(byPath(r.output, 'drop.log')).toBeUndefined();
    // The defaults dropped drop.log — that's a baseline default drop,
    // NOT a user-driven drop. filteredByIgnore should be 0.
    expect(r.output.filteredByIgnore).toBe(0);
  });
});

describe('scan-project.mjs — special-file recognition', () => {
  let projectRoot;

  afterEach(() => {
    if (projectRoot) {
      rmSync(projectRoot, { recursive: true, force: true });
      projectRoot = null;
    }
  });

  it('Dockerfile (no extension) is language=dockerfile, category=infra', () => {
    projectRoot = setupTree({
      Dockerfile: 'FROM alpine:3\nCMD ["sh"]\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    const entry = byPath(r.output, 'Dockerfile');
    expect(entry).toBeDefined();
    expect(entry.language).toBe('dockerfile');
    expect(entry.fileCategory).toBe('infra');
  });
});

describe('scan-project.mjs — determinism', () => {
  let projectRoot;

  afterEach(() => {
    if (projectRoot) {
      rmSync(projectRoot, { recursive: true, force: true });
      projectRoot = null;
    }
  });

  it('produces byte-identical output across runs for the same input tree', () => {
    projectRoot = setupTree({
      'README.md': '# project\n',
      'src/a.ts': 'export const a = 1;\n',
      'src/b.ts': 'export const b = 2;\n',
      'src/lib/c.ts': 'export const c = 3;\n',
      'package.json': '{}\n',
      'tsconfig.json': '{}\n',
    });
    const r1 = runScript(projectRoot);
    const r2 = runScript(projectRoot);
    expect(r1.status).toBe(0);
    expect(r2.status).toBe(0);
    expect(JSON.stringify(r1.output)).toBe(JSON.stringify(r2.output));
  });
});

describe('scan-project.mjs — empty repo', () => {
  let projectRoot;

  afterEach(() => {
    if (projectRoot) {
      rmSync(projectRoot, { recursive: true, force: true });
      projectRoot = null;
    }
  });

  it('handles a project with zero files without crashing', () => {
    projectRoot = setupTree({}, { gitInit: true });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(r.output.scriptCompleted).toBe(true);
    expect(r.output.totalFiles).toBe(0);
    expect(r.output.files).toEqual([]);
    expect(r.output.filteredByIgnore).toBe(0);
    expect(r.output.estimatedComplexity).toBe('small');
  });
});

describe('scan-project.mjs — per-file failure resilience', () => {
  let projectRoot;

  afterEach(() => {
    if (projectRoot) {
      // Restore permissions on any chmod'd file before delete, so cleanup
      // succeeds even when a test left a 000-permission file behind.
      try {
        const f = join(projectRoot, 'src/unreadable.ts');
        if (existsSync(f)) chmodSync(f, 0o644);
      } catch { /* best-effort */ }
      rmSync(projectRoot, { recursive: true, force: true });
      projectRoot = null;
    }
  });

  it('emits a Warning: and skips a file with unreadable permissions; other files survive', () => {
    if (process.platform === 'win32') {
      // chmod permission bits don't apply on Windows the same way; skip.
      return;
    }
    if (process.getuid && process.getuid() === 0) {
      // Running as root bypasses permission checks; the test cannot exercise
      // its failure mode. Skip rather than emit a false pass.
      return;
    }
    projectRoot = setupTree({
      'src/good.ts': 'export const good = 1;\n',
      'src/unreadable.ts': 'export const bad = 2;\n',
    });
    // Strip read permission on the synthetic file.
    chmodSync(join(projectRoot, 'src/unreadable.ts'), 0o000);

    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(r.output.scriptCompleted).toBe(true);
    // The good file is in the output.
    expect(byPath(r.output, 'src/good.ts')).toBeDefined();
    // The unreadable file is dropped.
    expect(byPath(r.output, 'src/unreadable.ts')).toBeUndefined();
    // A visible warning was emitted with the documented prefix.
    expect(r.stderr).toMatch(
      /Warning: scan-project: src\/unreadable\.ts — line count failed/,
    );
    expect(r.stderr).toMatch(/file skipped from output/);
    // Final summary line still fires.
    expect(r.stderr).toMatch(
      /scan-project: filesScanned=1 filteredByIgnore=0 complexity=small/,
    );
  });
});

describe('scan-project.mjs — estimatedComplexity thresholds', () => {
  let projectRoot;

  afterEach(() => {
    if (projectRoot) {
      rmSync(projectRoot, { recursive: true, force: true });
      projectRoot = null;
    }
  });

  /**
   * Build a tree with exactly N .ts files at the top level. Used to
   * lock in the complexity-tier boundary points from project-scanner.md
   * Step 7: small (≤30), moderate (31-150), large (151-500), very-large
   * (>500).
   */
  function setupNFiles(n) {
    const tree = {};
    for (let i = 0; i < n; i++) {
      // Pad indices so localeCompare gives the natural order for any N.
      tree[`f${String(i).padStart(4, '0')}.ts`] = 'export const x = 1;\n';
    }
    return setupTree(tree);
  }

  it('30 files -> small (upper boundary of small)', () => {
    projectRoot = setupNFiles(30);
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(r.output.totalFiles).toBe(30);
    expect(r.output.estimatedComplexity).toBe('small');
  });

  it('31 files -> moderate (lower boundary of moderate)', () => {
    projectRoot = setupNFiles(31);
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(r.output.totalFiles).toBe(31);
    expect(r.output.estimatedComplexity).toBe('moderate');
  });

  it('150 files -> moderate (upper boundary of moderate)', () => {
    projectRoot = setupNFiles(150);
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(r.output.totalFiles).toBe(150);
    expect(r.output.estimatedComplexity).toBe('moderate');
  });

  it('151 files -> large (lower boundary of large)', () => {
    projectRoot = setupNFiles(151);
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(r.output.totalFiles).toBe(151);
    expect(r.output.estimatedComplexity).toBe('large');
  });

  it('501 files -> very-large (lower boundary of very-large)', () => {
    projectRoot = setupNFiles(501);
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(r.output.totalFiles).toBe(501);
    expect(r.output.estimatedComplexity).toBe('very-large');
  });
});

describe('scan-project.mjs — CLI entry guard + invocation', () => {
  let projectRoot;

  afterEach(() => {
    if (projectRoot) {
      rmSync(projectRoot, { recursive: true, force: true });
      projectRoot = null;
    }
  });

  it('invokes successfully via subprocess and produces a parseable output file', () => {
    projectRoot = setupTree({
      'README.md': '# proj\n',
      'src/index.ts': 'export const x = 1;\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    expect(r.output).not.toBeNull();
    expect(r.output.scriptCompleted).toBe(true);
    // Stats summary line fires on stderr.
    expect(r.stderr).toMatch(
      /scan-project: filesScanned=2 filteredByIgnore=0 complexity=small/,
    );
    // Two files captured.
    expect(r.output.totalFiles).toBe(2);
  });

  it('fails fast with usage message when projectRoot is missing', () => {
    const result = spawnSync('node', [SCRIPT], { encoding: 'utf-8' });
    expect(result.status).toBe(1);
    expect(result.stderr).toMatch(/Usage: node scan-project\.mjs/);
  });
});

describe('scan-project.mjs — output schema invariants', () => {
  let projectRoot;

  afterEach(() => {
    if (projectRoot) {
      rmSync(projectRoot, { recursive: true, force: true });
      projectRoot = null;
    }
  });

  it('emits the documented top-level fields with correct shapes', () => {
    projectRoot = setupTree({
      'src/a.ts': 'export const a = 1;\n',
      'README.md': '# x\n',
      'package.json': '{}\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    const out = r.output;
    expect(out.scriptCompleted).toBe(true);
    expect(Array.isArray(out.files)).toBe(true);
    expect(typeof out.totalFiles).toBe('number');
    expect(out.totalFiles).toBe(out.files.length);
    expect(typeof out.filteredByIgnore).toBe('number');
    expect(['small', 'moderate', 'large', 'very-large']).toContain(
      out.estimatedComplexity,
    );
    expect(out.stats).toBeDefined();
    expect(out.stats.filesScanned).toBe(out.files.length);
    expect(typeof out.stats.byCategory).toBe('object');
    expect(typeof out.stats.byLanguage).toBe('object');
    // Per-file shape
    for (const f of out.files) {
      expect(typeof f.path).toBe('string');
      expect(typeof f.language).toBe('string');
      expect(typeof f.sizeLines).toBe('number');
      expect([
        'code', 'config', 'docs', 'infra', 'data', 'script', 'markup',
      ]).toContain(f.fileCategory);
    }
  });

  it('files[] is sorted by path.localeCompare', () => {
    projectRoot = setupTree({
      'zzz.ts': '\n',
      'aaa.ts': '\n',
      'mmm.ts': '\n',
      'subdir/file.ts': '\n',
    });
    const r = runScript(projectRoot);
    expect(r.status).toBe(0);
    const paths = r.output.files.map(f => f.path);
    const sortedPaths = [...paths].sort((a, b) => a.localeCompare(b));
    expect(paths).toEqual(sortedPaths);
  });
});
