"""Declarative static import contracts; dynamic imports are deliberately unscanned."""

import ast
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path

_REPO_ROOT = Path(__file__).resolve().parents[2]
_PACKAGE_ROOT = _REPO_ROOT / "src" / "free_claude_code"
_PACKAGE_NAME = "free_claude_code"

ALLOWED_PACKAGE_DEPENDENCIES: dict[str, set[str]] = {
    "config": set(),
    "core": set(),
    "application": {"config", "core"},
    "messaging": {"core"},
    "providers": {"application", "config", "core"},
    "api": {"application", "config", "core"},
    "cli": {"config", "core"},
    "runtime": {
        "api",
        "application",
        "cli",
        "config",
        "core",
        "messaging",
        "providers",
    },
}

IMPORT_EXCEPTIONS: dict[tuple[str, str], str] = {
    (
        "free_claude_code.cli.commands",
        "free_claude_code.runtime.bootstrap",
    ): (
        "Owner: installed server command. "
        "Reason: the command delegates construction to the process composition root."
    ),
}

FACADE_ONLY_BOUNDARIES = {
    "free_claude_code.core.openai_responses",
    "free_claude_code.messaging.trees",
    "free_claude_code.providers.openai_chat",
}

OPTIONAL_IMPORT_OWNERS = {
    "librosa": "free_claude_code.messaging.transcription",
    "torch": "free_claude_code.messaging.transcription",
    "transformers": "free_claude_code.messaging.transcription",
    "riva": "free_claude_code.providers.nvidia_nim.voice",
}


@dataclass(frozen=True, slots=True)
class ImportRecord:
    importer: str
    imported: str
    path: str
    line: int
    inside_function: bool

    def describe(self) -> str:
        return f"{self.path}:{self.line}: {self.importer} -> {self.imported}"


class _ImportVisitor(ast.NodeVisitor):
    def __init__(
        self,
        *,
        importer: str,
        importer_is_package: bool,
        modules: set[str],
        path: str,
    ) -> None:
        self._importer = importer
        self._importer_is_package = importer_is_package
        self._modules = modules
        self._path = path
        self._function_depth = 0
        self.records: list[ImportRecord] = []

    def visit_Import(self, node: ast.Import) -> None:
        for alias in node.names:
            self._record(alias.name, node.lineno)

    def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
        base = _resolve_import_from_base(
            importer=self._importer,
            importer_is_package=self._importer_is_package,
            level=node.level,
            module=node.module,
        )
        if base is None:
            return
        for alias in node.names:
            candidate = f"{base}.{alias.name}"
            imported = candidate if candidate in self._modules else base
            self._record(imported, node.lineno)

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        self._visit_function(node)

    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
        self._visit_function(node)

    def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
        self._function_depth += 1
        self.generic_visit(node)
        self._function_depth -= 1

    def _record(self, imported: str, line: int) -> None:
        self.records.append(
            ImportRecord(
                importer=self._importer,
                imported=imported,
                path=self._path,
                line=line,
                inside_function=self._function_depth > 0,
            )
        )


def test_package_dependencies_follow_declarative_policy() -> None:
    modules = _module_paths(_PACKAGE_ROOT)
    records = _scan_imports(_PACKAGE_ROOT)
    actual_packages = _ownership_roots(set(modules), _PACKAGE_NAME)

    assert set(ALLOWED_PACKAGE_DEPENDENCIES) == actual_packages
    assert all(
        (_PACKAGE_ROOT / package / "__init__.py").is_file()
        for package in actual_packages
    )
    assert all(
        dependency in actual_packages and dependency != package
        for package, dependencies in ALLOWED_PACKAGE_DEPENDENCIES.items()
        for dependency in dependencies
    )

    observed_dependencies: set[tuple[str, str]] = set()
    observed_exceptions: set[tuple[str, str]] = set()
    offenders: list[str] = []
    for record in records:
        source_package = _top_level_package(record.importer)
        target_package = _top_level_package(record.imported)
        if source_package is None or target_package is None:
            continue
        if source_package == target_package:
            continue
        exact_edge = (record.importer, record.imported)
        if exact_edge in IMPORT_EXCEPTIONS:
            observed_exceptions.add(exact_edge)
            continue
        if target_package in ALLOWED_PACKAGE_DEPENDENCIES[source_package]:
            observed_dependencies.add((source_package, target_package))
            continue
        offenders.append(record.describe())

    declared_dependencies = {
        (package, dependency)
        for package, dependencies in ALLOWED_PACKAGE_DEPENDENCIES.items()
        for dependency in dependencies
    }
    assert sorted(offenders) == []
    assert declared_dependencies - observed_dependencies == set()
    assert set(IMPORT_EXCEPTIONS) - observed_exceptions == set()
    assert all(
        "Owner:" in reason and "Reason:" in reason
        for reason in IMPORT_EXCEPTIONS.values()
    )
    for source, target in IMPORT_EXCEPTIONS:
        source_package = _top_level_package(source)
        target_package = _top_level_package(target)
        assert source_package is not None
        assert target_package is not None
        assert target_package not in ALLOWED_PACKAGE_DEPENDENCIES[source_package]
    expected_package_modules = {
        _PACKAGE_NAME,
        *(f"{_PACKAGE_NAME}.{package}" for package in actual_packages),
    }
    assert set(modules) >= expected_package_modules


def test_first_party_imports_use_the_installable_namespace() -> None:
    offenders = _legacy_first_party_import_offenders(
        _scan_imports(_PACKAGE_ROOT),
        set(ALLOWED_PACKAGE_DEPENDENCIES),
    )

    assert offenders == []


def test_openai_chat_collaborators_have_explicit_ownership_boundaries() -> None:
    provider_root = _PACKAGE_ROOT / "providers" / "openai_chat"

    assert _provider_backchannel_offenders(provider_root) == []


def test_provider_backchannel_detector_reports_untyped_private_access(
    tmp_path: Path,
) -> None:
    provider_root = tmp_path / "openai_chat"
    _write_module(
        provider_root / "sample" / "runner.py",
        "from typing import Any\n"
        "\n"
        "class Runner:\n"
        "    def __init__(self, provider: object | Any) -> None:\n"
        "        self._provider = provider\n"
        "\n"
        "    def run(self) -> object:\n"
        "        return self._provider._send()\n",
    )

    assert _provider_backchannel_offenders(provider_root) == [
        "sample/runner.py:4: untyped provider collaborator",
        "sample/runner.py:8: private provider member _send outside provider.py",
    ]


def test_legacy_first_party_import_detector_rejects_bare_owner_names() -> None:
    record = ImportRecord(
        importer="free_claude_code.api.routes",
        imported="core.anthropic",
        path="free_claude_code/api/routes.py",
        line=7,
        inside_function=False,
    )

    assert _legacy_first_party_import_offenders([record], {"api", "core"}) == [
        "free_claude_code/api/routes.py:7: "
        "free_claude_code.api.routes -> core.anthropic"
    ]


def test_ownership_root_discovery_includes_modules_and_namespace_directories(
    tmp_path: Path,
) -> None:
    package_root = tmp_path / "sample"
    _write_module(package_root / "__init__.py")
    _write_module(package_root / "declared" / "__init__.py")
    _write_module(package_root / "namespace" / "module.py")
    _write_module(package_root / "root_module.py")

    roots = _ownership_roots(set(_module_paths(package_root)), "sample")

    assert roots == {"declared", "namespace", "root_module"}


def test_descendants_do_not_import_ancestor_package_facades() -> None:
    modules = _module_paths(_PACKAGE_ROOT)
    packages = {
        module for module, path in modules.items() if path.name == "__init__.py"
    }
    offenders = _ancestor_facade_offenders(_scan_imports(_PACKAGE_ROOT), packages)

    assert offenders == []


def test_static_first_party_import_graph_is_acyclic() -> None:
    modules = set(_module_paths(_PACKAGE_ROOT))
    graph = {module: set() for module in modules}
    for record in _scan_imports(_PACKAGE_ROOT):
        if record.imported in modules:
            graph[record.importer].add(record.imported)

    assert _cyclic_components(graph) == []


def test_cycle_detector_reports_exact_strongly_connected_components() -> None:
    graph = {
        "package.a": {"package.b"},
        "package.b": {"package.a"},
        "package.c": set(),
        "package.self": {"package.self"},
    }

    assert _cyclic_components(graph) == [
        ("package.a", "package.b"),
        ("package.self",),
    ]


def test_external_consumers_use_owned_package_facades() -> None:
    offenders: list[str] = []
    for record in _scan_imports(_PACKAGE_ROOT):
        for facade in FACADE_ONLY_BOUNDARIES:
            if record.importer == facade or record.importer.startswith(f"{facade}."):
                continue
            if record.imported.startswith(f"{facade}."):
                offenders.append(record.describe())

    assert sorted(offenders) == []


def test_import_scanner_resolves_absolute_relative_and_lazy_imports(
    tmp_path: Path,
) -> None:
    package_root = tmp_path / "sample"
    _write_module(package_root / "__init__.py")
    _write_module(package_root / "alpha" / "__init__.py", "PUBLIC = object()\n")
    _write_module(package_root / "alpha" / "sibling.py")
    _write_module(package_root / "beta" / "__init__.py")
    _write_module(package_root / "beta" / "absolute.py")
    _write_module(package_root / "beta" / "relative.py")
    _write_module(package_root / "beta" / "lazy.py")
    _write_module(
        package_root / "alpha" / "consumer.py",
        "\n".join(
            (
                "import sample.beta.absolute",
                "from ..beta import relative",
                "from . import sibling",
                "from . import PUBLIC",
                "def load():",
                "    import sample.beta.lazy",
                "",
            )
        ),
    )

    records = _scan_imports(package_root)
    resolved = {
        (record.imported, record.inside_function)
        for record in records
        if record.importer == "sample.alpha.consumer"
    }

    assert resolved == {
        ("sample.alpha", False),
        ("sample.alpha.sibling", False),
        ("sample.beta.absolute", False),
        ("sample.beta.relative", False),
        ("sample.beta.lazy", True),
    }


def test_import_scanner_distinguishes_facade_symbols_from_sibling_modules(
    tmp_path: Path,
) -> None:
    package_root = tmp_path / "sample"
    _write_module(package_root / "__init__.py")
    _write_module(package_root / "owner" / "__init__.py", "PUBLIC = object()\n")
    _write_module(package_root / "owner" / "sibling.py")
    _write_module(
        package_root / "owner" / "consumer.py",
        "from . import PUBLIC, sibling\n",
    )
    modules = _module_paths(package_root)
    packages = {
        module for module, path in modules.items() if path.name == "__init__.py"
    }

    offenders = _ancestor_facade_offenders(_scan_imports(package_root), packages)

    assert offenders == [
        "sample/owner/consumer.py:1: sample.owner.consumer -> sample.owner"
    ]


def test_anthropic_request_boundaries_use_the_protocol_model() -> None:
    """Known Messages fields must not cross core/provider boundaries by duck typing."""
    roots = [
        _PACKAGE_ROOT / "core" / "anthropic",
        _PACKAGE_ROOT / "providers",
    ]
    request_names = {"request", "request_data"}
    protocol_fields = {
        "extra_body",
        "max_tokens",
        "messages",
        "model",
        "stop_sequences",
        "system",
        "temperature",
        "thinking",
        "tool_choice",
        "tools",
        "top_k",
        "top_p",
    }
    offenders: list[str] = []

    for root in roots:
        for path in root.rglob("*.py"):
            tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
            relative = path.relative_to(_REPO_ROOT).as_posix()
            for node in ast.walk(tree):
                if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
                    arguments = [
                        *node.args.posonlyargs,
                        *node.args.args,
                        *node.args.kwonlyargs,
                    ]
                    for argument in arguments:
                        if argument.arg.lstrip("_") not in request_names:
                            continue
                        annotation_names = (
                            {
                                child.id
                                for child in ast.walk(argument.annotation)
                                if isinstance(child, ast.Name)
                            }
                            if argument.annotation is not None
                            else set()
                        )
                        if argument.annotation is None or annotation_names & {
                            "Any",
                            "Mapping",
                        }:
                            offenders.append(
                                f"{relative}:{argument.lineno}: "
                                f"{node.name}({argument.arg}) is not concrete"
                            )
                if not isinstance(node, ast.Call) or not (
                    isinstance(node.func, ast.Name)
                    and node.func.id == "getattr"
                    and len(node.args) >= 2
                    and isinstance(node.args[0], ast.Name)
                    and node.args[0].id.lstrip("_") in request_names
                    and isinstance(node.args[1], ast.Constant)
                    and node.args[1].value in protocol_fields
                ):
                    continue
                offenders.append(
                    f"{relative}:{node.lineno}: "
                    f"getattr({node.args[0].id}, {node.args[1].value!r})"
                )

    assert sorted(offenders) == []


def test_core_does_not_import_provider_transport_sdks() -> None:
    forbidden_roots = {"aiohttp", "httpx", "openai"}
    offenders = [
        record.describe()
        for record in _scan_imports(_PACKAGE_ROOT)
        if (
            record.importer == "free_claude_code.core"
            or record.importer.startswith("free_claude_code.core.")
        )
        and record.imported.split(".", 1)[0] in forbidden_roots
    ]

    assert sorted(offenders) == []


def test_providers_do_not_own_wire_error_type_literals() -> None:
    wire_types = {
        "api_error",
        "authentication_error",
        "billing_error",
        "invalid_request_error",
        "not_found_error",
        "overloaded_error",
        "permission_error",
        "rate_limit_error",
        "request_too_large",
        "timeout_error",
    }
    offenders: list[str] = []
    for path in (_PACKAGE_ROOT / "providers").rglob("*.py"):
        tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
        offenders.extend(
            f"{path.relative_to(_REPO_ROOT).as_posix()}:{node.lineno}: {node.value}"
            for node in ast.walk(tree)
            if isinstance(node, ast.Constant) and node.value in wire_types
        )

    assert sorted(offenders) == []


def test_optional_dependencies_have_one_lazy_owner() -> None:
    seen: set[str] = set()
    offenders: list[str] = []
    for record in _scan_imports(_PACKAGE_ROOT):
        dependency = record.imported.split(".", 1)[0]
        owner = OPTIONAL_IMPORT_OWNERS.get(dependency)
        if owner is None:
            continue
        seen.add(dependency)
        if record.importer != owner or not record.inside_function:
            offenders.append(record.describe())

    assert seen == set(OPTIONAL_IMPORT_OWNERS)
    assert sorted(offenders) == []


def test_runtime_imports_without_optional_voice_dependencies() -> None:
    blocked = sorted(OPTIONAL_IMPORT_OWNERS)
    script = "\n".join(
        (
            "import importlib.abc",
            "import sys",
            f"BLOCKED = {blocked!r}",
            "class Blocker(importlib.abc.MetaPathFinder):",
            "    def find_spec(self, fullname, path=None, target=None):",
            "        if fullname.split('.', 1)[0] in BLOCKED:",
            "            raise ModuleNotFoundError(fullname)",
            "        return None",
            "sys.meta_path.insert(0, Blocker())",
            "import free_claude_code.runtime.bootstrap",
            "import free_claude_code.api.app",
        )
    )

    completed = subprocess.run(
        [sys.executable, "-c", script],
        capture_output=True,
        check=False,
        text=True,
    )

    assert completed.returncode == 0, completed.stderr or completed.stdout


def test_supported_messaging_facade_is_explicit() -> None:
    import free_claude_code.messaging as facade
    from free_claude_code.messaging.managed_protocols import (
        ManagedClaudeSessionManagerProtocol,
        ManagedClaudeSessionProtocol,
    )
    from free_claude_code.messaging.models import IncomingMessage, MessageScope
    from free_claude_code.messaging.platforms.ports import OutboundMessenger

    expected = {
        "IncomingMessage": IncomingMessage,
        "ManagedClaudeSessionManagerProtocol": ManagedClaudeSessionManagerProtocol,
        "ManagedClaudeSessionProtocol": ManagedClaudeSessionProtocol,
        "MessageScope": MessageScope,
        "OutboundMessenger": OutboundMessenger,
    }

    assert set(facade.__all__) == set(expected)
    assert all(getattr(facade, name) is value for name, value in expected.items())


def test_message_tree_mutability_stays_behind_its_facade() -> None:
    import free_claude_code.messaging.trees as facade

    for internal_owner in {
        "MessageNode",
        "MessageTree",
        "TreeQueueProcessor",
        "TreeRepository",
    }:
        assert internal_owner not in facade.__all__
        assert not hasattr(facade, internal_owner)


def _module_paths(package_root: Path) -> dict[str, Path]:
    return {
        _module_name(package_root, path): path for path in package_root.rglob("*.py")
    }


def _module_name(package_root: Path, path: Path) -> str:
    relative = path.relative_to(package_root)
    module_parts = (
        relative.parent.parts
        if path.name == "__init__.py"
        else relative.with_suffix("").parts
    )
    return ".".join((package_root.name, *module_parts))


def _scan_imports(package_root: Path) -> list[ImportRecord]:
    module_paths = _module_paths(package_root)
    modules = set(module_paths)
    records: list[ImportRecord] = []
    for importer, path in sorted(module_paths.items()):
        visitor = _ImportVisitor(
            importer=importer,
            importer_is_package=path.name == "__init__.py",
            modules=modules,
            path=path.relative_to(package_root.parent).as_posix(),
        )
        visitor.visit(ast.parse(path.read_text(encoding="utf-8"), filename=str(path)))
        records.extend(visitor.records)
    return records


def _resolve_import_from_base(
    *,
    importer: str,
    importer_is_package: bool,
    level: int,
    module: str | None,
) -> str | None:
    if level == 0:
        return module
    package_parts = importer.split(".")
    if not importer_is_package:
        package_parts.pop()
    parents_to_remove = level - 1
    if parents_to_remove > len(package_parts):
        return None
    if parents_to_remove:
        del package_parts[-parents_to_remove:]
    if module is not None:
        package_parts.extend(module.split("."))
    return ".".join(package_parts) or None


def _top_level_package(module: str) -> str | None:
    parts = module.split(".")
    if len(parts) < 2 or parts[0] != _PACKAGE_NAME:
        return None
    return parts[1]


def _ownership_roots(modules: set[str], package_name: str) -> set[str]:
    roots: set[str] = set()
    for module in modules:
        parts = module.split(".")
        if len(parts) >= 2 and parts[0] == package_name:
            roots.add(parts[1])
    return roots


def _legacy_first_party_import_offenders(
    records: list[ImportRecord],
    owner_names: set[str],
) -> list[str]:
    offenders = [
        record.describe()
        for record in records
        if record.imported.split(".", 1)[0] in owner_names
    ]
    return sorted(offenders)


def _provider_backchannel_offenders(provider_root: Path) -> list[str]:
    offenders: list[str] = []
    for path in sorted(provider_root.rglob("*.py")):
        relative_path = path.relative_to(provider_root).as_posix()
        tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
        for node in ast.walk(tree):
            if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
                arguments = (
                    *node.args.posonlyargs,
                    *node.args.args,
                    *node.args.kwonlyargs,
                )
                offenders.extend(
                    f"{relative_path}:{argument.lineno}: untyped provider collaborator"
                    for argument in arguments
                    if argument.arg == "provider"
                    and _annotation_is_any(argument.annotation)
                )
            if (
                path.name != "provider.py"
                and isinstance(node, ast.Attribute)
                and node.attr.startswith("_")
                and _is_provider_reference(node.value)
            ):
                offenders.append(
                    f"{relative_path}:{node.lineno}: private provider member "
                    f"{node.attr} outside provider.py"
                )
    return sorted(offenders)


def _annotation_is_any(annotation: ast.expr | None) -> bool:
    if annotation is None:
        return False
    return any(
        (isinstance(node, ast.Name) and node.id == "Any")
        or (isinstance(node, ast.Attribute) and node.attr == "Any")
        for node in ast.walk(annotation)
    )


def _is_provider_reference(expression: ast.expr) -> bool:
    return (isinstance(expression, ast.Name) and expression.id == "provider") or (
        isinstance(expression, ast.Attribute)
        and isinstance(expression.value, ast.Name)
        and expression.value.id == "self"
        and expression.attr == "_provider"
    )


def _ancestor_facade_offenders(
    records: list[ImportRecord],
    packages: set[str],
) -> list[str]:
    offenders = [
        record.describe()
        for record in records
        if record.imported in packages
        and record.importer.startswith(f"{record.imported}.")
    ]
    return sorted(offenders)


def _cyclic_components(graph: dict[str, set[str]]) -> list[tuple[str, ...]]:
    index = 0
    indices: dict[str, int] = {}
    lowlinks: dict[str, int] = {}
    stack: list[str] = []
    on_stack: set[str] = set()
    components: list[tuple[str, ...]] = []

    def visit(node: str) -> None:
        nonlocal index
        indices[node] = index
        lowlinks[node] = index
        index += 1
        stack.append(node)
        on_stack.add(node)

        for successor in sorted(graph[node]):
            if successor not in indices:
                visit(successor)
                lowlinks[node] = min(lowlinks[node], lowlinks[successor])
            elif successor in on_stack:
                lowlinks[node] = min(lowlinks[node], indices[successor])

        if lowlinks[node] != indices[node]:
            return
        component: list[str] = []
        while True:
            member = stack.pop()
            on_stack.remove(member)
            component.append(member)
            if member == node:
                break
        if len(component) > 1 or node in graph[node]:
            components.append(tuple(sorted(component)))

    for node in sorted(graph):
        if node not in indices:
            visit(node)
    return sorted(components)


def _write_module(path: Path, text: str = "") -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text, encoding="utf-8")
