"""
Standalone autodoc dependency tracker for BuildCache.

Tracks which Python source files produce which autodoc pages, enabling
selective regeneration of autodoc pages when their source files change.
Used via composition in BuildCache.

Key Concepts:
- Source file tracking: Maps Python/OpenAPI source files to autodoc page paths
- Selective invalidation: Only rebuild affected autodoc pages, not all
- Orphan cleanup: Remove autodoc pages when source files are deleted
- Self-validation: Validate source file hashes for CI cache correctness

Related Modules:
- bengal.autodoc.orchestration: Creates autodoc pages with dependencies
- bengal.orchestration.incremental: Uses dependency info for selective builds
- bengal.utils.hashing: Shared hashing utilities

See Also:
- plan/rfc-autodoc-incremental-builds.md: Design rationale
- plan/rfc-ci-cache-inputs.md: CI cache validation rationale

"""

from __future__ import annotations

from pathlib import Path
from typing import Any

from bengal.utils.observability.logger import get_logger

logger = get_logger(__name__)


class AutodocTracker:
    """
    Standalone autodoc dependency tracker.

    Tracks autodoc source file to page dependencies WITH hash validation.
    Enables selective rebuilds when only specific Python/OpenAPI source files
    change. Also provides self-validation to detect stale autodoc sources even
    when CI cache keys are incorrect.

    Attributes:
        autodoc_dependencies: Mapping of source_file path to set of autodoc page paths
            that are generated from that source file.
        autodoc_source_metadata: Mapping of source_file path to metadata tuple:
            (file_content_hash, mtime, {page_path: doc_content_hash}).
            The mtime-first optimization skips hash computation when mtime is unchanged.

    """

    def __init__(
        self,
        autodoc_dependencies: dict[str, set[str]] | None = None,
        autodoc_source_metadata: dict[str, tuple[str, float, dict[str, str]]] | None = None,
    ) -> None:
        self.autodoc_dependencies = autodoc_dependencies if autodoc_dependencies is not None else {}
        self.autodoc_source_metadata = (
            autodoc_source_metadata if autodoc_source_metadata is not None else {}
        )

    def _normalize_source_path(self, source_file: Path | str, site_root: Path) -> str:
        """
        Normalize source path for consistent cache keys.

        Converts absolute paths to relative (from site parent) when possible.
        This ensures cache hits across different checkout locations.

        Args:
            source_file: Path to normalize
            site_root: Site root path for computing relative paths

        Returns:
            Normalized path string (relative when possible)
        """
        path = Path(source_file)
        if path.is_absolute():
            try:
                return str(path.relative_to(site_root.parent))
            except ValueError:
                # External path outside repo - keep absolute
                return str(path)
        return str(path)

    def add_autodoc_dependency(
        self,
        source_file: Path | str,
        autodoc_page: Path | str,
        site_root: Path | None = None,
        source_hash: str | None = None,
        source_mtime: float | None = None,
        content_hash: str | None = None,
    ) -> None:
        """
        Register that source_file produces autodoc_page.

        Args:
            source_file: Path to the Python/OpenAPI source file
            autodoc_page: Path to the generated autodoc page (source_path)
            site_root: Site root path for normalization (optional, uses raw path if not provided)
            source_hash: File-level content hash for self-validation (optional)
            source_mtime: File mtime for fast staleness check (optional)
            content_hash: Fine-grained doc content hash for selective rebuilds (optional)
        """
        if site_root is not None:
            source_key = self._normalize_source_path(source_file, site_root)
        else:
            source_key = str(source_file)

        page_key = str(autodoc_page)

        if source_key not in self.autodoc_dependencies:
            self.autodoc_dependencies[source_key] = set()
        self.autodoc_dependencies[source_key].add(page_key)

        if source_hash is None or source_mtime is None:
            raise ValueError(
                "Autodoc dependency metadata required: provide source_hash and source_mtime."
            )

        # Store metadata for self-validation and fine-grained change detection
        existing = self.autodoc_source_metadata.get(source_key)
        doc_hashes: dict[str, str] = {}
        if existing:
            if len(existing) != 3:
                raise ValueError(
                    "Autodoc source metadata must be a 3-tuple (hash, mtime, doc_hashes)."
                )
            doc_hashes = existing[2]

        if content_hash:
            doc_hashes[page_key] = content_hash

        self.autodoc_source_metadata[source_key] = (source_hash, source_mtime, doc_hashes)

        logger.debug(
            "autodoc_dependency_registered",
            source_file=source_key,
            autodoc_page=page_key,
            has_metadata=source_hash is not None,
            has_content_hash=content_hash is not None,
        )

    def get_affected_autodoc_pages(self, changed_source: Path | str) -> set[str]:
        """
        Get autodoc pages affected by a source file change.

        Args:
            changed_source: Path to the changed Python/OpenAPI source file

        Returns:
            Set of autodoc page paths that need to be rebuilt
        """
        source_key = str(changed_source)
        return self.autodoc_dependencies.get(source_key, set())

    def get_autodoc_source_files(self) -> set[str]:
        """
        Get all tracked autodoc source files.

        Returns:
            Set of all source file paths that have autodoc dependencies
        """
        return set(self.autodoc_dependencies.keys())

    def clear_autodoc_dependencies(self) -> None:
        """
        Clear all autodoc dependency mappings.

        Called when autodoc configuration changes to ensure fresh mappings.
        """
        self.autodoc_dependencies.clear()
        self.autodoc_source_metadata.clear()
        logger.debug("autodoc_dependencies_cleared")

    def clear(self) -> None:
        """Clear all autodoc tracking data."""
        self.clear_autodoc_dependencies()

    def remove_autodoc_source(self, source_file: Path | str) -> set[str]:
        """
        Remove a source file and return its associated autodoc pages.

        Args:
            source_file: Path to the source file being removed

        Returns:
            Set of autodoc page paths that were associated with this source
        """
        source_key = str(source_file)
        removed_pages = self.autodoc_dependencies.pop(source_key, set())

        # Also remove metadata
        self.autodoc_source_metadata.pop(source_key, None)

        if removed_pages:
            logger.debug(
                "autodoc_source_removed",
                source_file=source_key,
                affected_pages=len(removed_pages),
            )

        return removed_pages

    def get_stale_autodoc_sources(self, site_root: Path) -> set[str]:
        """
        Validate autodoc sources and return paths that have changed.

        Uses mtime-first optimization: only computes hash if mtime changed.
        This enables cache self-validation independent of CI cache keys.

        This method provides defense-in-depth: even if CI cache keys are
        incorrect, Bengal will detect stale autodoc sources and rebuild
        affected pages.

        RFC: rfc-incremental-build-observability
        When BENGAL_STRICT_INCREMENTAL is set to 'warn' or 'error', this
        method will log warnings or raise errors when fallback paths are used.

        Args:
            site_root: Site root path for resolving relative paths

        Returns:
            Set of source file paths whose content has changed since caching
        """
        from bengal.utils.primitives.hashing import hash_file

        if not self.autodoc_source_metadata and self.autodoc_dependencies:
            from bengal.errors import BengalCacheError, ErrorCode

            raise BengalCacheError(
                "Autodoc source metadata missing but dependencies exist. "
                "Rebuild autodoc to restore metadata.",
                code=ErrorCode.A001,
                suggestion="Re-run autodoc generation to repopulate metadata.",
            )

        stale_sources: set[str] = set()

        for source_key, metadata in self.autodoc_source_metadata.items():
            if len(metadata) != 3:
                raise ValueError(
                    "Autodoc source metadata must be a 3-tuple (hash, mtime, doc_hashes)."
                )
            stored_hash, stored_mtime, doc_hashes = metadata

            # Resolve path - keys are normalized relative to site PARENT
            # (since autodoc sources are typically outside site root, e.g., ../bengal/)
            if Path(source_key).is_absolute():
                source = Path(source_key)
            else:
                # All relative keys are relative to site parent
                source = site_root.parent / source_key

            if not source.exists():
                # Source deleted - mark as stale for cleanup
                stale_sources.add(source_key)
                logger.debug(
                    "autodoc_source_deleted",
                    source_key=source_key,
                )
                continue

            try:
                # mtime-first optimization: skip hash if mtime unchanged
                current_mtime = source.stat().st_mtime
                if current_mtime == stored_mtime:
                    continue  # Fast path: file unchanged

                # mtime changed - verify with hash (handles touch without content change)
                current_hash = hash_file(source, truncate=16)
                if current_hash != stored_hash:
                    stale_sources.add(source_key)
                    logger.debug(
                        "autodoc_source_changed",
                        source_key=source_key,
                        stored_hash=stored_hash[:8],
                        current_hash=current_hash[:8],
                    )
                else:
                    # Content unchanged, update mtime in metadata
                    self.autodoc_source_metadata[source_key] = (
                        stored_hash,
                        current_mtime,
                        doc_hashes,
                    )
            except OSError as e:
                # Can't read file - mark as stale to be safe
                logger.warning(
                    "autodoc_source_read_error",
                    source_key=source_key,
                    error=str(e),
                )
                stale_sources.add(source_key)

        if stale_sources:
            logger.info(
                "stale_autodoc_sources_detected",
                count=len(stale_sources),
                sources=list(stale_sources)[:5],
            )

        return stale_sources

    def get_autodoc_stats(self) -> dict[str, Any]:
        """
        Get statistics about autodoc dependency tracking.

        Returns:
            Dictionary with tracking stats including source count,
            page count, and metadata coverage.
        """
        total_sources = len(self.autodoc_dependencies)
        total_pages = sum(len(pages) for pages in self.autodoc_dependencies.values())
        sources_with_metadata = len(self.autodoc_source_metadata)

        return {
            "autodoc_source_files": total_sources,
            "autodoc_pages_tracked": total_pages,
            "sources_with_metadata": sources_with_metadata,
            "metadata_coverage_pct": (
                round(sources_with_metadata / total_sources * 100, 1)
                if total_sources > 0
                else 100.0
            ),
        }

    def is_doc_content_changed(
        self,
        source_key: str,
        page_path: str,
        current_doc_hash: str,
    ) -> bool:
        """
        Check if the documentation content for a specific page has changed.

        Args:
            source_key: Normalized source file key
            page_path: Path to the autodoc page
            current_doc_hash: Current doc content hash computed during extraction

        Returns:
            True if changed (or not in cache), False otherwise
        """
        metadata = self.autodoc_source_metadata.get(source_key)
        if not metadata:
            return True  # No metadata, assume changed
        if len(metadata) != 3:
            raise ValueError("Autodoc source metadata must be a 3-tuple (hash, mtime, doc_hashes).")

        # metadata is (file_hash, mtime, {page_path: doc_hash})
        doc_hashes = metadata[2]
        stored_hash = doc_hashes.get(page_path)

        if stored_hash is None:
            return True  # Page not in cache for this source

        return stored_hash != current_doc_hash
