"""test_gate.py — deterministic gate for the cross-file resolver (Part B). Loads gate.py by PATH
(project convention — no package import, no __init__.py)."""
import importlib.util, os, tempfile, json

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def _load(name, path):
    spec = importlib.util.spec_from_file_location(name, path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


gate = _load("sg_gate", os.path.join(ROOT, "orchestrator", "gate.py"))
resolver = _load("sg_resolver", os.path.join(ROOT, "orchestrator", "resolver.py"))


def _ws_tree():
    """A pnpm workspace: packages/db with exports['.']->src/index.ts and ['./queries']->src/queries/index.ts,
    plus a built package whose exports point at dist/ (must remap to the src/ twin)."""
    d = tempfile.mkdtemp()
    open(os.path.join(d, "pnpm-workspace.yaml"), "w").write("packages:\n  - 'packages/*'\n")
    db = os.path.join(d, "packages/db/src/queries")
    os.makedirs(db, exist_ok=True)
    open(os.path.join(d, "packages/db/package.json"), "w").write(json.dumps({
        "name": "@zync/db", "type": "module",
        "exports": {".": "./src/index.ts", "./queries": "./src/queries/index.ts"},
    }))
    open(os.path.join(d, "packages/db/src/index.ts"), "w").write("export { createDb } from './client'")
    open(os.path.join(d, "packages/db/src/queries/index.ts"), "w").write("export const tenantQuery = () => {}")
    built = os.path.join(d, "packages/built/src")
    os.makedirs(built, exist_ok=True)
    open(os.path.join(d, "packages/built/package.json"), "w").write(json.dumps({
        "name": "@zync/built", "exports": {".": "./dist/index.js"},
    }))
    open(os.path.join(d, "packages/built/src/index.ts"), "w").write("export const x = 1")  # the src twin
    return d


def test_build_workspace_aliases_resolves_bare_and_subpath_and_dist_remap():
    d = _ws_tree()
    aliases = resolver.build_workspace_aliases(d)
    amap = dict(aliases)
    assert amap["@zync/db"] == os.path.join(d, "packages/db/src/index.ts")            # bare -> barrel
    assert amap["@zync/db/queries"] == os.path.join(d, "packages/db/src/queries/index.ts")  # subpath -> direct
    assert amap["@zync/built"] == os.path.join(d, "packages/built/src/index.ts")      # dist -> src remap
    # subpath specifier (longer) must sort BEFORE the bare name so resolve()'s exact match picks it
    keys = [s for s, _ in aliases]
    assert keys.index("@zync/db/queries") < keys.index("@zync/db")


def test_resolve_uses_workspace_alias_for_bare_specifier():
    d = _ws_tree()
    aliases = resolver.build_workspace_aliases(d)
    f = resolver.resolve("@zync/db", os.path.join(d, "packages/db"), aliases)
    assert f == os.path.join(d, "packages/db/src/index.ts")
    f2 = resolver.resolve("@zync/db/queries", os.path.join(d, "packages/db"), aliases)
    assert f2 == os.path.join(d, "packages/db/src/queries/index.ts")


def _barrel_tree():
    """importer -> barrel (re-export) -> def. Plus an `as`-rename chain and an `export *` chain."""
    d = tempfile.mkdtemp()
    os.makedirs(os.path.join(d, "pkg"), exist_ok=True)
    open(os.path.join(d, "pkg/barrel.ts"), "w").write(
        "export { buildSession } from './def'\n"
        "export { internalMint as mintToken } from './mint'\n"
        "export * from './starred'\n")
    open(os.path.join(d, "pkg/def.ts"), "w").write("export function buildSession(){ return { enforce2fa:false } }")
    open(os.path.join(d, "pkg/mint.ts"), "w").write("export function internalMint(){ return 't' }")
    open(os.path.join(d, "pkg/starred.ts"), "w").write("export function starHelper(){ return 1 }")
    return d


def test_resolve_through_barrel_named_reexport_reaches_def():
    d = _barrel_tree()
    barrel = os.path.join(d, "pkg/barrel.ts")
    assert resolver.resolve_through_barrel(barrel, "buildSession", []) == os.path.join(d, "pkg/def.ts")


def test_resolve_through_barrel_follows_as_rename():
    d = _barrel_tree()
    barrel = os.path.join(d, "pkg/barrel.ts")
    # importer sees `mintToken`; barrel maps it to `internalMint` from ./mint
    assert resolver.resolve_through_barrel(barrel, "mintToken", []) == os.path.join(d, "pkg/mint.ts")


def test_resolve_through_barrel_follows_export_star():
    d = _barrel_tree()
    barrel = os.path.join(d, "pkg/barrel.ts")
    assert resolver.resolve_through_barrel(barrel, "starHelper", []) == os.path.join(d, "pkg/starred.ts")


def test_resolve_through_barrel_noop_when_symbol_is_local():
    d = _barrel_tree()
    deff = os.path.join(d, "pkg/def.ts")
    assert resolver.resolve_through_barrel(deff, "buildSession", []) == deff   # defined here -> unchanged


def test_resolve_through_barrel_local_def_wins_over_export_star():
    """A file that BOTH defines a symbol locally AND has `export * from './sub'`: importing that symbol must
    resolve to THIS file, NOT the star target. Without local-def precedence the gate would inline/scan the
    wrong file and a sink in the local def is a SILENT FALSE-CLEAN (the cardinal failure)."""
    d = tempfile.mkdtemp()
    os.makedirs(os.path.join(d, "pkg"), exist_ok=True)
    open(os.path.join(d, "pkg/utils.ts"), "w").write(
        "export function localThing(){ return 1 }\nexport * from './sub'\n")
    open(os.path.join(d, "pkg/sub.ts"), "w").write("export function other(){ return 2 }")
    utils = os.path.join(d, "pkg/utils.ts")
    assert resolver.resolve_through_barrel(utils, "localThing", []) == utils   # local def wins, NOT sub.ts


def test_first_party_value_imports_returns_def_not_barrel():
    d = _barrel_tree()
    importer = os.path.join(d, "pkg/importer.ts")
    open(importer, "w").write("import { buildSession } from './barrel'\nbuildSession()")
    pairs = resolver.first_party_value_imports(importer, [])
    files = {f for f, _s in pairs}
    assert os.path.join(d, "pkg/def.ts") in files          # resolved THROUGH the barrel to the def
    assert os.path.join(d, "pkg/barrel.ts") not in files    # the barrel indirection is gone


def _xfile_barrel_tree():
    """importer -> @zync/db barrel -> def body carrying an oracle-class marker, via a real pnpm workspace so
    build_workspace_aliases + resolve_through_barrel BOTH fire (the full Part B path)."""
    d = tempfile.mkdtemp()
    open(os.path.join(d, "pnpm-workspace.yaml"), "w").write("packages:\n  - 'packages/*'\n  - 'apps/*'\n")
    db = os.path.join(d, "packages/db/src")
    os.makedirs(db, exist_ok=True)
    open(os.path.join(d, "packages/db/package.json"), "w").write(json.dumps({
        "name": "@zync/db", "exports": {".": "./src/index.ts"}}))
    open(os.path.join(db, "index.ts"), "w").write("export { buildSessionPayload } from './session'\n")  # barrel
    open(os.path.join(db, "session.ts"), "w").write(
        "// DEF_BODY_MARKER\nexport function buildSessionPayload(a){ return { enforce_2fa: a.enforce2fa ?? false } }\n")
    app = os.path.join(d, "apps/api/src/routes")
    os.makedirs(app, exist_ok=True)
    importer = os.path.join(app, "refresh.ts")
    open(importer, "w").write("import { buildSessionPayload } from '@zync/db'\nbuildSessionPayload({})\n")
    return d, importer


def test_bundle_and_oracle_set_reach_barrel_resolved_def():
    d, importer = _xfile_barrel_tree()
    aliases = resolver.build_workspace_aliases(d)
    deps, dropped = resolver.collect_deps(importer, aliases, 1, 60)
    dep_files = [f for f, _s, _imp in deps]
    def_file = os.path.join(d, "packages/db/src/session.ts")
    barrel = os.path.join(d, "packages/db/src/index.ts")
    assert def_file in dep_files            # collect_deps reached THROUGH the barrel to the def
    assert barrel not in dep_files          # the barrel indirection is not what gets inlined
    # (a) LLM bundle carries the DEF BODY, not the barrel re-export line
    bundle = gate.build_bundle(importer, deps)
    assert "DEF_BODY_MARKER" in bundle
    assert "enforce_2fa: a.enforce2fa ?? false" in bundle
    # (b) #42: the file list main() hands to run_oracle_set CONTAINS the resolved def file
    oracle_set_files = [importer] + dep_files
    assert def_file in oracle_set_files     # oracle leg follows the resolution -> no silent false-clean


# ---------------------------------------------------------------------------
# finding-citation spine: parse_findings extracts a snippet + line_hint anchor.
# Input format is REAL v2 output (MEASURED, /tmp/sg_fmt_roll_B.txt): bulleted sub-fields, FENCED Code block.
# (The plan's numbered-sub-field illustration does not match real output — see the spine design SoT.)
# ---------------------------------------------------------------------------
REAL_FINDING = (
    "1. **Reset token never invalidated after use — unlimited reuse within TTL**\n"
    "   - **Location:** `vuln.ts:91-92`\n"
    "   - **Code:**\n"
    "     ```ts\n"
    "     const passwordHash = await hashPassword(password)\n"
    "     await resetUserPassword(db, userId, passwordHash)\n"
    "     ```\n"
    "   - **Severity:** critical\n"
    "   - **Trigger / Exploit (Pass 2, Pass 6):** replay within TTL.\n"
)


def test_parse_findings_returns_five_tuple_with_anchor():
    rows = gate.parse_findings(REAL_FINDING)
    assert len(rows) == 1
    title, sev, body, snippet, line_hint = rows[0]
    assert sev == "critical"
    # snippet = FIRST non-empty line inside the Code fence, STRIPPED (not the whole multi-line span:
    # a multi-line string can never substring-match one file line -> a single line is the stable anchor).
    assert snippet == "const passwordHash = await hashPassword(password)"
    assert line_hint == 91  # first integer on the Location bullet line


def test_parse_findings_inline_backtick_code_fallback():
    rows = gate.parse_findings(
        "1. **NaN coercion disables the rate limit**\n"
        "   - **Location:** `vuln.ts:45`\n"
        "   - **Code:** `const count = countRaw ? Number(countRaw) : 0`\n"
        "   - **Severity:** low\n")
    assert len(rows) == 1
    _, _, _, snippet, line_hint = rows[0]
    assert snippet == "const count = countRaw ? Number(countRaw) : 0"
    assert line_hint == 45


def test_parse_findings_absence_class_no_code_kept_unanchored():
    # an absence-class finding may omit a quotable defect substring -> KEEP it, snippet="" (no-false-clean:
    # never drop a finding for lack of an anchor; the prompt's absence rule depends on this).
    rows = gate.parse_findings(
        "1. **Missing ownership check on the refund route (IDOR)**\n"
        "   - **Location:** `refund.ts:88`\n"
        "   - **Severity:** high\n"
        "   - **Trigger / Exploit:** any caller refunds another tenant's order.\n")
    assert len(rows) == 1
    title, sev, _, snippet, line_hint = rows[0]
    assert sev == "high" and snippet == "" and line_hint == 88
    assert "IDOR" in title


def test_parse_findings_real_capture_old_prompt_no_regression():
    # additive guarantee: the new parser must NOT change which lines count as finding headers.
    # /tmp/sg_fmt_roll.txt is a REAL roll under the OLD prompt (5 findings, no Code field).
    cap = "/tmp/sg_fmt_roll.txt"
    if not os.path.exists(cap):
        return  # capture is a dev artifact; skip cleanly off the capture host (no false-green either way)
    rows = gate.parse_findings(open(cap, encoding="utf-8").read())
    assert len(rows) == 5, f"header parsing regressed: got {len(rows)} findings"
    for _, _, _, snippet, _ in rows:
        assert snippet == "", "old-prompt output has no Code field -> snippet must be empty"
