"""Hexagonal architecture tests — enforce layer dependency rules.

Uses pytestarch for inter-layer dependency rules and ast-based import
scanning for external (third-party) dependency constraints.

Rules enforced:
- domain   -> no import from api, services, infra, persistence
- services -> no import from api, infra, persistence
- api      -> no import from infra, persistence
- infra    -> no import from api, services
- persistence -> no import from api, services, infra
- domain   -> no import of fastapi, sqlalchemy, httpx, opensearchpy
- services -> no import of fastapi
"""

from __future__ import annotations

import ast
from pathlib import Path

import pytest
from pytestarch import Rule, get_evaluable_architecture

# ---------------------------------------------------------------------------
# pytestarch evaluable (project root = document-parser/)
# ---------------------------------------------------------------------------

_PROJECT_ROOT = Path(__file__).resolve().parent.parent

# pytestarch uses the directory name as module prefix when given absolute paths.
# We use the directory name to build qualified module references.
_PREFIX = _PROJECT_ROOT.name  # "document-parser"

_evaluable = get_evaluable_architecture(str(_PROJECT_ROOT), str(_PROJECT_ROOT))


def _mod(layer: str) -> str:
    """Return the fully-qualified pytestarch module name for a layer."""
    return f"{_PREFIX}.{layer}"


# ---------------------------------------------------------------------------
# Helper: collect top-level imports from all .py files in a package
# ---------------------------------------------------------------------------


def _collect_imports(package: str) -> set[str]:
    """Return the set of top-level module names imported by *package*."""
    pkg_path = Path(_PROJECT_ROOT) / package
    imports: set[str] = set()
    for py_file in pkg_path.rglob("*.py"):
        tree = ast.parse(py_file.read_text(), filename=str(py_file))
        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                for alias in node.names:
                    imports.add(alias.name.split(".")[0])
            elif isinstance(node, ast.ImportFrom) and node.module:
                imports.add(node.module.split(".")[0])
    return imports


# ---------------------------------------------------------------------------
# Inter-layer dependency rules (pytestarch)
# ---------------------------------------------------------------------------


class TestDomainLayerIsolation:
    """domain must not depend on any other layer."""

    @pytest.mark.parametrize("forbidden", ["api", "services", "infra", "persistence"])
    def test_domain_does_not_import(self, forbidden: str):
        rule = (
            Rule()
            .modules_that()
            .are_sub_modules_of(_mod("domain"))
            .should_not()
            .import_modules_that()
            .are_sub_modules_of(_mod(forbidden))
        )
        rule.assert_applies(_evaluable)


class TestServicesLayerIsolation:
    """services may import domain only."""

    @pytest.mark.parametrize("forbidden", ["api", "infra", "persistence"])
    def test_services_does_not_import(self, forbidden: str):
        rule = (
            Rule()
            .modules_that()
            .are_sub_modules_of(_mod("services"))
            .should_not()
            .import_modules_that()
            .are_sub_modules_of(_mod(forbidden))
        )
        rule.assert_applies(_evaluable)


class TestApiLayerIsolation:
    """api may import services and domain, but not infra or persistence."""

    @pytest.mark.parametrize("forbidden", ["infra", "persistence"])
    def test_api_does_not_import(self, forbidden: str):
        rule = (
            Rule()
            .modules_that()
            .are_sub_modules_of(_mod("api"))
            .should_not()
            .import_modules_that()
            .are_sub_modules_of(_mod(forbidden))
        )
        rule.assert_applies(_evaluable)


class TestInfraLayerIsolation:
    """infra may import domain (ports), but not api or services."""

    @pytest.mark.parametrize("forbidden", ["api", "services"])
    def test_infra_does_not_import(self, forbidden: str):
        rule = (
            Rule()
            .modules_that()
            .are_sub_modules_of(_mod("infra"))
            .should_not()
            .import_modules_that()
            .are_sub_modules_of(_mod(forbidden))
        )
        rule.assert_applies(_evaluable)


class TestPersistenceLayerIsolation:
    """persistence may import domain, but not api, services, or infra."""

    @pytest.mark.parametrize("forbidden", ["api", "services", "infra"])
    def test_persistence_does_not_import(self, forbidden: str):
        rule = (
            Rule()
            .modules_that()
            .are_sub_modules_of(_mod("persistence"))
            .should_not()
            .import_modules_that()
            .are_sub_modules_of(_mod(forbidden))
        )
        rule.assert_applies(_evaluable)


# ---------------------------------------------------------------------------
# External dependency rules (ast-based)
# ---------------------------------------------------------------------------

_DOMAIN_FORBIDDEN_EXTERNALS = {"fastapi", "sqlalchemy", "httpx", "opensearchpy"}
_SERVICES_FORBIDDEN_EXTERNALS = {"fastapi"}


class TestDomainExternalDependencies:
    """domain must not import infrastructure-specific third-party libraries."""

    @pytest.mark.parametrize("lib", sorted(_DOMAIN_FORBIDDEN_EXTERNALS))
    def test_domain_does_not_import_external(self, lib: str):
        imports = _collect_imports("domain")
        assert lib not in imports, f"domain imports forbidden external library '{lib}'"


class TestServicesExternalDependencies:
    """services must not import web-framework libraries."""

    @pytest.mark.parametrize("lib", sorted(_SERVICES_FORBIDDEN_EXTERNALS))
    def test_services_does_not_import_external(self, lib: str):
        imports = _collect_imports("services")
        assert lib not in imports, f"services imports forbidden external library '{lib}'"


# ---------------------------------------------------------------------------
# Convention: ports live exclusively in domain.ports
# ---------------------------------------------------------------------------


class TestPortConvention:
    """Protocol definitions (ports) must live in domain.ports only."""

    def test_no_protocol_outside_domain_ports(self):
        """No Protocol subclass should be defined outside domain/ports.py."""
        ports_file = Path(_PROJECT_ROOT) / "domain" / "ports.py"
        for py_file in Path(_PROJECT_ROOT).rglob("*.py"):
            if py_file == ports_file:
                continue
            # Skip test files and __pycache__
            if "tests" in py_file.parts or "__pycache__" in py_file.parts:
                continue
            tree = ast.parse(py_file.read_text(), filename=str(py_file))
            for node in ast.walk(tree):
                if isinstance(node, ast.ClassDef):
                    for base in node.bases:
                        base_name = _get_name(base)
                        if base_name == "Protocol":
                            pytest.fail(
                                f"Protocol '{node.name}' defined in {py_file.relative_to(_PROJECT_ROOT)}"
                                f" — ports must live in domain/ports.py"
                            )


def _get_name(node: ast.expr) -> str:
    """Extract a simple name from an AST expression node."""
    if isinstance(node, ast.Name):
        return node.id
    if isinstance(node, ast.Attribute):
        return node.attr
    return ""
