/**
 * Unit tests for `extractParsedFile` — the parse-worker → ScopeExtractor
 * bridge (RFC #909 Ring 2 PKG #920).
 *
 * The goal is to pin three invariants:
 *
 *   1. When a provider does NOT implement `emitScopeCaptures`, the helper
 *      returns `undefined` silently. This is the state of every language
 *      today — `ParseWorkerResult.parsedFiles` stays empty and the legacy
 *      DAG continues unaffected.
 *   2. When a provider DOES implement the hook, the helper threads its
 *      output through `ScopeExtractor.extract` and returns a `ParsedFile`.
 *   3. Exceptions from either the hook or the extractor are caught
 *      locally. The helper returns `undefined` — scope-extraction
 *      failures must NEVER break legacy parsing on the same file.
 */

import { describe, it, expect } from 'vitest';
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js';
import type { LanguageProvider } from '../../../src/core/ingestion/language-provider.js';

// ─── Capture helpers ────────────────────────────────────────────────────────

const cap = (
  name: string,
  startLine: number,
  startCol: number,
  endLine: number,
  endCol: number,
  text = '',
): Capture => ({ name, range: { startLine, startCol, endLine, endCol }, text });

const moduleScopeMatch = (): CaptureMatch => ({
  '@scope.module': cap('@scope.module', 1, 0, 100, 0),
});

/**
 * Build a `LanguageProvider` whose shape is only as narrow as
 * `extractParsedFile` reads. Tests cast to the full provider type since
 * `extractParsedFile` is typed against `LanguageProvider` (not the narrow
 * `ScopeExtractorHooks`); the real worker always has a full provider.
 */
function fakeProvider(
  hooks: Partial<Pick<LanguageProvider, 'emitScopeCaptures' | 'resolveScopeKind'>>,
): LanguageProvider {
  return hooks as unknown as LanguageProvider;
}

// ─── Tests ─────────────────────────────────────────────────────────────────

describe('extractParsedFile', () => {
  describe('provider has NOT migrated (no emitScopeCaptures)', () => {
    it('returns undefined — silent no-op for legacy languages', () => {
      const provider = fakeProvider({}); // no hook
      const result = extractParsedFile(provider, 'source text', 'src/file.ts');
      expect(result).toBeUndefined();
    });

    it('never calls the scope extractor when the hook is absent — cannot throw', () => {
      // If the extractor was wrongly invoked, it would complain about the
      // missing Module scope for empty captures. This test proves the
      // short-circuit actually fires.
      const provider = fakeProvider({});
      expect(() => extractParsedFile(provider, '', 'x.ts')).not.toThrow();
    });
  });

  describe('provider HAS migrated', () => {
    it('returns undefined without warning for whitespace-only source', () => {
      const warnings: string[] = [];
      let called = false;
      const provider = fakeProvider({
        emitScopeCaptures: () => {
          called = true;
          throw new Error('should not inspect empty source');
        },
      });

      for (const src of ['', ' \n\t']) {
        const result = extractParsedFile(provider, src, 'pkg/__init__.py', (msg) => {
          warnings.push(msg);
        });
        expect(result).toBeUndefined();
      }

      expect(called).toBe(false);
      expect(warnings).toEqual([]);
    });

    it('threads emitScopeCaptures output through ScopeExtractor', () => {
      const provider = fakeProvider({
        emitScopeCaptures: () => [moduleScopeMatch()],
      });
      const result = extractParsedFile(provider, 'source text', 'src/file.ts');
      expect(result).toBeDefined();
      expect(result!.filePath).toBe('src/file.ts');
      expect(result!.scopes).toHaveLength(1);
      expect(result!.scopes[0]!.kind).toBe('Module');
    });

    it('forwards the correct arguments to emitScopeCaptures', () => {
      let seenText: string | undefined;
      let seenPath: string | undefined;
      let seenTree: unknown;
      const cachedTree = { rootNode: {} };
      const provider = fakeProvider({
        emitScopeCaptures: (text, path, tree) => {
          seenText = text;
          seenPath = path;
          seenTree = tree;
          return [moduleScopeMatch()];
        },
      });
      extractParsedFile(provider, 'the real text', 'deep/path/file.ts', undefined, cachedTree);
      expect(seenText).toBe('the real text');
      expect(seenPath).toBe('deep/path/file.ts');
      expect(seenTree).toBe(cachedTree);
    });
  });

  describe('error resilience — never breaks legacy parsing', () => {
    it('returns undefined when emitScopeCaptures throws', () => {
      const provider = fakeProvider({
        emitScopeCaptures: () => {
          throw new Error('provider boom');
        },
      });
      const result = extractParsedFile(provider, 'src', 'a.ts');
      expect(result).toBeUndefined();
    });

    it('routes errors through the onWarn callback when provided', () => {
      const warnings: string[] = [];
      const provider = fakeProvider({
        emitScopeCaptures: () => {
          throw new Error('provider boom');
        },
      });
      const result = extractParsedFile(provider, 'src', 'path/to/file.ts', (msg) => {
        warnings.push(msg);
      });
      expect(result).toBeUndefined();
      expect(warnings).toHaveLength(1);
      expect(warnings[0]).toContain('path/to/file.ts');
      expect(warnings[0]).toContain('provider boom');
    });

    it('synthesizes Module scope and re-parents orphan Class when no Module is emitted', () => {
      const provider = fakeProvider({
        emitScopeCaptures: () => [{ '@scope.class': cap('@scope.class', 5, 0, 10, 0) }],
      });
      const result = extractParsedFile(provider, 'src', 'a.ts');
      expect(result).toBeDefined();
      const moduleScope = result!.scopes.find((s) => s.kind === 'Module');
      expect(moduleScope).toBeDefined();
      const classScope = result!.scopes.find((s) => s.kind === 'Class');
      expect(classScope).toBeDefined();
      expect(classScope!.parent).toBe(moduleScope!.id);
    });

    it('returns undefined when ScopeExtractor throws on malformed captures (overlap)', () => {
      // Siblings with overlapping ranges trip the ScopeTreeInvariantError
      // from #912. The helper catches it and returns undefined.
      const provider = fakeProvider({
        emitScopeCaptures: () => [
          moduleScopeMatch(),
          { '@scope.function': cap('@scope.function', 10, 0, 20, 0) },
          { '@scope.function': cap('@scope.function', 15, 0, 25, 0) }, // overlap
        ],
      });
      const result = extractParsedFile(provider, 'src', 'a.ts');
      expect(result).toBeUndefined();
    });
  });
});
