#!/usr/bin/env python3
"""resolver.py — cross-file scope resolution (extracted from gate.py; modular-hierarchy refactor 1).

Pure, deterministic, stdlib-only. Answers ONE question: given a target TS/TSX file, which first-party
files are in scope (the target + its first-party import bodies) — resolving pnpm/npm/yarn workspace
aliases and following re-export barrels to definition sites. NO LLM, NO bundling, NO oracle — those
stay in gate.py. This file has ZERO dependency on gate.py (the cluster references no gate.py runtime
symbol). Loaded by PATH (spec_from_file_location) per project convention — no __init__.py.

Public surface: build_workspace_aliases, find_repo_root, resolve, resolve_through_barrel,
first_party_value_imports, collect_deps. CRITICAL is the priority-ordering regex (a hint, NOT a gate)."""
import glob, json, os, re, sys

# #36: the payments-name regex is DEMOTED from a pull-in GATE to a PRIORITY hint — ALL first-party
# imports are inlined as context (a bug hides behind any call, not just payment-named ones); CRITICAL-named
# imports are merely ORDERED FIRST so a tight --max-scope budget inlines the likeliest-dangerous first.
CRITICAL = re.compile(
    r"claim|lock|guard|settle|payout|refund|charge|transfer|release|mediat|authoriz|"
    r"verif|reserve|consume|debit|credit|ledger|escrow|hold|webhook|idempoten",
    re.I,
)
IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
# import { a, b } from 'spec';  |  import a from 'spec';  |  import * as a from 'spec'
IMPORT = re.compile(r"import\s+(?:type\s+)?(.+?)\s+from\s+['\"]([^'\"]+)['\"]", re.S)
# re-export barrel lines: `export { a, b as c } from 'spec'`  and  `export * from 'spec'` / `export * as ns from 'spec'`
REEXPORT_NAMED = re.compile(r"export\s*\{([^}]*)\}\s*from\s*['\"]([^'\"]+)['\"]")
REEXPORT_STAR = re.compile(r"export\s*\*\s*(?:as\s+\w+\s+)?from\s*['\"]([^'\"]+)['\"]")


def _read_pnpm_workspace_globs(repo_root):
    """Parse the `packages:` list from pnpm-workspace.yaml WITHOUT a YAML dep (deterministic Python-only gate).
    Handles the only shape in evidence: a top-level `packages:` key, then `- '<glob>'` list items."""
    p = os.path.join(repo_root, "pnpm-workspace.yaml")
    if not os.path.isfile(p):
        return []
    globs, in_pkgs = [], False
    for line in open(p, encoding="utf-8", errors="replace"):
        s = line.rstrip("\n")
        if re.match(r"^packages:\s*$", s):
            in_pkgs = True
            continue
        if in_pkgs:
            m = re.match(r"\s*-\s*['\"]?([^'\"#]+?)['\"]?\s*(?:#.*)?$", s)
            if m:
                globs.append(m.group(1).strip())
            elif s and not s[0].isspace():  # next top-level key ends the list
                break
    return globs


def _read_npm_workspaces(repo_root):
    """Best-effort npm/yarn `workspaces` globs from the root package.json (NOT measured — pnpm is the validated
    path). Supports both the array form and the {packages:[...]} object form."""
    pj = os.path.join(repo_root, "package.json")
    try:
        data = json.load(open(pj, encoding="utf-8"))
    except (OSError, ValueError):
        return []
    ws = data.get("workspaces")
    if isinstance(ws, list):
        return [g for g in ws if isinstance(g, str)]
    if isinstance(ws, dict) and isinstance(ws.get("packages"), list):
        return [g for g in ws["packages"] if isinstance(g, str)]
    return []


def _exports_target(val):
    """A package.json `exports` value -> the path string. String as-is; condition object -> import/module/
    default/node/require, else first nested string."""
    if isinstance(val, str):
        return val
    if isinstance(val, dict):
        for k in ("import", "module", "default", "node", "require"):
            if isinstance(val.get(k), str):
                return val[k]
        for v in val.values():
            t = _exports_target(v)
            if t:
                return t
    return None


def _dist_to_src(path):
    """A built package may point exports at dist/, which the git-driven walk excludes. Remap to the src/ twin
    when it exists (defensive — zync's exports point at source, but a built package would not)."""
    norm = path.replace(os.sep, "/")
    if "/dist/" in norm:
        twin = norm.replace("/dist/", "/src/")
        for cand in (twin, re.sub(r"\.m?js$", ".ts", twin)):
            if os.path.isfile(cand):
                return cand
    return path


def _existing_source(path):
    """Mirror resolve()'s on-disk candidate logic: strip .js, try .ts/.tsx/itself/index.ts. -> file or None."""
    base = re.sub(r"\.js$", "", path)
    for cand in (path, base + ".ts", base + ".tsx", base, os.path.join(base, "index.ts")):
        if os.path.isfile(cand):
            return cand
    return None


def build_workspace_aliases(repo_root):
    """pnpm-workspace.yaml (or npm/yarn `workspaces`) + each package.json -> [(specifier, source_file)] aliases
    resolve() consumes. For each workspace package: the bare name '@scope/pkg' -> its exports['.']/module/main
    source entry; each exports subpath '@scope/pkg/x' -> its target. Subpath (longer) specifiers are sorted
    FIRST so resolve()'s exact match picks the most specific. dist/ targets remap to the src/ twin."""
    aliases = []
    globs = _read_pnpm_workspace_globs(repo_root) or _read_npm_workspaces(repo_root)
    for g in globs:
        for pkg_dir in sorted(glob.glob(os.path.join(repo_root, g))):
            pj = os.path.join(pkg_dir, "package.json")
            if not os.path.isfile(pj):
                continue
            try:
                data = json.load(open(pj, encoding="utf-8"))
            except (OSError, ValueError):
                continue
            name = data.get("name")
            if not name:
                continue
            exp = data.get("exports")
            found = []
            if isinstance(exp, dict):
                for key, val in exp.items():
                    t = _exports_target(val)
                    if not t:
                        continue
                    f = _existing_source(_dist_to_src(os.path.normpath(os.path.join(pkg_dir, t))))
                    if not f:
                        continue
                    spec = name if key == "." else name + "/" + (key[2:] if key.startswith("./") else key)
                    found.append((spec, f))
            if not any(s == name for s, _ in found):  # bare-name fallback if exports had no '.'
                cand = (_exports_target(exp["."]) if isinstance(exp, dict) and "." in exp else None) \
                    or data.get("module") or data.get("main") or "src/index.ts"
                f = _existing_source(_dist_to_src(os.path.normpath(os.path.join(pkg_dir, cand))))
                if f:
                    found.append((name, f))
            aliases.extend(found)
    aliases.sort(key=lambda t: -len(t[0]))  # longest specifier first: exact subpath wins before the bare name
    return aliases


def find_repo_root(start):
    """Walk up from `start` to the workspace root (pnpm-workspace.yaml) or git root (.git). Falls back to the
    target's own directory if neither is found (single-file/no-workspace runs degrade to today's behavior)."""
    d = os.path.dirname(os.path.abspath(start))
    while True:
        if os.path.isfile(os.path.join(d, "pnpm-workspace.yaml")) or os.path.isdir(os.path.join(d, ".git")):
            return d
        parent = os.path.dirname(d)
        if parent == d:
            return os.path.dirname(os.path.abspath(start))
        d = parent


def resolve(spec, base_dir, aliases):
    """import specifier -> file path on disk (apply alias, strip .js, try .ts/.tsx/index)."""
    p = None
    for pre, root in aliases:
        if spec == pre:                       # whole specifier maps to one file/dir (e.g. '@zync/auth')
            p = root
            break
        pre_slash = pre if pre.endswith("/") else pre + "/"
        if spec.startswith(pre_slash):        # prefix alias (e.g. '@/' -> root): join the remainder
            p = os.path.join(root, spec[len(pre_slash):])
            break
    if p is None:
        if spec.startswith("."):
            p = os.path.normpath(os.path.join(base_dir, spec))
        else:
            return None  # bare package import — out of scope
    p = re.sub(r"\.js$", "", p)
    for cand in (p + ".ts", p + ".tsx", p, os.path.join(p, "index.ts")):
        if os.path.isfile(cand):
            return cand
    return None


def _barrel_named_source(text, want):
    """If `want` is re-exported by an `export { ... } from 'spec'` line, return (spec, upstream_name) — handling
    `export { internal as want }` (the upstream name in `spec` is `internal`). Else None."""
    for m in REEXPORT_NAMED.finditer(text):
        names_blob, spec = m.group(1), m.group(2)
        for piece in names_blob.split(","):
            piece = piece.strip()
            if not piece:
                continue
            parts = re.split(r"\s+as\s+", piece)
            local, exported = parts[0].strip(), parts[-1].strip()
            if exported == want:
                return spec, local
    return None


def _defines_local(text, want):
    """True if `want` is locally DEFINED-and-exported in this file (NOT via a re-export `from`). A local
    definition WINS over an `export * from ...` aggregator: `resolve_through_barrel` runs on EVERY first-party
    import, so a file that BOTH defines `want` AND carries `export *` would otherwise mis-resolve `want` to the
    star target — inlining/scanning the wrong file while a sink in the local def goes unscanned = a SILENT
    FALSE-CLEAN (the cardinal failure). Local def must short-circuit before the star branch is consulted."""
    w = re.escape(want)
    # `export default function want` / `export (async) function|const|let|var|class|enum want`
    if re.search(r"\bexport\s+(?:default\s+)?(?:async\s+)?(?:function|const|let|var|class|enum)\s+" + w + r"\b", text):
        return True
    # local `export { want }` / `export { internal as want }` WITHOUT a trailing `from` (a re-export has `from`)
    for m in re.finditer(r"\bexport\s*\{([^}]*)\}(?!\s*from)", text):
        for piece in m.group(1).split(","):
            parts = re.split(r"\s+as\s+", piece.strip())
            if parts[-1].strip() == want:
                return True
    return False


def resolve_through_barrel(file, symbol, aliases, max_hops=3):
    """Follow re-export barrels from `file` to the file that DEFINES `symbol`. Returns the definition file, or
    `file` unchanged when the symbol is locally defined or cannot be followed within max_hops. Logs to stderr on
    cap hit. `export *` lines are followed breadth-first (the symbol may arrive via any). Barrel-following is
    RESOLUTION (one correct target per transparent re-export), NOT a tunable depth — the cap is a cycle/pathology
    backstop only. A re-export from a bare package (node_modules) is out of scope -> bundle the barrel as-is."""
    seen = set()
    frontier = [(file, symbol, 0)]
    while frontier:
        f, want, hop = frontier.pop(0)
        if f in seen:
            continue
        seen.add(f)
        try:
            text = open(f, encoding="utf-8", errors="replace").read()
        except OSError:
            continue
        named = _barrel_named_source(text, want)
        if named:
            spec, upstream = named
            nxt = resolve(spec, os.path.dirname(f), aliases)
            if not nxt:
                return f  # re-exported from a bare package (out of scope)
            if hop + 1 >= max_hops:
                print(f"[barrel] hop cap {max_hops} reached resolving `{symbol}` from {os.path.relpath(file)}",
                      file=sys.stderr)
                return nxt
            frontier.insert(0, (nxt, upstream, hop + 1))  # follow the precise edge depth-first
            continue
        if _defines_local(text, want):
            return f  # locally defined here -> WINS over any `export *` aggregator (prevents a false-clean)
        stars = REEXPORT_STAR.findall(text)
        if not stars:
            return f  # not re-exported and no star-aggregator -> treat this as the def site
        if hop + 1 >= max_hops:
            print(f"[barrel] hop cap {max_hops} reached (export*) resolving `{symbol}` from {os.path.relpath(file)}",
                  file=sys.stderr)
            return f
        for spec in stars:
            nxt = resolve(spec, os.path.dirname(f), aliases)
            if nxt and nxt not in seen:
                frontier.append((nxt, want, hop + 1))  # export* targets breadth-first
    return file


def first_party_value_imports(path, aliases):
    """parse imports -> [(definition_file, symbol)] for ALL first-party VALUE imports (barrel re-exports
    followed to the def; type-only lines skipped).

    #36: a bug can hide behind ANY imported call, not just payment-named ones, so every first-party
    import the file pulls is candidate context. node_modules are already excluded by resolve() (bare
    specifier -> None). `import type {...}` lines carry no runtime behavior -> skipped (a missed type
    can't hide a runtime bug; inlining one only wastes tokens). CRITICAL-named symbols are ORDERED FIRST
    so a tight --max-scope budget inlines the likeliest-dangerous bodies first (priority, NOT a gate)."""
    try:
        src = open(path, encoding="utf-8", errors="replace").read()
    except OSError:
        return []
    base = os.path.dirname(path)
    crit_first, rest = [], []
    for m in IMPORT.finditer(src):
        if re.match(r"import\s+type\b", m.group(0)):
            continue  # type-only line: no runtime behavior to hide a bug
        names_blob, spec = m.group(1), m.group(2)
        syms = [s for s in IDENT.findall(names_blob) if s not in ("type", "as", "from")]
        if not syms:
            continue
        f = resolve(spec, base, aliases)
        if not f:
            continue  # node_modules / unresolved specifier
        for s in syms:
            deff = resolve_through_barrel(f, s, aliases)  # follow a re-export barrel to the symbol's def file
            (crit_first if CRITICAL.search(s) else rest).append((deff, s))
    return crit_first + rest


def collect_deps(target, aliases, max_hops, max_files):
    """first-party value-import file bodies to INLINE as dependency context for the target's review.

    BFS from the target; budget-capped at max_files inlined bodies. Returns (deps, dropped):
    deps = ordered [(file, symbol, importer_basename)]; dropped = {file: reason} (NEVER silently skipped).
    depth=1 (default) = caller + its DIRECT callees = the VALIDATED band-2 shape (the 3/3 spike). depth>1
    inlines deps-of-deps (budget-bounded, NOT rate-validated) — raise it deliberately, not by default."""
    deps, seen, dropped = [], {target}, {}
    frontier = [(target, 0)]
    while frontier:
        f, hop = frontier.pop(0)
        if hop >= max_hops:
            continue
        for dep_f, sym in first_party_value_imports(f, aliases):
            if dep_f in seen:
                continue
            if len(deps) >= max_files:
                dropped.setdefault(
                    dep_f,
                    f"NOT inlined — --max-scope {max_files} reached (import `{sym}` from {os.path.basename(f)})")
                continue
            seen.add(dep_f)
            deps.append((dep_f, sym, os.path.basename(f)))
            frontier.append((dep_f, hop + 1))
    return deps, dropped
