"""
Package version information dataclass.

This module defines the data structure for storing version and commit
information about installed packages.
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime
from pathlib import Path


@dataclass
class BuildInfo:
    """
    Build information for the application itself.

    Read from _build_info.py generated by git hook.

    Attributes:
        commit: Short commit hash (7 chars)
        commit_full: Full commit hash (40 chars)
        message: Commit message (first line)
        build_time: When the build info was generated
        _source_path: Path to _build_info.py (for runtime modified detection)
    """

    commit: str
    commit_full: str
    message: str = ""
    build_time: datetime | None = None
    _source_path: Path | None = None

    @property
    def message_short(self) -> str:
        """Get message truncated to 20 chars with ... if longer."""
        if len(self.message) <= 20:
            return self.message
        return self.message[:20] + "..."

    @property
    def modified(self) -> bool | None:
        """
        Check if repo has uncommitted changes (runtime detection).

        Returns:
            True if modified, False if clean, None if can't detect (no git repo)
        """
        if not self._source_path:
            return None

        git_root = self._find_git_root(self._source_path.parent)
        if not git_root:
            return None

        return self._check_git_modified(git_root)

    def _find_git_root(self, path: Path) -> Path | None:
        """Find the git root directory containing the given path."""
        current = path.resolve()
        while current != current.parent:
            if (current / ".git").exists():
                return current
            current = current.parent
        return None

    def _check_git_modified(self, git_root: Path) -> bool | None:
        """Check if the git repo has uncommitted changes."""
        import subprocess

        try:
            result = subprocess.run(
                ["git", "status", "--porcelain"],
                cwd=git_root,
                capture_output=True,
                text=True,
                timeout=5,
            )
            if result.returncode == 0:
                return bool(result.stdout.strip())
        except (subprocess.SubprocessError, FileNotFoundError, OSError):
            pass
        return None

    @classmethod
    def from_path(cls, path: Path) -> BuildInfo | None:
        """
        Read build info from a _build_info.py file.

        If the file has empty values (stub), falls back to runtime git detection.

        Args:
            path: Path to the _build_info.py file

        Returns:
            BuildInfo if file exists and is valid, None otherwise
        """
        resolved_path = path.resolve()

        if not resolved_path.exists():
            return cls._from_git_runtime(resolved_path.parent)

        namespace = cls._parse_build_info_file(resolved_path)
        if namespace is None:
            return cls._from_git_runtime(resolved_path.parent)

        commit_full = str(namespace.get("COMMIT_HASH", ""))
        if not commit_full:
            return cls._from_git_runtime(resolved_path.parent)

        return cls._create_from_namespace(namespace, commit_full, resolved_path)

    @classmethod
    def _parse_build_info_file(cls, path: Path) -> dict[str, object] | None:
        """Parse _build_info.py file and return namespace dict.

        Uses importlib to safely load the module instead of exec().
        """
        import importlib.util

        try:
            spec = importlib.util.spec_from_file_location("_build_info", path)
            if spec is None or spec.loader is None:
                return None

            module = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(module)

            # Extract relevant attributes into a dict
            return {
                key: getattr(module, key)
                for key in dir(module)
                if not key.startswith("_")
            }
        except Exception:
            return None

    @classmethod
    def _create_from_namespace(
        cls, namespace: dict[str, object], commit_full: str, source_path: Path
    ) -> BuildInfo:
        """Create BuildInfo from parsed namespace."""
        commit_short = str(namespace.get("COMMIT_SHORT", ""))
        commit_message = str(namespace.get("COMMIT_MESSAGE", ""))
        build_time_str = str(namespace.get("BUILD_TIME", ""))

        build_time = None
        if build_time_str:
            try:
                build_time = datetime.fromisoformat(
                    build_time_str.replace("Z", "+00:00")
                )
            except ValueError:
                pass

        return cls(
            commit=commit_short or commit_full[:7],
            commit_full=commit_full,
            message=commit_message,
            build_time=build_time,
            _source_path=source_path,
        )

    @classmethod
    def _from_git_runtime(cls, directory: Path) -> BuildInfo | None:
        """
        Create BuildInfo by running git commands at runtime.

        Used as fallback when _build_info.py is empty or missing.
        """
        git_root = cls._find_git_root_static(directory)
        if not git_root:
            return None

        commit_full = cls._run_git(git_root, ["rev-parse", "HEAD"])
        if not commit_full:
            return None

        message = cls._run_git(git_root, ["log", "-1", "--format=%s"]) or ""
        commit_time = cls._get_commit_time(git_root)

        return cls(
            commit=commit_full[:7],
            commit_full=commit_full,
            message=message,
            build_time=commit_time,
            _source_path=directory,
        )

    @staticmethod
    def _find_git_root_static(directory: Path) -> Path | None:
        """Find the git root directory containing the given path."""
        current = directory.resolve()
        while current != current.parent:
            if (current / ".git").exists():
                return current
            current = current.parent
        return None

    @staticmethod
    def _run_git(git_root: Path, args: list[str]) -> str | None:
        """Run a git command and return stdout, or None on failure."""
        import subprocess

        try:
            result = subprocess.run(
                ["git", *args],
                cwd=git_root,
                capture_output=True,
                text=True,
                timeout=5,
            )
            if result.returncode == 0:
                return result.stdout.strip()
        except (subprocess.SubprocessError, FileNotFoundError, OSError):
            pass
        return None

    @classmethod
    def _get_commit_time(cls, git_root: Path) -> datetime | None:
        """Get the commit timestamp from a git repository."""
        time_str = cls._run_git(git_root, ["log", "-1", "--format=%cI"])
        if time_str:
            try:
                return datetime.fromisoformat(time_str)
            except ValueError:
                pass
        return None


@dataclass
class PackageVersionInfo:
    """
    Version and commit information for a tracked package.

    Attributes:
        name: Package distribution name (e.g., "appinfra")
        version: Package version string from metadata (e.g., "1.0.0")
        commit: Short commit hash (7 chars) if available
        commit_full: Full commit hash (40 chars) if available
        message: Commit message (first line)
        source_url: Git URL if package was installed from git
        source_type: How the package was installed/detected
        build_time: When the package was built (if baked at build time)
        _package_path: Path to package for runtime modified detection
    """

    name: str
    version: str
    commit: str | None = None
    commit_full: str | None = None
    message: str = ""
    source_url: str | None = None
    source_type: str = "unknown"
    build_time: datetime | None = None
    _package_path: Path | None = None
    _build_modified: bool | None = None  # MODIFIED from _build_info.py

    # Valid source types
    SOURCE_PIP_GIT = "pip-git"  # pip install git+...
    SOURCE_BUILD_INFO = "build-info"  # _build_info.py baked at build time
    SOURCE_EDITABLE_GIT = "editable-git"  # pip install -e from git repo
    SOURCE_PIP = "pip"  # Regular pip install, no git info
    SOURCE_UNKNOWN = "unknown"

    def __post_init__(self) -> None:
        """Validate and normalize the data after initialization."""
        # Ensure commit_full is set if commit is provided
        if self.commit and not self.commit_full:
            # If only short hash provided, use it as full (unusual but allowed)
            if len(self.commit) == 40:
                self.commit_full = self.commit
                self.commit = self.commit[:7]

        # Derive short commit from full if not set
        if self.commit_full and not self.commit:
            self.commit = self.commit_full[:7]

    @property
    def has_commit(self) -> bool:
        """Check if commit information is available."""
        return self.commit is not None

    @property
    def modified(self) -> bool | None:
        """
        Check if package repo has uncommitted changes.

        For build-info sources, returns the MODIFIED value baked at build time.
        For editable installs, does runtime git detection.

        Returns:
            True if modified, False if clean, None if can't detect
        """
        # Return build-time modified value if available
        if self._build_modified is not None:
            return self._build_modified

        # Can only do runtime detection for editable installs or build-info with path
        if self.source_type not in (self.SOURCE_EDITABLE_GIT, self.SOURCE_BUILD_INFO):
            return None

        if not self._package_path:
            return None

        git_root = self._find_git_root(self._package_path)
        if not git_root:
            return None

        return self._check_git_modified(git_root)

    def _find_git_root(self, path: Path) -> Path | None:
        """Find the git root directory containing the given path."""
        current = path.resolve()
        while current != current.parent:
            if (current / ".git").exists():
                return current
            current = current.parent
        return None

    def _check_git_modified(self, git_root: Path) -> bool | None:
        """Check if the git repo has uncommitted changes."""
        import subprocess

        try:
            result = subprocess.run(
                ["git", "status", "--porcelain"],
                cwd=git_root,
                capture_output=True,
                text=True,
                timeout=5,
            )
            if result.returncode == 0:
                return bool(result.stdout.strip())
        except (subprocess.SubprocessError, FileNotFoundError, OSError):
            pass
        return None

    @property
    def message_short(self) -> str:
        """Get message truncated to 20 chars with ... if longer."""
        if len(self.message) <= 20:
            return self.message
        return self.message[:20] + "..."

    def format_short(self) -> str:
        """Format as 'name=version@commit' or 'name=version' if no commit."""
        if self.commit:
            return f"{self.name}={self.version}@{self.commit}"
        return f"{self.name}={self.version}"

    def format_long(self) -> str:
        """
        Format as detailed line for --version output.

        Examples:
            mylib     1.2.0 @ abc123f (git+https://github.com/org/mylib)
            otherlib  2.0.0 @ def456a
            plainpkg  3.0.0
        """
        parts = [f"{self.name}", f"{self.version}"]
        if self.commit:
            parts.append(f"@ {self.commit}")
        if self.source_url:
            parts.append(f"({self.source_url})")
        return " ".join(parts)
