"""Telegram file_id cache with pluggable storage backend."""

from __future__ import annotations

import asyncio
import logging
from datetime import datetime, timezone
from typing import Any, Final

from notify_bridge_core.storage import StorageBackend

_LOGGER = logging.getLogger(__name__)

DEFAULT_TELEGRAM_CACHE_TTL: Final = 48 * 60 * 60
DEFAULT_MAX_ENTRIES: Final = 5000


def _parse_iso(value: str | None) -> datetime | None:
    """Parse an ISO-8601 timestamp tolerantly. Returns ``None`` on failure."""
    if not value or not isinstance(value, str):
        return None
    try:
        # Python <3.11 doesn't accept "Z"; normalize to +00:00.
        v = value.replace("Z", "+00:00") if value.endswith("Z") else value
        return datetime.fromisoformat(v)
    except ValueError:
        return None


class TelegramFileCache:
    """Cache for Telegram file_ids to avoid re-uploading media.

    Two complementary invalidation strategies, usable together or separately:
    - TTL: entries expire after ``ttl_seconds``. Set to 0 to disable TTL
      (cache essentially forever, subject only to the size cap).
    - Thumbhash mode: entries are validated on read by comparing the stored
      thumbhash with the one the caller supplies; a mismatch drops the entry.
      Intended for content-addressable assets (e.g. Immich) where re-uploads
      should be triggered by visual change, not elapsed time.

    ``max_entries`` always applies as a FIFO size cap (oldest-cached first).

    Concurrency
    ~~~~~~~~~~~
    All mutators take an internal ``asyncio.Lock`` so concurrent
    media-group sends can't interleave a read-time invalidation with a
    bulk write and corrupt the underlying dict (``RuntimeError:
    dictionary changed size during iteration``) or lose just-written
    entries. Reads do not take the lock — they are O(1) dict lookups —
    but ``get`` uses a snapshot reference so it cannot mutate the data
    structure under another task.
    """

    def __init__(
        self,
        backend: StorageBackend,
        ttl_seconds: int = DEFAULT_TELEGRAM_CACHE_TTL,
        use_thumbhash: bool = False,
        max_entries: int = DEFAULT_MAX_ENTRIES,
    ) -> None:
        self._backend = backend
        self._data: dict[str, Any] | None = None
        self._ttl_seconds = ttl_seconds
        self._use_thumbhash = use_thumbhash
        self._max_entries = max_entries
        self._lock = asyncio.Lock()

    async def async_load(self) -> None:
        async with self._lock:
            self._data = await self._backend.load() or {"files": {}}
            await self._cleanup_expired_locked()

    async def _cleanup_expired_locked(self) -> None:
        """Caller must hold ``self._lock``."""
        if not self._data or "files" not in self._data:
            return
        files: dict[str, dict[str, Any]] = self._data["files"]
        changed = False

        if not self._use_thumbhash and self._ttl_seconds > 0:
            now = datetime.now(timezone.utc)
            expired: list[str] = []
            for url, entry in list(files.items()):
                cached_at = _parse_iso(entry.get("cached_at"))
                if cached_at is None:
                    continue
                if cached_at.tzinfo is None:
                    cached_at = cached_at.replace(tzinfo=timezone.utc)
                if (now - cached_at).total_seconds() > self._ttl_seconds:
                    expired.append(url)
            for key in expired:
                del files[key]
                changed = True

        if self._max_entries > 0 and len(files) > self._max_entries:
            sorted_keys = sorted(
                files,
                key=lambda k: _parse_iso(files[k].get("cached_at")) or datetime.min.replace(tzinfo=timezone.utc),
            )
            for key in sorted_keys[: len(files) - self._max_entries]:
                del files[key]
                changed = True

        if changed:
            await self._backend.save(self._data)

    def get(self, key: str, thumbhash: str | None = None) -> dict[str, Any] | None:
        if not self._data or "files" not in self._data:
            return None

        # Take a local reference so a concurrent ``async_set`` rebuilding
        # the dict cannot pull the rug out mid-read.
        files = self._data["files"]
        entry = files.get(key)
        if not entry:
            return None

        if self._use_thumbhash:
            if thumbhash is not None:
                stored = entry.get("thumbhash")
                if stored and stored != thumbhash:
                    # Mark stale — actual deletion happens lock-protected
                    # in the next mutation. Returning None is sufficient
                    # for the caller to skip the cache hit.
                    return None
        elif self._ttl_seconds > 0:
            cached_at = _parse_iso(entry.get("cached_at"))
            if cached_at is not None:
                if cached_at.tzinfo is None:
                    cached_at = cached_at.replace(tzinfo=timezone.utc)
                age = (datetime.now(timezone.utc) - cached_at).total_seconds()
                if age > self._ttl_seconds:
                    return None

        return {
            "file_id": entry.get("file_id"),
            "type": entry.get("type"),
            "size": entry.get("size"),
        }

    async def async_set(
        self,
        key: str,
        file_id: str,
        media_type: str,
        thumbhash: str | None = None,
        size: int | None = None,
    ) -> None:
        async with self._lock:
            if self._data is None:
                self._data = {"files": {}}

            entry: dict[str, Any] = {
                "file_id": file_id,
                "type": media_type,
                "cached_at": datetime.now(timezone.utc).isoformat(),
            }
            if thumbhash is not None:
                entry["thumbhash"] = thumbhash
            if size is not None:
                entry["size"] = size

            self._data["files"][key] = entry
            await self._backend.save(self._data)

    async def async_set_many(
        self,
        entries: list[tuple[str, str, str, str | None] | tuple[str, str, str, str | None, int | None]],
    ) -> None:
        """Bulk-store file_id cache entries.

        Each entry is a tuple ``(key, file_id, media_type, thumbhash[, size])``.
        The size element is optional for backward compatibility with callers
        that don't yet track upload sizes.
        """
        if not entries:
            return
        async with self._lock:
            if self._data is None:
                self._data = {"files": {}}

            now_iso = datetime.now(timezone.utc).isoformat()
            for item in entries:
                if len(item) == 5:
                    key, file_id, media_type, thumbhash, size = item
                else:
                    key, file_id, media_type, thumbhash = item
                    size = None
                entry: dict[str, Any] = {
                    "file_id": file_id,
                    "type": media_type,
                    "cached_at": now_iso,
                }
                if thumbhash is not None:
                    entry["thumbhash"] = thumbhash
                if size is not None:
                    entry["size"] = size
                self._data["files"][key] = entry

            await self._backend.save(self._data)

    async def async_remove(self) -> None:
        async with self._lock:
            await self._backend.remove()
            self._data = None

    def stats(self) -> dict[str, Any]:
        """Return summary stats about the current cache contents.

        Includes the number of cached entries, total tracked size in bytes
        (only counts entries with a recorded ``size``), and the oldest /
        newest ``cached_at`` timestamps (ISO strings, or ``None`` if empty).
        Timestamps are compared as parsed ``datetime`` objects so mixed
        timezone formats (``Z`` vs ``+00:00``) order correctly.
        """
        files = self._data.get("files", {}) if self._data else {}
        count = len(files)
        total_size = 0
        oldest_dt: datetime | None = None
        newest_dt: datetime | None = None
        oldest_str: str | None = None
        newest_str: str | None = None
        for entry in files.values():
            size = entry.get("size")
            if isinstance(size, int):
                total_size += size
            cached_at = entry.get("cached_at")
            dt = _parse_iso(cached_at)
            if dt is None or not cached_at:
                continue
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=timezone.utc)
            if oldest_dt is None or dt < oldest_dt:
                oldest_dt, oldest_str = dt, cached_at
            if newest_dt is None or dt > newest_dt:
                newest_dt, newest_str = dt, cached_at
        return {
            "count": count,
            "total_size_bytes": total_size,
            "oldest": oldest_str,
            "newest": newest_str,
        }
