"""Will this scanner load?

Runs the checks that can be answered without executing a tick: every file compiles, the entrypoint
imports, and the module exposes an entry function of the right shape. No MCP client is built, no
network is touched, and `scan()` is never called — so this needs no credentials and is safe to run
across a whole fleet.

Deliberately reuses `entrypoint_loader` rather than reimplementing the import. That module is what
the scaffold itself uses before its first tick, so a package that passes here has satisfied the
same contract production applies, not an approximation of it.

    python3 -m scaffold.import_check <scanner-root> [entrypoint]

Emits one JSON document on stdout. Findings are returned as data — this exits 0 whenever the check
itself ran, and reserves a non-zero exit for being unable to run at all.
"""

import ast
import json
import os
import sys
import traceback
from typing import Any, Dict, List, Optional

from .entrypoint_loader import (
    ScanEntrypointError,
    ScanSignatureError,
    import_entrypoint,
    locate_scan,
    verify_scan_signature,
)

# Modules a scanner is expected to reach for. Anything here means data is arriving by a route the
# runtime cannot see, which the scan contract does not sanction.
# Routes that fetch data where the runtime cannot see it. `subprocess` belongs here for the same
# reason as `socket`: shelling out to curl is a data source, and one that also escapes the
# in-process guards entirely.
_UNSANCTIONED_SOURCES = {
    "urllib", "urllib.request", "requests", "http", "http.client", "httpx", "socket", "subprocess",
}


def _stdlib_names() -> set:
    """Names that would shadow a standard-library module. Falls back for older interpreters."""
    names = getattr(sys, "stdlib_module_names", None)
    if names:
        return set(names)
    return {
        "abc", "argparse", "ast", "asyncio", "base64", "collections", "contextlib", "copy", "csv",
        "datetime", "decimal", "enum", "functools", "hashlib", "http", "io", "itertools", "json",
        "logging", "math", "os", "pathlib", "queue", "random", "re", "select", "shutil", "signal",
        "socket", "statistics", "string", "struct", "subprocess", "sys", "tempfile", "threading",
        "time", "types", "typing", "urllib", "uuid", "warnings",
    }


def _python_files(root: str) -> List[str]:
    """Every .py beneath `root`, skipping caches. Sorted so output is stable across runs."""
    out = []
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d != "__pycache__" and not d.startswith(".")]
        for name in sorted(filenames):
            if name.endswith(".py"):
                out.append(os.path.join(dirpath, name))
    return sorted(out)


def _own_prefix(root: str) -> str:
    """The root as a path prefix, separator included.

    Without the separator, root `/x/scanner` claims `/x/scanner-utils/evil.py` as the author's own
    code — plausible with sibling per-instance scanner directories, and it makes `relpath` produce
    `../scanner-utils/...` in a finding that is supposed to name a file inside the package.
    """
    return os.path.abspath(root).rstrip(os.sep) + os.sep


def _rel(root: str, path: str) -> str:
    try:
        return os.path.relpath(path, root)
    except ValueError:
        return path


def _finding(code: str, **fields: Any) -> Dict[str, Any]:
    return {"code": code, **fields}


def _chain(exc: BaseException) -> List[str]:
    """The full formatted traceback including every chained cause.

    The three-argument form is used because it is correct on every interpreter this runs on.
    """
    return traceback.format_exception(type(exc), exc, exc.__traceback__)


def _deepest_frame_in(root: str, exc: BaseException) -> Optional[Dict[str, Any]]:
    """The last frame inside the scanner directory, walking the whole cause chain.

    This is the attribution that matters. An import failure three modules deep reports as a failure
    to import the entrypoint, which names the wrong file — the reader then goes looking in a file
    that is fine. Walking back to the deepest frame that is still the author's code puts the finding
    where the defect is.
    """
    best = None
    seen = set()
    current: Optional[BaseException] = exc
    while current is not None and id(current) not in seen:
        seen.add(id(current))
        tb = current.__traceback__
        while tb is not None:
            filename = tb.tb_frame.f_code.co_filename
            if os.path.abspath(filename).startswith(_own_prefix(root)):
                best = {"file": _rel(root, filename), "line": tb.tb_lineno}
            tb = tb.tb_next
        current = current.__cause__ or current.__context__
    return best


def _import_chain(root: str, exc: BaseException) -> List[str]:
    """The author's modules involved in the failure, entrypoint first."""
    chain: List[str] = []
    seen = set()
    current: Optional[BaseException] = exc
    while current is not None and id(current) not in seen:
        seen.add(id(current))
        tb = current.__traceback__
        while tb is not None:
            filename = os.path.abspath(tb.tb_frame.f_code.co_filename)
            if filename.startswith(_own_prefix(root)):
                name = _rel(root, filename)
                if name not in chain:
                    chain.append(name)
            tb = tb.tb_next
        current = current.__cause__ or current.__context__
    return chain


def check_compiles(root: str) -> List[Dict[str, Any]]:
    """Compile every file, including ones the entrypoint never imports.

    An import only executes the modules it reaches. A helper that is broken but only referenced
    inside a branch stays invisible until that branch runs — in production, at some later hour.

    Uses the builtin compiler rather than `py_compile` so nothing is written to disk: this runs
    against directories it does not own, and a check that leaves artefacts behind is a check people
    stop running.
    """
    findings = []
    for path in _python_files(root):
        try:
            # Bytes, so the file's own PEP 263 declaration governs. Decoding as UTF-8 here would
            # raise on a file CPython reports as `SyntaxError: Non-UTF-8 code` — a defect in the
            # package, which was escaping as an unhandled crash and being blamed on the environment.
            with open(path, "rb") as handle:
                source = handle.read()
        except OSError as exc:
            findings.append(_finding("SYNTAX", file=_rel(root, path), line=None, message=str(exc)))
            continue
        try:
            compile(source, path, "exec")
        except SyntaxError as exc:
            findings.append(
                _finding("SYNTAX", file=_rel(root, path), line=exc.lineno, message=exc.msg)
            )
        except ValueError as exc:
            # Null bytes, an undecodable encoding declaration, and similar cases the compiler
            # rejects outright. `UnicodeDecodeError` is a ValueError and lands here too.
            findings.append(_finding("SYNTAX", file=_rel(root, path), line=None, message=str(exc)))
    return findings


def check_folder_shape(root: str) -> List[Dict[str, Any]]:
    """Shapes that are legal Python but wrong for how the scaffold imports this directory."""
    findings = []

    if os.path.isfile(os.path.join(root, "__init__.py")):
        findings.append(_finding("SCANNERS_IS_PACKAGE", file="__init__.py"))

    stdlib = _stdlib_names()
    for name in sorted(os.listdir(root)):
        if not name.endswith(".py") or name == "__init__.py":
            continue
        stem = name[:-3]
        if stem in stdlib:
            findings.append(_finding("SHADOWS_STDLIB", file=name, module=stem))

    return findings


def check_data_sources(root: str) -> List[Dict[str, Any]]:
    """Imports that fetch data by a route the runtime cannot observe.

    Read statically rather than at run time: the import may sit on a branch this check never
    executes, and the point is to surface it either way.
    """
    findings = []
    for path in _python_files(root):
        try:
            # Bytes, not text: PEP 263 lets a file declare its own encoding, and `compile`/`ast.parse`
            # honour it. Reading as UTF-8 here would raise UnicodeDecodeError on a file CPython
            # itself calls a SyntaxError — reported, before this fix, as a broken environment.
            with open(path, "rb") as handle:
                tree = ast.parse(handle.read(), filename=path)
        except (OSError, SyntaxError, ValueError):
            continue  # a file that will not parse is already reported by the compile sweep
        for node in ast.walk(tree):
            names = []
            if isinstance(node, ast.Import):
                names = [alias.name for alias in node.names]
            elif isinstance(node, ast.ImportFrom) and node.module:
                names = [node.module]
            for name in names:
                root_name = name.split(".")[0]
                if name in _UNSANCTIONED_SOURCES or root_name in _UNSANCTIONED_SOURCES:
                    findings.append(
                        _finding("NON_MCP_SOURCE", file=_rel(root, path), line=node.lineno, module=name)
                    )
    return findings


def check_entrypoint(root: str, entrypoint: str) -> List[Dict[str, Any]]:
    """Import the entrypoint for real and verify the contract it must satisfy.

    Restores the module cache and the search path afterwards. The loader clears the *entrypoint*
    before importing, but not the siblings it pulls in — so without this, a `scoring` left behind by
    one check satisfies the next scanner's `import scoring` even when that file does not exist, and
    a broken package reports clean. Production spawns a process per scanner and never sees it; a
    function that is only correct the first time it is called is a trap regardless.
    """
    before_modules = set(sys.modules)
    before_path = list(sys.path)
    try:
        return _check_entrypoint_inner(root, entrypoint)
    finally:
        for name in set(sys.modules) - before_modules:
            del sys.modules[name]
        sys.path[:] = before_path


def _check_entrypoint_inner(root: str, entrypoint: str) -> List[Dict[str, Any]]:
    try:
        module = import_entrypoint(root, entrypoint)
    except ScanEntrypointError as exc:
        cause = exc.__cause__ or exc
        return [
            _finding(
                "IMPORT_FAILED",
                message=str(cause),
                error_type=type(cause).__name__,
                where=_deepest_frame_in(root, exc),
                import_chain=_import_chain(root, exc),
                traceback="".join(_chain(exc)),
            )
        ]
    except BaseException as exc:  # noqa: BLE001 — a module can raise anything at import time
        return [
            _finding(
                "IMPORT_FAILED",
                message=str(exc),
                error_type=type(exc).__name__,
                where=_deepest_frame_in(root, exc),
                import_chain=_import_chain(root, exc),
                traceback="".join(_chain(exc)),
            )
        ]

    try:
        scan = locate_scan(module)
    except ScanEntrypointError as exc:
        code = "SCAN_NOT_CALLABLE" if "not callable" in str(exc) else "SCAN_MISSING"
        return [_finding(code, message=str(exc), file=entrypoint)]

    try:
        verify_scan_signature(scan)
    except ScanSignatureError as exc:
        return [_finding("SCAN_BAD_ARITY", message=str(exc), file=entrypoint)]

    return []


def run(root: str, entrypoint: Optional[str]) -> Dict[str, Any]:
    if not os.path.isdir(root):
        return {"ok": False, "error": "scanner root is not a directory: {}".format(root)}

    findings = check_compiles(root)
    findings.extend(check_folder_shape(root))
    findings.extend(check_data_sources(root))

    # A file that will not compile cannot be imported, and the import error would only restate the
    # syntax error less clearly.
    entry_blocked = any(f["code"] == "SYNTAX" and f["file"] == entrypoint for f in findings)
    if entrypoint and not entry_blocked:
        findings.extend(check_entrypoint(root, entrypoint))

    return {"ok": True, "root": root, "entrypoint": entrypoint, "findings": findings}


def main(argv: Optional[List[str]] = None) -> int:
    argv = list(sys.argv if argv is None else argv)
    if len(argv) < 2:
        sys.stderr.write("usage: python3 -m scaffold.import_check <scanner-root> [entrypoint]\n")
        return 2
    result = run(argv[1], argv[2] if len(argv) > 2 else None)
    sys.stdout.write(json.dumps(result))
    return 0 if result.get("ok") else 1


if __name__ == "__main__":
    sys.exit(main())
