import { z } from 'zod';
import { compareCodeUnits } from '../utils.js';
/**
 * Why an entity or file was judged to be a test.
 *
 * A recorded reason is not decoration: affected-test discovery is only
 * trustworthy if a caller can see *why* something was counted, and a
 * misclassified helper is otherwise indistinguishable from a real test in the
 * output.
 */
export const OccTestReasonCodeSchema = z.enum([
    'test-directory',
    'test-filename',
    'test-symbol-name',
    'test-container',
    'test-import',
    'test-call',
    'test-block',
    'test-attribute',
    'custom-directory',
    'custom-pattern',
]);
export const OccTestReasonSchema = z.object({
    code: OccTestReasonCodeSchema,
    /** The concrete evidence: `__tests__/`, `*.spec.ts`, `@Test`, `pytest`. */
    detail: z.string(),
    confidence: z.number().min(0).max(1),
    source: z.enum(['path', 'symbol', 'graph', 'content']),
});
export const OccTestSignalSchema = z.object({
    isTest: z.boolean(),
    confidence: z.number().min(0).max(1),
    /** Every contributing signal, strongest first. Deterministically ordered. */
    reasons: z.array(OccTestReasonSchema),
});
/**
 * Directory segments that mark a test tree. Matched as whole segments, never
 * as substrings — `src/contest/`, `src/latest/`, and `src/attestation/` are
 * production code and must not be swept up.
 */
export const DEFAULT_TEST_DIRECTORIES = [
    '__mocks__',
    '__tests__',
    'cypress',
    'e2e',
    'integration-tests',
    'playwright',
    'spec',
    'specs',
    'test',
    'testing',
    'tests',
];
export const DEFAULT_TEST_FILE_PATTERNS = [
    '*.test.*',
    '*.spec.*',
    '*_test.*',
    '*_spec.*',
    'test_*.py',
    'conftest.py',
    'Test*.java',
    '*Test.java',
    '*Tests.java',
    '*Test.kt',
    '*Test.cs',
    '*Tests.cs',
    '*Test.php',
    '*Tests.swift',
    '*Spec.scala',
    '*Suite.scala',
];
/**
 * Symbol-name conventions, by language family. These are the cases where the
 * test framework keys off the name itself rather than an attribute — Go's
 * `TestXxx`, Python's `test_*`, xUnit's `*Test` classes.
 */
const SYMBOL_NAME_RULES = [
    { pattern: /^Test[A-Z_]/, detail: 'TestXxx (Go, xUnit)' },
    { pattern: /^Benchmark[A-Z_]/, detail: 'BenchmarkXxx (Go)' },
    { pattern: /^Fuzz[A-Z_]/, detail: 'FuzzXxx (Go)' },
    { pattern: /^Example[A-Z_]/, detail: 'ExampleXxx (Go)' },
    { pattern: /^test_/, detail: 'test_* (Python, Rust)' },
    { pattern: /_test$/, detail: '*_test' },
    { pattern: /Tests?$/, detail: '*Test / *Tests class' },
    { pattern: /^(it|describe|spec)_/, detail: 'BDD-style prefix' },
];
/**
 * Attributes and macros that mark a test. None of OCC's parser tiers surface
 * decorators or attributes on `ParsedSymbol`, and the vendored tree-sitter tag
 * queries capture only definitions and call references — so these can only be
 * found by reading source text, which is why they are gated behind an explicit
 * `declarationContext` the caller supplies.
 */
const ATTRIBUTE_RULES = [
    { pattern: /#\[(tokio::)?test\]/, detail: '#[test] (Rust)' },
    { pattern: /#\[cfg\(test\)\]/, detail: '#[cfg(test)] (Rust)' },
    { pattern: /@(Test|ParameterizedTest|BeforeEach|AfterEach)\b/, detail: '@Test (JUnit)' },
    { pattern: /\[(Test|Fact|Theory|TestMethod|TestCase)\]/, detail: '[Test] (xUnit/NUnit/MSTest)' },
    { pattern: /@pytest\b/, detail: '@pytest (Python)' },
];
/**
 * Test-framework module specifiers. An import is strong evidence because it is
 * a deliberate dependency rather than a naming coincidence.
 */
const TEST_IMPORT_ROOTS = [
    '@jest/globals',
    '@testing-library',
    'ava',
    'chai',
    'jasmine',
    'jest',
    'mocha',
    'node:test',
    'pytest',
    'sinon',
    'supertest',
    'tap',
    'unittest',
    'vitest',
];
/**
 * Framework entry points that open a test block.
 *
 * Deliberately the BDD vocabulary rather than any one framework's API: jest,
 * vitest, mocha, jasmine, node:test, and Google Test all spell a block with one
 * of these. Exported so the parser recognizes exactly the names the recognizer
 * scores, instead of keeping a second list that can drift.
 */
export const TEST_CALL_NAMES = new Set([
    'describe',
    'it',
    'test',
    'suite',
    'context',
    'bench',
    'TEST',
    'TEST_F',
    'TEST_P',
]);
/**
 * True when a module specifier names a test framework.
 *
 * The qualifier that turns a bare-name match into evidence: `it(...)` imported
 * from `vitest` is a test, `it(...)` imported from a local module is a function
 * that happens to share the name.
 */
export function isTestFrameworkSpecifier(specifier) {
    const root = specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0];
    return TEST_IMPORT_ROOTS.includes(root) || TEST_IMPORT_ROOTS.includes(specifier);
}
/**
 * Base classes whose subclasses are tests by definition. OCC extracts
 * inheritance edges for TypeScript, Python, and the generic tree-sitter tier,
 * so this signal is genuinely available rather than aspirational.
 */
const TEST_BASE_CLASSES = new Set([
    'TestCase',
    'IsolatedAsyncioTestCase',
    'XCTestCase',
    'AnyFunSuite',
    'AnyFlatSpec',
    'FlatSpec',
    'FunSuite',
    'Minitest::Test',
    'Test',
]);
/**
 * Names that mark shared scaffolding rather than a test. A file in `tests/`
 * called `helpers.ts` holds fixtures the tests import; counting it as a test
 * inflates affected-test discovery with things no runner will execute.
 */
const SUPPORT_FILE_PATTERN = /(^|[-_.])(helper|helpers|fixture|fixtures|mock|mocks|stub|stubs|util|utils|support|factory|factories|setup|conftest)([-_.]|$)/i;
const DEFAULT_MIN_CONFIDENCE = 0.5;
function globToRegExp(pattern) {
    const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*');
    return new RegExp(`^${escaped}$`);
}
function basename(relativePath) {
    const segments = relativePath.split('/');
    return segments[segments.length - 1] ?? relativePath;
}
/**
 * Combine independent signals with noisy-OR: `1 - Π(1 - cᵢ)`.
 *
 * Independent weak signals should reinforce each other — a file in `tests/`
 * that also imports `vitest` is more certainly a test than either fact alone —
 * while no single signal can reach certainty on its own.
 */
function combineConfidence(reasons) {
    let inverse = 1;
    for (const reason of reasons)
        inverse *= 1 - reason.confidence;
    return Math.min(0.99, 1 - inverse);
}
function sortReasons(reasons) {
    return reasons.sort((a, b) => b.confidence - a.confidence || compareCodeUnits(a.code, b.code));
}
export function createTestRecognizer(config = {}) {
    const useDefaults = config.disableDefaults !== true;
    const directories = new Set([
        ...(useDefaults ? DEFAULT_TEST_DIRECTORIES : []),
        ...(config.directories ?? []),
    ]);
    const customDirectories = new Set(config.directories ?? []);
    const filePatterns = (useDefaults ? DEFAULT_TEST_FILE_PATTERNS : []).map(pattern => ({
        detail: pattern,
        regexp: globToRegExp(pattern),
        custom: false,
    }));
    for (const pattern of config.filePatterns ?? []) {
        filePatterns.push({ detail: pattern, regexp: globToRegExp(pattern), custom: true });
    }
    const callNames = new Set([...(useDefaults ? TEST_CALL_NAMES : []), ...(config.callNames ?? [])]);
    const symbolPatterns = (config.symbolPatterns ?? []).map(source => ({
        detail: source,
        regexp: new RegExp(source),
    }));
    const minConfidence = config.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
    function finish(reasons) {
        const ordered = sortReasons(reasons);
        const confidence = combineConfidence(ordered);
        return { isTest: confidence >= minConfidence, confidence, reasons: ordered };
    }
    return {
        classifyFile(input) {
            const reasons = [];
            const segments = input.relativePath.split('/').slice(0, -1);
            const file = basename(input.relativePath);
            const isSupportFile = SUPPORT_FILE_PATTERN.test(file);
            for (const segment of segments) {
                if (!directories.has(segment))
                    continue;
                const custom = customDirectories.has(segment);
                reasons.push({
                    code: custom ? 'custom-directory' : 'test-directory',
                    detail: `${segment}/`,
                    // A support file inside a test tree is scaffolding the runner never
                    // executes, so the directory alone must not carry it over the line.
                    confidence: isSupportFile ? 0.3 : 0.8,
                    source: 'path',
                });
                break;
            }
            for (const pattern of filePatterns) {
                if (!pattern.regexp.test(file))
                    continue;
                reasons.push({
                    code: pattern.custom ? 'custom-pattern' : 'test-filename',
                    detail: pattern.detail,
                    confidence: 0.9,
                    source: 'path',
                });
                break;
            }
            for (const specifier of input.importSpecifiers ?? []) {
                const root = specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0];
                if (!TEST_IMPORT_ROOTS.includes(root) && !TEST_IMPORT_ROOTS.includes(specifier))
                    continue;
                reasons.push({ code: 'test-import', detail: specifier, confidence: 0.9, source: 'graph' });
                break;
            }
            const bindings = input.bindings ? new Map(input.bindings.map(entry => [entry.name, entry.specifier])) : undefined;
            for (const callee of input.calleeNames ?? []) {
                if (!callNames.has(callee))
                    continue;
                if (bindings?.has(callee)) {
                    // The name resolves to something in this repository. Unless that
                    // something is a test framework, the match is a coincidence.
                    const specifier = bindings.get(callee);
                    if (!specifier || !isTestFrameworkSpecifier(specifier))
                        continue;
                }
                reasons.push({ code: 'test-call', detail: `${callee}(...)`, confidence: 0.75, source: 'graph' });
                break;
            }
            return finish(reasons);
        },
        classifySymbol(input) {
            const reasons = [];
            // Extraction already resolved the framework call that opened this block,
            // so this is a fact rather than a name-shaped guess.
            if (input.isTestBlock || input.kind === 'test' || input.kind === 'test_suite') {
                reasons.push({
                    code: 'test-block',
                    detail: input.kind === 'test_suite' ? 'test suite block' : 'test block',
                    confidence: 0.95,
                    source: 'symbol',
                });
            }
            for (const rule of SYMBOL_NAME_RULES) {
                if (!rule.pattern.test(input.name))
                    continue;
                reasons.push({ code: 'test-symbol-name', detail: rule.detail, confidence: 0.85, source: 'symbol' });
                break;
            }
            for (const rule of symbolPatterns) {
                if (!rule.regexp.test(input.name))
                    continue;
                reasons.push({ code: 'custom-pattern', detail: rule.detail, confidence: 0.9, source: 'symbol' });
                break;
            }
            if (input.containerName) {
                for (const rule of SYMBOL_NAME_RULES) {
                    if (!rule.pattern.test(input.containerName))
                        continue;
                    reasons.push({
                        code: 'test-container',
                        detail: `${input.containerName} (${rule.detail})`,
                        confidence: 0.7,
                        source: 'symbol',
                    });
                    break;
                }
            }
            for (const base of input.baseNames ?? []) {
                if (!TEST_BASE_CLASSES.has(base))
                    continue;
                reasons.push({ code: 'test-container', detail: `extends ${base}`, confidence: 0.95, source: 'graph' });
                break;
            }
            const context = (input.declarationContext ?? []).join('\n');
            if (context) {
                for (const rule of ATTRIBUTE_RULES) {
                    if (!rule.pattern.test(context))
                        continue;
                    reasons.push({ code: 'test-attribute', detail: rule.detail, confidence: 0.95, source: 'content' });
                    break;
                }
            }
            return finish(reasons);
        },
        classifyEntity(fileSignal, symbolSignal) {
            // File evidence is inherited at a discount: living in a test file makes
            // an entity likely a test, but a fixture defined there is still not one.
            const inherited = fileSignal.reasons.map(reason => ({
                ...reason,
                confidence: Math.min(reason.confidence, reason.confidence * 0.9),
            }));
            return finish([...inherited, ...symbolSignal.reasons]);
        },
    };
}
/** Convenience predicate for callers that only have a path. */
export function isTestPath(relativePath, config) {
    return createTestRecognizer(config).classifyFile({ relativePath }).isTest;
}
//# sourceMappingURL=tests.js.map