"""
File tracking mixin for BuildCache.

Provides methods for tracking file changes, hashing, and dependency management.
Used as a mixin by the main BuildCache class.

Key Concepts:
- File fingerprints: mtime + size for fast change detection
- SHA256 hashing: Reliable content change detection
- Dependency tracking: Template, partial, and data file dependencies
- Output tracking: Source → output file mapping for cleanup

Related Modules:
- bengal.cache.build_cache.core: Main BuildCache class
- bengal.orchestration.incremental: Incremental build logic

"""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING, Any

from bengal.utils.observability.logger import get_logger
from bengal.utils.primitives.hashing import hash_file

if TYPE_CHECKING:
    pass

logger = get_logger(__name__)


class FileTrackingMixin:
    """
    Mixin providing file tracking, hashing, and dependency management.

    Requires these attributes on the host class:
        - file_fingerprints: dict[str, dict[str, Any]]
        - dependencies: dict[str, set[str]]
        - output_sources: dict[str, str]
        - _cache_key: Callable[[Path], str]  # Canonical path key (content_key)

    Performance Optimization:
    - Added reverse_dependencies for O(1) affected pages lookup
    - get_affected_pages(): O(n) → O(1)

    """

    # Type hints for mixin attributes (provided by host class)
    file_fingerprints: dict[str, dict[str, Any]]  # Keys are CacheKey at runtime
    dependencies: dict[str, set[str]]
    output_sources: dict[str, str]
    # Reverse dependency graph: dependency → set of source pages that depend on it
    reverse_dependencies: dict[str, set[str]]

    def get_file_fingerprint(self, path: Path) -> dict[str, Any] | None:
        """
        Get fingerprint for a file (path-based lookup with normalized key).

        Use this instead of direct file_fingerprints access to ensure
        correct key normalization (content_key) across path formats.
        """
        return self.file_fingerprints.get(self._cache_key(path))

    def set_file_fingerprint(self, path: Path, data: dict[str, Any]) -> None:
        """
        Set fingerprint for a file (path-based storage with normalized key).

        Use this instead of direct file_fingerprints access to ensure
        correct key normalization (content_key) across path formats.
        """
        self.file_fingerprints[self._cache_key(path)] = data

    def hash_file(self, file_path: Path) -> str:
        """
        Generate SHA256 hash of a file.

        Delegates to centralized hash_file utility for consistent behavior.

        Args:
            file_path: Path to file

        Returns:
            Hex string of SHA256 hash
        """
        try:
            return hash_file(file_path)
        except Exception as e:
            logger.warning(
                "file_hash_failed",
                file_path=str(file_path),
                error=str(e),
                error_type=type(e).__name__,
                fallback="empty_hash",
            )
            return ""

    def should_bypass(self, source_path: Path, changed_sources: set[Path] | None = None) -> bool:
        """
        Determine if cache should be bypassed for a source file.

        This is the single source of truth for cache bypass decisions.

        Cache bypass is required when:
        1. File is in the changed_sources set (explicit change from file watcher)
        2. File hash differs from cached hash (is_changed check)

        Args:
            source_path: Path to source file
            changed_sources: Set of paths explicitly marked as changed (from file watcher)

        Returns:
            True if cache should be bypassed, False if cache can be used
        """
        # Check explicit change set first (fast path)
        # Use _cache_key (content_key format) to handle absolute/relative path mismatch
        # between source_path (from discovery) and changed_sources (from watcher)
        if changed_sources:
            source_key = self._cache_key(source_path)
            for changed in changed_sources:
                if self._cache_key(changed) == source_key:
                    return True

        # Fall back to hash-based change detection
        return self.is_changed(source_path)

    def is_changed(self, file_path: Path) -> bool:
        """
        Check if a file has changed since last build.

        Performance Optimization:
            - Fast path: mtime + size check (single stat call, no file read)
            - Slow path: SHA256 hash only when mtime/size mismatch detected
            - Handles edge cases: touch/rsync may change mtime but not content

        Note:
            Prefer using should_bypass() which combines this check with
            changed_sources for correct incremental build behavior.

        Args:
            file_path: Path to file

        Returns:
            True if file is new or has changed, False if unchanged
        """
        file_key = self._cache_key(file_path)

        if not file_path.exists():
            # File was deleted
            logger.debug("cache_miss", file=file_key, reason="file_not_found")
            return True

        # Check fingerprint first (fast path)
        cached = self.file_fingerprints.get(file_key)
        if not cached:
            logger.debug("cache_miss", file=file_key, reason="not_in_cache")
            return True

        try:
            stat = file_path.stat()
            current_mtime = stat.st_mtime
            current_size = stat.st_size
            cached_mtime = cached.get("mtime")
            cached_size = cached.get("size")

            # Fast path: mtime + size unchanged = definitely no change
            if cached_mtime == current_mtime and cached_size == current_size:
                logger.debug("cache_hit", file=file_key, reason="mtime_size_match")
                return False

            # mtime or size changed - verify with hash (handles touch/rsync)
            cached_hash = cached.get("hash")
            if cached_hash:
                current_hash = self.hash_file(file_path)
                if current_hash == cached_hash:
                    # Content unchanged despite mtime change (e.g., touch)
                    # Update mtime/size in fingerprint for future fast path
                    self.file_fingerprints[file_key] = {
                        "mtime": current_mtime,
                        "size": current_size,
                        "hash": cached_hash,
                    }
                    logger.debug(
                        "cache_hit",
                        file=file_key,
                        reason="mtime_changed_content_same",
                        cached_mtime=cached_mtime,
                        current_mtime=current_mtime,
                    )
                    return False

                # Hash differs, file changed
                logger.debug(
                    "cache_miss",
                    file=file_key,
                    reason="content_changed",
                    cached_hash=cached_hash[:8] if cached_hash else "none",
                    current_hash=current_hash[:8] if current_hash else "none",
                )
                return True

            # No cached hash, treat as changed
            logger.debug("cache_miss", file=file_key, reason="no_cached_hash")
            return True

        except OSError as e:
            # Can't stat file, treat as changed
            logger.debug("cache_miss", file=file_key, reason="stat_failed", error=str(e))
            return True

    def update_file(self, file_path: Path) -> None:
        """
        Update the fingerprint for a file (mtime + size + hash).

        Performance Optimization:
            Stores full fingerprint for fast change detection on subsequent builds.
            Uses mtime + size for fast path, hash for verification.

        Args:
            file_path: Path to file
        """
        file_key = self._cache_key(file_path)

        try:
            stat = file_path.stat()
            file_hash = self.hash_file(file_path)

            # Store full fingerprint
            self.file_fingerprints[file_key] = {
                "mtime": stat.st_mtime,
                "size": stat.st_size,
                "hash": file_hash,
            }

        except FileNotFoundError:
            # File was deleted - remove from tracking silently
            # This commonly happens when switching from markdown-based autodoc to virtual pages
            if file_key in self.file_fingerprints:
                del self.file_fingerprints[file_key]
            logger.debug(
                "file_removed_from_tracking",
                file_path=str(file_path),
                reason="file_not_found",
            )
        except OSError as e:
            logger.warning(
                "file_update_failed",
                file_path=str(file_path),
                error=str(e),
                error_type=type(e).__name__,
            )

    def track_output(self, source_path: Path, output_path: Path, output_dir: Path) -> None:
        """
        Track the relationship between a source file and its output file.

        This enables cleanup of output files when source files are deleted.

        Args:
            source_path: Path to source file (e.g., content/blog/post.md)
            output_path: Absolute path to output file (e.g., /path/to/public/blog/post/index.html)
            output_dir: Site output directory (e.g., /path/to/public)
        """
        # Store as relative path from output_dir for portability
        try:
            rel_output = str(output_path.relative_to(output_dir))
            self.output_sources[rel_output] = self._cache_key(source_path)
        except ValueError:
            # output_path not relative to output_dir, skip
            logger.debug("output_not_relative", output=str(output_path), output_dir=str(output_dir))

    def add_dependency(self, source: Path, dependency: Path) -> None:
        """
        Record that a source file depends on another file (O(1)).

        Maintains both forward and reverse dependency graphs for efficient lookups.

        Args:
            source: Source file (e.g., content page)
            dependency: Dependency file (e.g., template, partial, config)
        """
        source_key = self._cache_key(source)
        dep_key = self._cache_key(dependency)

        # Forward graph: source → dependencies
        if source_key not in self.dependencies:
            self.dependencies[source_key] = set()
        self.dependencies[source_key].add(dep_key)

        # Reverse graph: dependency → sources
        if dep_key not in self.reverse_dependencies:
            self.reverse_dependencies[dep_key] = set()
        self.reverse_dependencies[dep_key].add(source_key)

    def get_affected_pages(self, changed_file: Path) -> set[str]:
        """
        Find all pages that depend on a changed file (O(1) via reverse graph).

        Performance: O(1) lookup instead of O(n) iteration.

        Args:
            changed_file: File that changed

        Returns:
            Set of page paths that need to be rebuilt
        """
        changed_key = self._cache_key(changed_file)
        affected = set()

        # O(1) lookup via reverse dependency graph
        affected.update(self.reverse_dependencies.get(changed_key, set()))

        # If the changed file itself is a source, rebuild it
        if changed_key in self.dependencies:
            affected.add(changed_key)

        return affected

    def invalidate_fingerprint(self, file_path: Path) -> bool:
        """
        Remove cached fingerprint for a file.

        This forces re-computation of hash on next access.

        Args:
            file_path: Path to file

        Returns:
            True if fingerprint was removed, False if not present
        """
        key = self._cache_key(file_path)
        if key in self.file_fingerprints:
            del self.file_fingerprints[key]
            return True
        return False

    def invalidate_file(self, file_path: Path) -> None:
        """
        Remove a file from the cache (useful when file is deleted).

        Cleans up both forward and reverse dependency graphs.

        Note: This is a partial invalidation. Full invalidation of related
        caches (parsed_content, rendered_output, etc.) should be handled
        by the main BuildCache class.

        Args:
            file_path: Path to file
        """
        file_key = self._cache_key(file_path)
        self.file_fingerprints.pop(file_key, None)

        # Remove from forward graph and update reverse graph
        deps = self.dependencies.pop(file_key, set())
        for dep in deps:
            if dep in self.reverse_dependencies:
                self.reverse_dependencies[dep].discard(file_key)
                # Clean up empty entries
                if not self.reverse_dependencies[dep]:
                    del self.reverse_dependencies[dep]

        # Remove as a dependency from other files (forward graph)
        for source_deps in self.dependencies.values():
            source_deps.discard(file_key)

        # Remove from reverse graph (as a dependency)
        self.reverse_dependencies.pop(file_key, None)
        # Note: sources that depended on this file still have it in their forward deps
        # This is intentional - they'll rebuild when they next run
