# Copyright 2026 4f4d
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Local file cache and download manager."""

from __future__ import annotations

import json
import logging
import shutil
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from tqdm import tqdm

from f1radio.client import OpenF1Client
from f1radio.exceptions import CacheError

logger = logging.getLogger("f1radio")

# Sessions unused for this many days are auto-cleaned
DEFAULT_MAX_AGE_DAYS = 30


def _default_cache_dir() -> Path:
    """Cache lives inside the package dir as .cache/."""
    return Path(__file__).parent / ".cache"


# Module-level configurable cache directory
_cache_dir: Optional[Path] = None


def set_cache_dir(path: str) -> None:
    """Set a custom cache directory path.

    Args:
        path: Absolute path to use as cache root.
    """
    global _cache_dir
    _cache_dir = Path(path)


def get_cache_dir() -> Path:
    """Get the current cache directory path."""
    return _cache_dir or _default_cache_dir()


def _sanitize_name(name: str) -> str:
    """Sanitize a name for use in file paths."""
    return "".join(c if c.isalnum() or c in "-_" else "_" for c in name).strip("_").lower()


class CacheManager:
    """Handles local caching of session data and MP3 audio.

    Layout: <cache_root>/<year>_<race>_<session>/{audio/, context/, metadata.json}

    Stale sessions (unused for 30+ days by default) are cleaned up automatically.
    """

    def __init__(
        self,
        client: Optional[OpenF1Client] = None,
        max_age_days: int = DEFAULT_MAX_AGE_DAYS,
    ):
        self._client = client or OpenF1Client()
        self._root = get_cache_dir()
        self._max_age_days = max_age_days

    def _session_dir(self, year: int, race: str, session_type: str) -> Path:
        """Get the cache directory for a specific session."""
        dirname = f"{year}_{_sanitize_name(race)}_{_sanitize_name(session_type)}"
        return self._root / dirname

    def is_cached(self, year: int, race: str, session_type: str) -> bool:
        """True if metadata.json exists for this session."""
        session_dir = self._session_dir(year, race, session_type)
        return (session_dir / "metadata.json").exists()

    def get_cache_path(self, year: int, race: str, session_type: str) -> Path:
        """Get the cache directory path for a session."""
        return self._session_dir(year, race, session_type)

    def _touch_session(self, year: int, race: str, session_type: str) -> None:
        """Update the last_accessed timestamp for a session."""
        session_dir = self._session_dir(year, race, session_type)
        access_file = session_dir / ".last_accessed"
        try:
            session_dir.mkdir(parents=True, exist_ok=True)
            access_file.write_text(str(time.time()))
        except IOError:
            pass

    def _get_last_accessed(self, session_dir: Path) -> float:
        """Get the last-accessed timestamp for a session directory."""
        access_file = session_dir / ".last_accessed"
        if access_file.exists():
            try:
                return float(access_file.read_text().strip())
            except (ValueError, IOError):
                pass
        # Fallback: use directory modification time
        try:
            return session_dir.stat().st_mtime
        except OSError:
            return 0.0

    def cleanup_stale(self) -> int:
        """Remove sessions unused for longer than max_age_days. Returns count removed."""
        if not self._root.exists():
            return 0

        cutoff = time.time() - (self._max_age_days * 86400)
        cleaned = 0

        for session_dir in list(self._root.iterdir()):
            if not session_dir.is_dir():
                continue
            last_access = self._get_last_accessed(session_dir)
            if last_access < cutoff:
                logger.info(f"Auto-cleaning stale session: {session_dir.name}")
                shutil.rmtree(session_dir)
                cleaned += 1

        if cleaned > 0:
            logger.info(f"Cleaned {cleaned} stale session(s) (unused for {self._max_age_days}+ days)")
        return cleaned

    def save_metadata(
        self,
        year: int,
        race: str,
        session_type: str,
        metadata: Dict[str, Any],
    ) -> None:
        """Save session metadata to cache."""
        session_dir = self._session_dir(year, race, session_type)
        session_dir.mkdir(parents=True, exist_ok=True)
        meta_path = session_dir / "metadata.json"
        try:
            with open(meta_path, "w") as f:
                json.dump(metadata, f, indent=2, default=str)
        except IOError as e:
            raise CacheError(f"Failed to save metadata: {e}")
        self._touch_session(year, race, session_type)

    def load_metadata(
        self,
        year: int,
        race: str,
        session_type: str,
    ) -> Optional[Dict[str, Any]]:
        """Load session metadata from cache."""
        session_dir = self._session_dir(year, race, session_type)
        meta_path = session_dir / "metadata.json"
        if not meta_path.exists():
            return None
        try:
            with open(meta_path) as f:
                data = json.load(f)
            self._touch_session(year, race, session_type)
            return data
        except (IOError, json.JSONDecodeError) as e:
            logger.warning(f"Failed to load metadata: {e}")
            return None

    def save_context(
        self,
        year: int,
        race: str,
        session_type: str,
        endpoint: str,
        data: List[Dict],
    ) -> None:
        """Save context data (from an API endpoint) to cache.

        Args:
            year: Season year.
            race: Race name.
            session_type: Session type.
            endpoint: API endpoint name (e.g., "position", "intervals").
            data: List of dicts from the API.
        """
        session_dir = self._session_dir(year, race, session_type)
        context_dir = session_dir / "context"
        context_dir.mkdir(parents=True, exist_ok=True)
        file_path = context_dir / f"{endpoint}.json"
        try:
            with open(file_path, "w") as f:
                json.dump(data, f, default=str)
        except IOError as e:
            raise CacheError(f"Failed to save context data for {endpoint}: {e}")

    def load_context(
        self,
        year: int,
        race: str,
        session_type: str,
        endpoint: str,
    ) -> Optional[List[Dict]]:
        """Load context data from cache.

        Returns:
            List of dicts, or None if not cached.
        """
        session_dir = self._session_dir(year, race, session_type)
        file_path = session_dir / "context" / f"{endpoint}.json"
        if not file_path.exists():
            return None
        try:
            with open(file_path) as f:
                data = json.load(f)
            self._touch_session(year, race, session_type)
            return data
        except (IOError, json.JSONDecodeError) as e:
            logger.warning(f"Failed to load context for {endpoint}: {e}")
            return None

    def download_mp3(self, url: str, dest_path: str) -> bool:
        """Download a single MP3 file.

        Args:
            url: URL of the MP3 file.
            dest_path: Local path to save to.

        Returns:
            True if successful.
        """
        dest = Path(dest_path)
        if dest.exists() and dest.stat().st_size > 0:
            return True  # Already downloaded
        dest.parent.mkdir(parents=True, exist_ok=True)
        return self._client.download_file(url, str(dest))

    def download_all_mp3s(
        self,
        downloads: List[Tuple[str, str]],
        verbose: bool = True,
    ) -> int:
        """Download multiple MP3 files in parallel.

        Args:
            downloads: List of (url, dest_path) tuples.
            verbose: Show progress bar.

        Returns:
            Number of successfully downloaded files.
        """
        if not downloads:
            return 0

        # Filter out already-downloaded files
        pending = [
            (url, dest) for url, dest in downloads
            if not (Path(dest).exists() and Path(dest).stat().st_size > 0)
        ]

        if not pending:
            if verbose:
                logger.info(f"All {len(downloads)} MP3 files already cached")
            return len(downloads)

        success_count = len(downloads) - len(pending)
        progress = tqdm(
            total=len(pending),
            desc="Downloading audio",
            unit="file",
            disable=not verbose,
        )

        with ThreadPoolExecutor(max_workers=4) as executor:
            futures = {
                executor.submit(self.download_mp3, url, dest): (url, dest)
                for url, dest in pending
            }
            for future in as_completed(futures):
                url, dest = futures[future]
                try:
                    if future.result():
                        success_count += 1
                except Exception as e:
                    logger.warning(f"Failed to download {url}: {e}")
                progress.update(1)

        progress.close()
        return success_count

    def clear_cache(
        self,
        year: Optional[int] = None,
        race: Optional[str] = None,
        session_type: Optional[str] = None,
    ) -> int:
        """Clear cached data.

        Args:
            year: If provided with race and session_type, clear specific session.
                  If None, clears all cache.

        Returns:
            Number of sessions cleared.
        """
        if year is not None and race is not None and session_type is not None:
            session_dir = self._session_dir(year, race, session_type)
            if session_dir.exists():
                shutil.rmtree(session_dir)
                return 1
            return 0

        # Clear all
        count = 0
        if self._root.exists():
            for child in self._root.iterdir():
                if child.is_dir():
                    shutil.rmtree(child)
                    count += 1
        return count

    def cache_info(self) -> Dict[str, Any]:
        """Get information about cached sessions.

        Returns:
            Dict with session names as keys and size info as values.
        """
        info: Dict[str, Any] = {
            "cache_dir": str(self._root),
            "sessions": {},
            "total_size_mb": 0.0,
            "max_age_days": self._max_age_days,
        }

        if not self._root.exists():
            return info

        now = time.time()
        total_bytes = 0
        for session_dir in sorted(self._root.iterdir()):
            if not session_dir.is_dir():
                continue

            session_bytes = sum(
                f.stat().st_size
                for f in session_dir.rglob("*")
                if f.is_file()
            )
            total_bytes += session_bytes

            # Count audio files
            audio_dir = session_dir / "audio"
            mp3_count = len(list(audio_dir.glob("*.mp3"))) if audio_dir.exists() else 0

            # Last access info
            last_access = self._get_last_accessed(session_dir)
            days_ago = round((now - last_access) / 86400, 1) if last_access else None

            info["sessions"][session_dir.name] = {
                "size_mb": round(session_bytes / (1024 * 1024), 2),
                "mp3_count": mp3_count,
                "last_used_days_ago": days_ago,
                "path": str(session_dir),
            }

        info["total_size_mb"] = round(total_bytes / (1024 * 1024), 2)
        return info
