"""Shared primitives for Polytropos."""

import logging
import os
import tomllib
import warnings
from pathlib import Path
from typing import Any

from packaging.specifiers import SpecifierSet
from packaging.version import Version
from pathspec import PathSpec

from ..ph import _odoo

_logger = logging.getLogger(__name__)


def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
    """Deep merge two dictionaries.

    - If value is a dict, recurse
    - If value is a list, extend base list with override list
    - Otherwise, override the value

    Args:
        base: Base dictionary
        override: Override dictionary

    Returns:
        Merged dictionary
    """
    result = base.copy()
    for key, value in override.items():
        if key in result and isinstance(result[key], dict) and isinstance(value, dict):
            result[key] = deep_merge(result[key], value)
        elif (
            key in result and isinstance(result[key], list) and isinstance(value, list)
        ):
            combined = result[key] + value
            try:
                result[key] = list(dict.fromkeys(combined))
            except TypeError:
                seen = []
                for item in combined:
                    if item not in seen:
                        seen.append(item)
                result[key] = seen
        else:
            result[key] = value
    return result


def get_odoo_release(
    config_settings: dict[str, Any] | None = None,
    pyproject_data: dict[str, Any] | None = None,
) -> str:
    """Get the Odoo release to use for building.

    Resolution order:
    1. config_settings["odoo_release"]
    2. ODOO_RELEASE environment variable
    3. odoo.release.series (if odoo installed)
    4. tool.polytropos.default_odoo_release (if pyproject_data provided)

    Args:
        config_settings: PEP 517 config_settings dictionary
        pyproject_data: Parsed pyproject.toml (optional, for default fallback)

    Returns:
        Odoo release string (e.g., "17.0")

    Raises:
        ValueError: If release cannot be determined
    """
    if config_settings and (release := config_settings.get("odoo_release")):
        return str(release)

    if release := os.environ.get("ODOO_RELEASE"):
        return release

    try:
        return str(_odoo().release.series)
    except (ImportError, AttributeError):
        _logger.debug("Could not detect Odoo release from installed package")

    if pyproject_data:
        polytropos_config = pyproject_data.get("tool", {}).get("polytropos", {})
        if default_release := polytropos_config.get("default_odoo_release"):
            return str(default_release)

    raise ValueError(
        "Odoo release not specified. Use --config-settings odoo_release=19.0, "
        "set ODOO_RELEASE environment variable, set "
        "[tool.polytropos].default_odoo_release in pyproject.toml, or make "
        "the odoo package importable at build time."
    )


def build_full_version(module_version: str, odoo_release: str) -> str:
    """Build the full version string for the module.

    Combines the Odoo release (A.B) with the module version (X.Y.Z)
    to form A.B.X.Y.Z (5+ components, required by manifestoo-core).

    Args:
        module_version: Module version (e.g., "1.0.0", "1.2.3")
        odoo_release: Odoo release (e.g., "17.0")

    Returns:
        Full version (e.g., "17.0.1.0.0", "18.0.1.2.3")

    Raises:
        ValueError: If module version has fewer than 3 components
    """
    release_version = Version(odoo_release)
    module_version_obj = Version(module_version)

    release_parts = [release_version.major, release_version.minor]

    mod_parts: list[int] = []
    if hasattr(module_version_obj, "release") and isinstance(
        module_version_obj.release, tuple
    ):
        mod_parts.extend(
            part for part in module_version_obj.release if part is not None
        )

    if not mod_parts:
        mod_parts = [module_version_obj.major, module_version_obj.minor]

    if len(mod_parts) < 3:
        raise ValueError(
            f"Module version must have at least 3 components (X.Y.Z), got {module_version}"
        )

    return ".".join(str(p) for p in release_parts + mod_parts)


def get_pyproject_data(root: Path) -> dict[str, Any]:
    """Load and parse pyproject.toml.

    Args:
        root: Path to the project root

    Returns:
        Parsed pyproject.toml as dictionary

    Raises:
        ValueError: If pyproject.toml not found
    """
    pyproject_path = root / "pyproject.toml"
    if not pyproject_path.exists():
        raise ValueError("pyproject.toml not found")

    with open(pyproject_path, "rb") as f:
        return tomllib.load(f)


def check_dynamic_version(pyproject_data: dict[str, Any]) -> None:
    """Check that version is declared as dynamic if present.

    Without dynamic=['version'], tools like uv cannot properly resolve
    dependencies that depend on the Odoo release (e.g., odoo-addon-foo==18.0.*).

    Args:
        pyproject_data: Parsed pyproject.toml
    """
    project = pyproject_data.get("project", {})
    version = project.get("version")
    dynamic = project.get("dynamic", [])

    if version is not None and "version" not in dynamic:
        warnings.warn(
            "version is defined in [project] but not listed in dynamic. "
            "Add 'dynamic = [\"version\"]' to [project] to enable proper "
            "dependency resolution. Without this, tools like uv may fail to "
            "resolve dependencies that depend on the Odoo release "
            "(e.g., odoo-addon-foo==18.0.*).",
            UserWarning,
            stacklevel=2,
        )


def parse_release(release: str) -> Version:
    """Parse release string to Version object.

    Args:
        release: Release string (e.g., "17.0")

    Returns:
        Version object
    """
    normalized = release
    if "." not in normalized:
        normalized = f"{normalized}.0"
    return Version(normalized)


def check_release_compatibility(
    odoo_release: str,
    pyproject_data: dict[str, Any],
) -> None:
    """Check if Odoo release is compatible with project.dependencies constraints.

    Combines all ``odoo`` dependency entries (e.g. ``["odoo>=18", "odoo<19"]``)
    into a single specifier set before validation so that split constraints are
    handled identically to a single ``"odoo>=18,<19"`` entry.

    Args:
        odoo_release: Odoo release version (e.g., "17.0")
        pyproject_data: Parsed pyproject.toml

    Raises:
        ValueError: If release is incompatible with constraints
    """
    from packaging.requirements import Requirement

    dependencies = pyproject_data.get("project", {}).get("dependencies", [])
    combined_spec = SpecifierSet()
    found = False
    for dep in dependencies:
        req = Requirement(dep)
        if req.name == "odoo":
            found = True
            combined_spec &= req.specifier
    if found and odoo_release not in combined_spec:
        raise ValueError(
            f"Cannot build for Odoo {odoo_release}: project requires odoo{combined_spec}"
        )


def matches_release_specifier(release_version: Version, specifier: str | None) -> bool:
    """Check if release matches the given PEP 440 specifier.

    Args:
        release_version: Parsed Version object
        specifier: PEP 440 version specifier (e.g., ">=18, <20")

    Returns:
        True if release matches specifier
    """
    if specifier is None or specifier == "":
        return True
    try:
        spec = SpecifierSet(specifier)
        return release_version in spec
    except (ValueError, TypeError) as exc:
        import logging

        logging.getLogger(__name__).debug(
            "Invalid version specifier '%s': %s", specifier, exc
        )
        return False


def get_src_dir(root: Path) -> str | None:
    """Get the src directory from polytropos configuration.

    Args:
        root: Path to the project root

    Returns:
        The src directory path (e.g., "src") or None if not configured
    """
    pyproject_data = get_pyproject_data(root)
    polytropos_config = pyproject_data.get("tool", {}).get("polytropos", {})
    src_dir = polytropos_config.get("src")
    return str(src_dir) if src_dir is not None else None


def get_readme_info(
    pyproject_data: dict[str, Any],
) -> tuple[str, str] | None:
    """Get readme file path and content type from pyproject.toml.

    Args:
        pyproject_data: Parsed pyproject.toml

    Returns:
        Tuple of (readme_filename, content_type) or None if not specified
    """
    project_data = pyproject_data.get("project", {})
    readme = project_data.get("readme")
    if not readme:
        return None

    readme_str = str(readme)

    if explicit_content_type := project_data.get("readme-content-type"):
        content_type = explicit_content_type
    elif readme_str.lower().endswith(".md"):
        content_type = "text/markdown"
    elif readme_str.lower().endswith(".rst"):
        content_type = "text/x-rst"
    else:
        content_type = "text/plain"

    return (readme_str, content_type)


def get_exclusion_patterns(root: Path) -> set[str]:
    """Get file patterns to exclude from builds.

    Combines default patterns with .gitignore if present.

    Args:
        root: Project root directory

    Returns:
        Set of glob patterns to exclude
    """
    DEFAULT_EXCLUDES = {
        ".git",
        ".github",
        "__pycache__",
        ".eggs",
        "*.pyc",
        "*.pyo",
        ".pytest_cache",
        ".mypy_cache",
        ".ruff_cache",
        "node_modules",
    }

    patterns = DEFAULT_EXCLUDES.copy()

    gitignore = root / ".gitignore"
    if gitignore.exists():
        gitignore_spec = PathSpec.from_lines(
            "gitwildmatch", gitignore.read_text().splitlines()
        )
        gitignore_patterns = {p.pattern for p in gitignore_spec.patterns}  # type: ignore[attr-defined]
        return patterns | gitignore_patterns

    return patterns
