"""Entrypoint loading and scan() compatibility verification.

Pure, unit-testable helpers — no side effects beyond sys.path mutation and imports.

Exported error types:
    ScanEntrypointError — import failure, missing/non-callable scan.
    ScanSignatureError  — scan call-shape incompatible with the scaffold contract.

Both are importable from here AND from the package root (scaffold).
"""

import importlib
import inspect
import sys
from typing import Any, Callable


class ScanEntrypointError(Exception):
    """Import failure, or scan is missing / not callable."""


class ScanSignatureError(Exception):
    """scan's call shape is incompatible with scan(inputs, ctx)."""


def add_path_to_sys_path(path: str) -> None:
    """Insert `path` at sys.path[0]. Idempotent — never inserts a duplicate."""
    if path not in sys.path:
        sys.path.insert(0, path)


def import_entrypoint(path: str, entrypoint: str):
    """Put `path` on sys.path, then import `entrypoint` by module name.

    The recipe expresses `entrypoint` as a FILENAME (e.g. `scan.py`, the shape the
    B1 launcher writes verbatim into the launch config and the §4a entrypoint FS
    check validates). importlib needs a MODULE name, so a trailing `.py` is
    stripped here — the only place the filename<->module-name bridge lives.

    Raises ScanEntrypointError on any import/syntax/missing-sibling failure,
    chaining the underlying exception.
    """
    add_path_to_sys_path(path)
    module_name = entrypoint[:-3] if entrypoint.endswith(".py") else entrypoint
    try:
        # Force re-import when the module has been previously imported so that
        # tests can write fresh modules to tmp_path without cache collisions.
        if module_name in sys.modules:
            del sys.modules[module_name]
        return importlib.import_module(module_name)
    except Exception as exc:
        raise ScanEntrypointError(
            f"Failed to import entrypoint '{entrypoint}' from '{path}': {exc}"
        ) from exc


def locate_scan(module) -> Callable:
    """Return the module's `scan` callable.

    Raises ScanEntrypointError if scan is missing, None, or not callable.
    """
    scan = getattr(module, "scan", _MISSING)
    if scan is _MISSING:
        raise ScanEntrypointError(
            f"Module '{getattr(module, '__name__', module)}' has no 'scan' attribute."
        )
    if scan is None or not callable(scan):
        raise ScanEntrypointError(
            f"'scan' in module '{getattr(module, '__name__', module)}' is not callable "
            f"(got {type(scan).__name__!r})."
        )
    return scan


def verify_scan_signature(scan: Callable) -> None:
    """Verify scan's call shape is compatible with the v2 contract scan(inputs, ctx).

    v2 calls scan with EXACTLY two positionals (inputs, ctx). A signature must be
    callable that way.

    Accepted shapes:
        scan(inputs, ctx)                     — 2 required (the canonical shape)
        scan(inputs, ctx, extra=None)         — 2 required + defaulted extra
        scan(inputs, ctx, *args)              — variadic absorbs nothing extra here
        scan(*args, **kwargs)                 — fully variadic, accepts anything

    Rejected shapes:
        scan()                                — 0 required
        scan(inputs)                          — 1 required
        scan(params, state, client)           — 3 required (the OLD v1 client shape)
        scan(a, b, c, d)                      — >=4 required

    The contract is now EXACTLY two positionals: a 3rd REQUIRED positional cannot
    be satisfied by scan(inputs, ctx) and is rejected.

    Raises ScanSignatureError naming the found arity on mismatch.
    """
    try:
        sig = inspect.signature(scan)
    except (ValueError, TypeError):
        # Builtins or C extensions without introspectable signatures: accept.
        return

    params = list(sig.parameters.values())

    has_var_positional = any(
        p.kind == inspect.Parameter.VAR_POSITIONAL for p in params
    )

    # Required (non-defaulted) fixed positional params.
    required_fixed = [
        p for p in params
        if p.kind in (
            inspect.Parameter.POSITIONAL_ONLY,
            inspect.Parameter.POSITIONAL_OR_KEYWORD,
        )
        and p.default is inspect.Parameter.empty
    ]
    n_required = len(required_fixed)

    # A *args (or *args/**kwargs) signature accepts the 2-positional call as long
    # as it has no MORE than 2 required fixed positionals ahead of *args.
    if has_var_positional:
        if n_required > 2:
            raise ScanSignatureError(
                f"scan requires {n_required} fixed positional params; the v2 "
                f"contract calls scan(inputs, ctx) — exactly 2. Found arity: {n_required}."
            )
        return

    if n_required < 2:
        raise ScanSignatureError(
            f"scan has {n_required} required positional param(s); the v2 contract "
            f"requires exactly 2 (inputs, ctx). Found arity: {n_required}."
        )

    if n_required > 2:
        raise ScanSignatureError(
            f"scan has {n_required} required positional params; the v2 contract "
            f"calls scan(inputs, ctx) — exactly 2. A 3rd required positional "
            f"(the old v1 client shape) is no longer accepted. Found arity: {n_required}."
        )


# Private sentinel — distinct from None so getattr(..., None) doesn't hide a
# real `scan = None` attribute.
class _MissingSentinel:
    pass


_MISSING = _MissingSentinel()
