"""Unit identity in the code-health ratchet: moves between files, repeated names, TypeScript.

Real git repos, the pinned ruff and the pinned TypeScript, like test_code_health.py. Each
positive case sits next to the control that must keep failing (copies never inherit debt).
"""

from __future__ import annotations

import json
import shutil
import subprocess
from pathlib import Path

import pytest

from scripts.code_health.ts_measure import pinned_typescript, resolve_typescript
from tests.scripts.test_code_health import _LEGACY, _SWALLOW, REPO, _commit, _git, _repo, _verdict

_EDITED = _LEGACY.replace("return 7\n", "return 77\n")  # one constant changed, CC still 22
# Same name and CC as _LEGACY, unrelated body: a new function reusing a deleted name.
_UNRELATED = "def legacy(x):\n" + "".join(
    f"    while x > {i}:\n        x = x // 2 - {i}\n" for i in range(21)) + "    return x\n"
_MODULE_SWALLOW = "try:\n    import json\nexcept Exception:\n    pass\n"


def _base(tmp_path: Path, files: dict[str, str | None]) -> tuple[Path, str]:
    repo, _ = _repo(tmp_path)
    return repo, _commit(repo, files)


# --- M1: module-level code moved to another file keeps its existing hits -----------------

_M1_BASE: dict[str, str | None] = {"pkg/a.py": _LEGACY + "\n\n" + _SWALLOW + "\n\n" + _MODULE_SWALLOW}


@pytest.mark.parametrize("files, blocks", [
    # A0 (control): the block stays, a function is added
    ({"pkg/a.py": _M1_BASE["pkg/a.py"] + "\n\ndef added():\n    return 1\n"}, False),
    # A1: the same block moved unchanged to a new module
    ({"pkg/a.py": _LEGACY + "\n\n" + _SWALLOW, "pkg/a_compat.py": _MODULE_SWALLOW}, False),
    # moved out of a deleted file
    ({"pkg/a.py": None, "pkg/a_legacy.py": _LEGACY + "\n\n" + _SWALLOW,
      "pkg/a_compat.py": "X = 1\n\n" + _MODULE_SWALLOW}, False),
    # a copy (the origin keeps it) is new
    ({"pkg/a_compat.py": _MODULE_SWALLOW}, True),
    # one departed occurrence pays for one arrival, never two
    ({"pkg/a.py": _LEGACY + "\n\n" + _SWALLOW, "pkg/a_compat.py": _MODULE_SWALLOW + "\n" + _MODULE_SWALLOW}, True),
    # dropping one guard never pays for a different one elsewhere: a move is the whole statement
    ({"pkg/a.py": _LEGACY + "\n\n" + _SWALLOW, "pkg/a_compat.py": _MODULE_SWALLOW.replace("json", "yaml")}, True),
    # a module-level hit never pays for one inside a function elsewhere
    ({"pkg/a.py": _LEGACY + "\n\n" + _SWALLOW,
      "pkg/a_compat.py": "def load():\n" + "".join("    " + ln + "\n" for ln in _MODULE_SWALLOW.splitlines())}, True),
])
def test_module_level_hits_follow_code_moved_to_another_file(tmp_path, capsys, files, blocks):
    repo, base = _base(tmp_path, _M1_BASE)
    code, out = _verdict(repo, base, files, capsys)
    assert code == (1 if blocks else 0), out


_ENV_CONST = "import os\n\nCACHED = os.getenv('PATH')\n"


@pytest.mark.parametrize("files, blocks", [
    ({"pkg/c.py": "import os\n", "pkg/d.py": _ENV_CONST}, False),  # a one-line constant moved
    ({"pkg/c.py": "import os\n", "pkg/d.py": _ENV_CONST.replace("PATH", "HOME")}, True),  # not a move
])
def test_module_level_constant_moved_to_another_file(tmp_path, capsys, files, blocks):
    repo, base = _base(tmp_path, {"pkg/c.py": _ENV_CONST})
    code, out = _verdict(repo, base, files, capsys)
    assert code == (1 if blocks else 0), out


# --- M2: a function moved to another file AND edited keeps its cap ----------------------

_LEGACY_SW = _LEGACY.replace("def legacy(x):\n", "def legacy(x):\n" + _SWALLOW.split("\n", 1)[1])


@pytest.mark.parametrize("extra_base, files, blocks", [
    # P1: moved with one constant changed (CC still 22)
    ({}, {"pkg/a.py": _SWALLOW, "pkg/a_legacy.py": _EDITED}, False),
    # P2 (control): the same edit in place
    ({}, {"pkg/a.py": _EDITED + "\n\n" + _SWALLOW}, False),
    # moved, edited and grown: the cap is the old value, not a pass
    ({}, {"pkg/a.py": _SWALLOW, "pkg/a_legacy.py": _EDITED + "    if x == 99:\n        return 99\n"}, True),
    # a copy (the origin still has it) is new code
    ({}, {"pkg/a_legacy.py": _EDITED}, True),
    # an unrelated function that reuses the deleted name is new code
    ({}, {"pkg/a.py": _SWALLOW, "pkg/a_legacy.py": _UNRELATED}, True),
    # two arrivals with that name: neither is provably the moved one
    ({}, {"pkg/a.py": _SWALLOW, "pkg/b.py": _EDITED, "pkg/c.py": _EDITED}, True),
    # the moved+edited function keeps its existing swallow, but not a second one
    ({"pkg/a.py": _LEGACY_SW + "\n\n" + _SWALLOW},
     {"pkg/a.py": _SWALLOW, "pkg/a_legacy.py": _LEGACY_SW.replace("return 7\n", "return 77\n")}, False),
    ({"pkg/a.py": _LEGACY_SW + "\n\n" + _SWALLOW},
     {"pkg/a.py": _SWALLOW, "pkg/a_legacy.py": _LEGACY_SW.replace("return 7\n", "return 77\n")
      + "    try:\n        pass\n    except Exception:\n        pass\n"}, True),
    # a swallow relocated within the moved function is new, as it is in place
    ({"pkg/a.py": _LEGACY_SW + "\n\n" + _SWALLOW},
     {"pkg/a.py": _SWALLOW, "pkg/a_legacy.py": _EDITED + "    try:\n        pass\n    except Exception:\n        pass\n"},
     True),
    ({"pkg/a.py": _LEGACY_SW + "\n\n" + _SWALLOW},
     {"pkg/a.py": _EDITED + "    try:\n        pass\n    except Exception:\n        pass\n\n\n" + _SWALLOW}, True),
])
def test_function_moved_and_edited_keeps_its_cap(tmp_path, capsys, extra_base, files, blocks):
    repo, base = _base(tmp_path, extra_base) if extra_base else _repo(tmp_path)
    code, out = _verdict(repo, base, files, capsys)
    assert code == (1 if blocks else 0), out


# --- B3: repeated names pair by body, not by ordinal (banditburai) ------------------------

_DECORATOR = "def method(name):\n    return lambda f: f\n"


def _handler(route: str, branches: int, fmt: str = "    if x == {i}:\n        return {i}\n") -> str:
    return f'\n\n@method("{route}")\ndef _(x):\n' + "".join(fmt.format(i=i) for i in range(branches)) + "    return x\n"


@pytest.mark.parametrize("files, blocks", [
    # a new CC-22 `_` above the old one (trimmed to 20) is new code
    ({"pkg/h.py": _DECORATOR + _handler("new", 21, '    if x == "s{i}":\n        return "s{i}" * 2\n')
      + _handler("old", 19)}, True),
    # a small `_` above the old one, whose CC-22 body only changes a literal, keeps its cap
    ({"pkg/h.py": _DECORATOR + _handler("new", 0) + _handler("old", 21).replace("return 7\n", "return 77\n")}, False),
    # control: the old handler grows; its cap is 22 wherever it sits
    ({"pkg/h.py": _DECORATOR + _handler("new", 0) + _handler("old", 22)}, True),
])
def test_repeated_python_names_pair_by_body(tmp_path, capsys, files, blocks):
    repo, base = _base(tmp_path, {"pkg/h.py": _DECORATOR + _handler("old", 21)})
    code, out = _verdict(repo, base, files, capsys)
    assert code == (1 if blocks else 0), out


# --- TypeScript identity ----------------------------------------------------------------

def _ts_base(tmp_path: Path, files: dict[str, str | None]) -> tuple[Path, str]:
    if shutil.which("node") is None:
        pytest.skip("node is not installed")
    repo, _ = _repo(tmp_path)
    # The repo's pin; the measurer resolves (or installs) it into the user cache.
    lock = {"packages": {"node_modules/typescript": {"version": pinned_typescript(REPO)}}}
    return repo, _commit(repo, {**files, "package-lock.json": json.dumps(lock)})


def _ifs(n: int, k: int = 0, indent: str = "    ") -> str:
    return "".join(f"{indent}if (x === {i + k}) return {i + k}\n" for i in range(n))


def _obj(name: str, branches: int, k: int = 0) -> str:
    return (f"export const {name} = {{\n  f(x: number): number {{\n{_ifs(branches, k, '    ')}"
            f"    return -1\n  }},\n}}\n")


@pytest.mark.parametrize("files, blocks", [
    # V9a: objects swapped; a.f CC 31 -> 20, b.f CC 3 -> 23: b.f must not inherit a.f's cap
    ({"web/o.ts": _obj("b", 22, 100) + _obj("a", 19)}, True),
    # V9b (control): the same edits in the original order
    ({"web/o.ts": _obj("a", 19) + _obj("b", 22, 100)}, True),
    # control: swapped, only a.f trimmed
    ({"web/o.ts": _obj("b", 2, 100) + _obj("a", 19)}, False),
])
def test_ts_object_literal_methods_are_named_by_their_object(tmp_path, capsys, files, blocks):
    repo, base = _ts_base(tmp_path, {"web/o.ts": _obj("a", 30) + _obj("b", 2, 100)})
    code, out = _verdict(repo, base, files, capsys)
    assert code == (1 if blocks else 0), out


_SIGS = "export function old(x: number): number\nexport function old(x: string): number\n"
_NEW_SIG = "export function old(x: boolean): number\n"
_IMPL = "export function old(x: any): number {\n" + _ifs(21) + "  return -1\n}\n"
_IMPL_EDITED = _IMPL.replace("return 7\n", "return 77\n")


@pytest.mark.parametrize("files, blocks", [
    ({"web/v.ts": _SIGS + _NEW_SIG + _IMPL_EDITED}, False),  # V10a: new overload + edited impl
    ({"web/v.ts": _SIGS + _IMPL_EDITED}, False),  # V10b (control): no new overload
    ({"web/v.ts": _SIGS + _NEW_SIG + _IMPL}, False),  # V10c (control): impl untouched
    ({"web/v.ts": _SIGS + _NEW_SIG + _IMPL.replace("  return -1", "  if (x === 99) return 99\n  return -1")}, True),
])
def test_ts_overload_signatures_are_not_units(tmp_path, capsys, files, blocks):
    repo, base = _ts_base(tmp_path, {"web/v.ts": _SIGS + _IMPL})
    code, out = _verdict(repo, base, files, capsys)
    assert code == (1 if blocks else 0), out


def test_ts_syntax_error_fails_closed(tmp_path, capsys):
    repo, base = _ts_base(tmp_path, {"web/ok.ts": "export const x = 1\n"})
    code, out = _verdict(repo, base, {"web/bad.ts": "export function f(x: number {\n  return x\n}\n"}, capsys)
    assert code == 1 and "MEASURE" in out and "does not parse" in out, out
    code, out = _verdict(repo, base, {"web/bad.ts": "export function f(x: number) {\n  return x\n}\n"}, capsys)
    assert code == 0, out


_TS_LEGACY = "export function legacy(x: number): number {\n" + _ifs(21, indent="  ") + "  return -1\n}\n"


@pytest.mark.parametrize("files, blocks", [
    # renamed, plus one comment line: comments are not code, so it is the same function
    ({"web/l.ts": _TS_LEGACY.replace("legacy", "walk").replace("  return -1", "  // fell through\n  return -1")}, False),
    ({"web/l.ts": _TS_LEGACY.replace("legacy", "walk")}, False),  # control: rename alone
    ({"web/l.ts": _TS_LEGACY + _TS_LEGACY.replace("legacy", "copied")}, True),  # control: a copy
])
def test_ts_identity_ignores_comments(tmp_path, capsys, files, blocks):
    repo, base = _ts_base(tmp_path, {"web/l.ts": _TS_LEGACY})
    code, out = _verdict(repo, base, files, capsys)
    assert code == (1 if blocks else 0), out


# A nested callback's own `legacy` parameter is not the outer function rebinding its name.
_TS_RECURSIVE = ("export function legacy(x: number): number {\n  const ys = [x].map((legacy) => legacy)\n"
                 + _ifs(21, indent="  ") + "  return legacy(ys[0] - 1)\n}\n")


@pytest.mark.parametrize("files, blocks", [
    ({"web/r.ts": _TS_RECURSIVE.replace("function legacy", "function walk").replace("return legacy(", "return walk(")},
     False),
    # control: a rename that leaves the old self-call behind is different code
    ({"web/r.ts": _TS_RECURSIVE.replace("function legacy", "function walk")}, True),
])
def test_ts_recursive_rename_ignores_nested_bindings(tmp_path, capsys, files, blocks):
    repo, base = _ts_base(tmp_path, {"web/r.ts": _TS_RECURSIVE})
    code, out = _verdict(repo, base, files, capsys)
    assert code == (1 if blocks else 0), out


_RUN_DECL = "declare function run(cb: (x: number) => number): void\n"
_SMALL_CB = "run((x: number) => {\n  return x + 1\n})\n"
_BIG_CB = "run((x: number) => {\n" + _ifs(21, indent="  ") + "  return -1\n})\n"


@pytest.mark.parametrize("files, blocks", [
    # a new callback above shifts every `<anon>#n`; the edited CC-22 callback keeps its cap
    ({"web/c.ts": _RUN_DECL + "run((x: number) => {\n  return x * 2\n})\n" + _SMALL_CB
      + _BIG_CB.replace("return 7\n", "return 77\n")}, False),
    # a new CC-22 callback above, the old one trimmed to 20: the new one is new code
    ({"web/c.ts": _RUN_DECL + "run((x: number) => {\n" + "".join(
        f"  while (x > {i}) x = x / 2 - {i}\n" for i in range(21)) + "  return x\n})\n"
      + _SMALL_CB + _BIG_CB.replace(_ifs(2, 19, "  "), "")}, True),
])
def test_ts_anonymous_callbacks_pair_by_body(tmp_path, capsys, files, blocks):
    repo, base = _ts_base(tmp_path, {"web/c.ts": _RUN_DECL + _SMALL_CB + _BIG_CB})
    code, out = _verdict(repo, base, files, capsys)
    assert code == (1 if blocks else 0), out


# --- m5: the TypeScript install honours the repo's .npmrc and fails as a RuntimeError -----

def test_typescript_install_uses_repo_npmrc_and_fails_cleanly(tmp_path, monkeypatch):
    npm = shutil.which("npm")
    if npm is None:
        pytest.skip("npm is not installed")
    repo = tmp_path / "repo"
    repo.mkdir()
    _git(repo, "init", "-q", "-b", "main")
    (repo / "package-lock.json").write_text(
        json.dumps({"packages": {"node_modules/typescript": {"version": pinned_typescript(REPO)}}}),
        encoding="utf-8")
    # An unreachable registry and an empty npm cache: only the repo's .npmrc can say so.
    (repo / ".npmrc").write_text(
        f"registry=http://127.0.0.1:9/\nfetch-retries=0\ncache={tmp_path / 'npm-cache'}\nmin-release-age=14\n",
        encoding="utf-8")
    monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
    with pytest.raises(RuntimeError, match="typescript"):
        resolve_typescript(repo)
    prefix = next((tmp_path / "cache" / "hermes-code-health").iterdir())
    got = subprocess.run([npm, "config", "get", "min-release-age", "--prefix", str(prefix)],
                         cwd=prefix, capture_output=True, text=True, timeout=60, check=True).stdout.strip()
    assert got == "14"
