"""Self-mock signal SSOT: language-keyed regexes for detecting self-mocked tests.

Every entry is a port of one bullet from the matching
`prompts/coding-preflight/languages/<lang>.md` "Self-mock signals to refuse"
section. `doc_keyword` is the literal substring of that doc which the signal was
derived from, so the drift guard can fail when the doc and this module diverge.
Detector and guard MUST import from here; re-defining a pattern elsewhere
violates the single-reference-point rule.

Patterns stay narrow on purpose — a missed self-mock is cheaper than a false
accusation, which teaches users to ignore the gate. Narrow means matching only
the syntactic shape "stub the subject's own method, then assert the stub", plus
reaching into the subject's privates. Which object is the subject is never
inferred from naming beyond the literal `sut` token; a `<var>` capture in a
pattern exists for hit reporting, not for subject identification.

Four documented shapes are deliberately left to the mutation gate because
deciding them needs subject identity that no regex has:

- Kotlin `mockkObject(SomeSingleton)` / `mockkStatic(...)` and Java
  `MockedStatic<SomeUtil>` are the anti-pattern only when the mocked singleton
  *is* the unit under test, and are legitimate boundary fakes otherwise.
- Python `patch.object(Calculator, "_compute")` names a class, and a class name
  alone does not say whether it is the subject or a collaborator. Only patching
  the test instance itself (`patch.object(self, ...)`) is unambiguous, so that is
  all `patch-sut-method` matches — `patch.object(self.service, "fetch")` patches
  a collaborator reached through `self` and must stay silent.
- TypeScript `jest.spyOn(FooService.prototype, ...)` counts only when that class
  is the spec file's own subject (`javascript-typescript.md:70` scopes it to
  "in `foo.service.spec.ts`"). Without that scoping every prototype spy matches,
  including the standard `Date.prototype` / `Repository.prototype` /
  `HTMLElement.prototype` stubs of collaborators and the environment.
"""
from __future__ import annotations

import posixpath
import re
from collections import namedtuple

Signal = namedtuple("Signal", "name pattern doc_keyword")


def selfmock_path_key(path: str) -> str:
    """Fold one self-mock path into the spelling every consumer compares on.

    Three producers name the same file and none of them agrees on spelling: the
    report's §5.7.3 rows, the detector's `--test-file` arguments, and the
    hand-authored waiver file. All three start from `git diff --name-only` in the
    worktree cwd, so this folds only the drift that survives that shared origin —
    Windows separators, a leading `./`, duplicated slashes. A single definition
    is load-bearing: a waiver that normalizes differently from the coverage check
    would clear one gate while tripping the other.
    """
    return posixpath.normpath(path.strip().replace("\\", "/"))


def waiver_entry_key(entry: dict, discriminator: str) -> tuple:
    """The `(file, line, <discriminator>)` triple a waiver is matched on.

    Both gates match this way — gate A on `signal`, gate B on `mutant` — so the
    mechanics live here rather than being written twice. Two spellings of "is
    this the same finding?" could disagree, and a waiver would clear one gate
    while leaving the other failing.

    The line is coerced to `int` when it can be: the waiver file is typed by
    hand, and a quoted `"12"` is a JSON typo, not a different finding. Everything
    else is compared as written, so a near-miss waiver fails to match and its
    finding keeps failing the run.
    """
    line = entry.get("line")
    if isinstance(line, str) and line.strip().isdigit():
        line = int(line)
    return (
        selfmock_path_key(str(entry.get("file", ""))),
        line,
        entry.get(discriminator),
    )


def partition_waived_entries(
    findings: list[dict], waivers: list[dict], discriminator: str
) -> tuple[list[dict], list[dict]]:
    """Split findings into `(still failing, waived)` on the shared key.

    A waived row carries the acknowledgement fields alongside the detector's own
    spelling of the finding, so the sidecar records both what was found and who
    accepted it.

    Note what this does NOT do: it never inspects `reason` or `acknowledgedBy`.
    Matching and adjudicating are deliberately separate — an entry with no
    acknowledgement is carried through so `validate-run.py` can block on it,
    instead of being dropped here where the run that produced the finding would
    be excusing itself.
    """
    by_key = {waiver_entry_key(w, discriminator): w for w in waivers}
    remaining: list[dict] = []
    waived: list[dict] = []
    for finding in findings:
        waiver = by_key.get(waiver_entry_key(finding, discriminator))
        if waiver is None:
            remaining.append(finding)
        else:
            waived.append({**waiver, **finding})
    return remaining, waived


SIGNALS: dict[str, list[Signal]] = {
    "ts_js": [
        # `sut` must be the whole spy target: `jest.spyOn(sut.repo, 'find')`
        # stubs a collaborator reached through the subject, which is fine.
        Signal("spyOn-sut",
               re.compile(r"jest\.spyOn\(\s*sut\s*,[^)]*\)\.(mockReturnValue|mockResolvedValue|mockImplementation)"),
               "jest.spyOn(sut"),
        Signal("assign-sut-fn",
               re.compile(r"\bsut\.\w+\s*=\s*(jest|vi)\.fn"),
               "sut.calculateTotal = jest.fn"),
        Signal("private-reach",
               re.compile(r"\(\s*sut\s+as\s+any\s*\)\.\w+|sut\[['\"]\w+['\"]\]\("),
               "(sut as any).privateMethod"),
    ],
    "python": [
        # The leading \b keeps ordinary methods ending in "patch" out —
        # `dispatch(self, request)` and `apply_patch(self, diff)` are not patches.
        Signal("patch-sut-method",
               re.compile(r"\bpatch(?:\.object)?\(\s*self\s*,"),
               "unittest.mock.patch"),
        # `obj` is the doc's placeholder, not a subject marker: `obj._meta` is
        # everyday Django. Only the literal `sut` token identifies the subject.
        Signal("private-reach",
               re.compile(r"\bsut\._\w+"),
               "obj._internal"),
    ],
    "kotlin": [
        Signal("spyk-sut",
               re.compile(r"\bspyk\(\s*sut\b"),
               "spyk(sut)"),
        # The argument list allows one level of nesting so MockK matchers
        # (`any()`, `eq(1)`) inside the stubbed call still match.
        Signal("every-sut-returns",
               re.compile(r"\bevery\s*\{\s*sut\.\w+\((?:[^()]|\([^()]*\))*\)\s*\}\s*returns"),
               "every { sut.someMethod() } returns"),
        Signal("coevery-sut-returns",
               re.compile(r"\bcoEvery\s*\{\s*sut\.\w+\((?:[^()]|\([^()]*\))*\)\s*\}\s*returns"),
               "coEvery { sut.suspendMethod() } returns"),
        Signal("private-reach",
               re.compile(r"\bcallPrivateFunc\b|\bsut\.javaClass\.getDeclared(?:Method|Field)\s*\("),
               "callPrivateFunc"),
    ],
    "rust": [
        # `let sut = MockFoo::new()` makes the subject itself a generated mock.
        Signal("sut-is-mock",
               re.compile(r"\blet\s+(?:mut\s+)?sut\s*=\s*Mock\w+::"),
               "same struct under test"),
        Signal("expect-on-sut",
               re.compile(r"\bsut\s*\.\s*expect_\w+\(\)"),
               "expect_helper().returning"),
        Signal("private-reach",
               re.compile(r"#\[cfg\(test\)\]\s*pub\b"),
               "#[cfg(test)] pub"),
    ],
    "java": [
        # Both annotations must decorate the SAME field — only other annotations
        # may sit between them. Two adjacent fields each carrying one of the two
        # is the ordinary `@Spy` collaborator + `@InjectMocks` subject layout.
        Signal("injectmocks-spy",
               re.compile(r"@(?:Spy|InjectMocks)\b(?:\s*@\w+(?:\([^)]*\))?)*\s*@(?:InjectMocks|Spy)\b"),
               "@InjectMocks"),
        # Both branches target the subject: a spy of a collaborator and
        # `doReturn(...).when(collaboratorSpy)` are the doc's "What's fine".
        Signal("spy-sut-stub",
               re.compile(r"\bMockito\.spy\(\s*\w*[sS]ut\b|\bdoReturn\([^;]*\)\s*\.when\(\s*\w*[sS]ut\b"),
               "Mockito.spy(realSut)"),
        Signal("private-reach",
               re.compile(r"\bReflectionTestUtils\.(?:setField|getField|invokeMethod)\(\s*\w*[sS]ut\b"),
               "ReflectionTestUtils.setField(sut"),
    ],
}

EXT_TO_LANG: dict[str, str] = {
    ".py": "python", ".ts": "ts_js", ".tsx": "ts_js",
    ".js": "ts_js", ".jsx": "ts_js", ".mjs": "ts_js",
    ".kt": "kotlin", ".kts": "kotlin", ".rs": "rust", ".java": "java",
}
