"""
Package version information dataclass.

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

from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Self


@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) -> Self | 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
    ) -> Self:
        """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) -> Self | 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)
