"""Immich service provider — concrete implementation of ServiceProvider."""

from __future__ import annotations

import asyncio
import hashlib
import logging
import time
from typing import Any

import aiohttp

from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
from notify_bridge_core.templates.variables import TemplateVariableDefinition

from .asset_utils import asset_to_media
from .change_detector import _MAX_ASSETS_PER_EVENT, detect_album_changes
from .client import ImmichClient
from .models import ImmichAlbumData, ImmichAlbumMeta, ImmichAssetInfo

_LOGGER = logging.getLogger(__name__)


# Module-level users cache shared across ImmichServiceProvider instances.
# Users change rarely (new people joining the server, display-name edits), so
# refetching on every tracker's ``connect()`` is wasteful — a fleet of 10
# trackers on the same Immich server otherwise issues 10 ``GET /api/users``
# calls per poll cycle.  TTL is conservative (1h) and a hashed key keeps the
# raw api_key out of dict keys in case of a memory dump.
_USERS_CACHE_TTL_SECONDS = 3600
# Lazy init: ``asyncio.Lock()`` at module import binds to whichever event
# loop is current at import time (often none, or the wrong one when tests
# spin up dedicated loops). Defer creation to first use.
_users_cache_lock: asyncio.Lock | None = None
_users_cache: dict[str, tuple[float, dict[str, str]]] = {}


def _get_users_cache_lock() -> asyncio.Lock:
    """Return the module users-cache lock, creating it on first call."""
    global _users_cache_lock
    if _users_cache_lock is None:
        _users_cache_lock = asyncio.Lock()
    return _users_cache_lock


def _users_cache_key(url: str, api_key: str) -> str:
    digest = hashlib.sha256(f"{url}|{api_key}".encode("utf-8")).hexdigest()
    return digest[:32]


async def _get_cached_users(
    client: ImmichClient, url: str, api_key: str
) -> dict[str, str]:
    """Return ``{user_id: display_name}`` for the server, reusing cache entries
    whose TTL has not elapsed. Misses and stale hits fall through to a real
    fetch under a single lock so concurrent polls don't stampede the server.
    """
    key = _users_cache_key(url, api_key)
    now = time.monotonic()
    entry = _users_cache.get(key)
    if entry is not None and (now - entry[0]) < _USERS_CACHE_TTL_SECONDS:
        return entry[1]

    async with _get_users_cache_lock():
        # Re-check after acquiring the lock — another coroutine may have
        # refreshed the entry while we waited.
        entry = _users_cache.get(key)
        if entry is not None and (time.monotonic() - entry[0]) < _USERS_CACHE_TTL_SECONDS:
            return entry[1]
        fresh = await client.get_users()
        _users_cache[key] = (time.monotonic(), fresh)
        return fresh


def invalidate_users_cache() -> None:
    """Drop every cached users dict. Exposed for callers that mutate users
    (e.g. provider config changes, integration tests) and need the next
    ``connect()`` to re-fetch.
    """
    _users_cache.clear()


# Immich-specific template variables
IMMICH_VARIABLES: list[TemplateVariableDefinition] = [
    TemplateVariableDefinition(
        name="album_name",
        type="string",
        description="Album name (alias for collection_name)",
        example="Vacation 2026",
        provider_type=ServiceProviderType.IMMICH,
    ),
    TemplateVariableDefinition(
        name="album_id",
        type="string",
        description="Album ID (alias for collection_id)",
        example="abc-123-def",
        provider_type=ServiceProviderType.IMMICH,
    ),
    TemplateVariableDefinition(
        name="album_url",
        type="string",
        description="Web URL to the album",
        example="https://photos.example.com/albums/abc-123",
        provider_type=ServiceProviderType.IMMICH,
    ),
    TemplateVariableDefinition(
        name="people",
        type="list",
        description="Detected people names across all assets",
        example='["Alice", "Bob"]',
        provider_type=ServiceProviderType.IMMICH,
    ),
    TemplateVariableDefinition(
        name="shared",
        type="bool",
        description="Whether the album is shared",
        example="true",
        provider_type=ServiceProviderType.IMMICH,
    ),
    TemplateVariableDefinition(
        name="public_url",
        type="string",
        description="Public share link URL (if available)",
        example="https://photos.example.com/share/abc123",
        provider_type=ServiceProviderType.IMMICH,
    ),
    TemplateVariableDefinition(
        name="protected_url",
        type="string",
        description="Password-protected share link URL (if available)",
        example="https://photos.example.com/share/def456",
        provider_type=ServiceProviderType.IMMICH,
    ),
    TemplateVariableDefinition(
        name="old_album_name",
        type="string",
        description="Previous album name (for rename events)",
        example="Old Name",
        provider_type=ServiceProviderType.IMMICH,
    ),
    TemplateVariableDefinition(
        name="new_album_name",
        type="string",
        description="New album name (for rename events)",
        example="New Name",
        provider_type=ServiceProviderType.IMMICH,
    ),
    TemplateVariableDefinition(
        name="photo_count",
        type="int",
        description="Number of photos in album",
        example="42",
        provider_type=ServiceProviderType.IMMICH,
    ),
    TemplateVariableDefinition(
        name="video_count",
        type="int",
        description="Number of videos in album",
        example="8",
        provider_type=ServiceProviderType.IMMICH,
    ),
    TemplateVariableDefinition(
        name="owner",
        type="string",
        description="Album owner name",
        example="John",
        provider_type=ServiceProviderType.IMMICH,
    ),
]


class ImmichServiceProvider(ServiceProvider):
    """Immich photo server provider."""

    provider_type = ServiceProviderType.IMMICH

    def __init__(
        self,
        session: aiohttp.ClientSession,
        url: str,
        api_key: str,
        external_domain: str | None = None,
        name: str = "Immich",
    ) -> None:
        self._client = ImmichClient(session, url, api_key)
        self._name = name
        self._external_domain = external_domain
        self._users_cache: dict[str, str] = {}

    @property
    def client(self) -> ImmichClient:
        return self._client

    async def connect(self) -> bool:
        ok = await self._client.ping()
        if ok:
            await self._client.get_server_config()
            if self._external_domain:
                self._client.external_domain = self._external_domain
            self._users_cache = await _get_cached_users(
                self._client, self._client.url, self._client.api_key,
            )
        return ok

    async def disconnect(self) -> None:
        pass  # session lifecycle managed by caller

    async def poll(
        self,
        collection_ids: list[str],
        tracker_state: dict[str, Any],
    ) -> tuple[list[ServiceEvent], dict[str, Any]]:
        events: list[ServiceEvent] = []
        new_state = dict(tracker_state)
        external_url = self._client.external_url

        # Tick-scoped share-link cache. Populated lazily on first enrichment;
        # a tracker watching 5 albums with changes now issues 1 ``/api/shared-links``
        # request per tick instead of 5 (and the endpoint is server-wide — each
        # call was already fetching all links and discarding most of them).
        self._tick_shared_links: dict[str, list] | None = None

        # Fan out the cheap meta probes in parallel. For a tracker that
        # watches 20 albums on the same Immich server this turns a 20-hop
        # serial wait into ~1 round-trip's worth of latency. aiohttp's
        # connection pool caps concurrency per host, so this can't stampede.
        meta_results = await asyncio.gather(
            *(self._client.get_album_meta(aid) for aid in collection_ids),
            return_exceptions=True,
        )

        for album_id, meta_or_exc in zip(collection_ids, meta_results):
            if isinstance(meta_or_exc, BaseException):
                # Transient failure on this album — preserve existing state
                # and move on. Logging at warning so flaky albums surface in
                # the log without flooding on hard outages.
                _LOGGER.warning(
                    "Meta probe failed for album %s: %s", album_id, meta_or_exc,
                )
                continue
            meta = meta_or_exc
            if meta is None:
                # Album deleted
                if album_id in new_state:
                    from notify_bridge_core.models.events import EventType
                    from datetime import datetime, timezone
                    events.append(ServiceEvent(
                        event_type=EventType.COLLECTION_DELETED,
                        provider_type=ServiceProviderType.IMMICH,
                        provider_name=self._name,
                        collection_id=album_id,
                        collection_name=new_state.get(album_id, {}).get("name", "Unknown"),
                        timestamp=datetime.now(timezone.utc),
                    ))
                    del new_state[album_id]
                continue

            prev = new_state.get(album_id)
            prev_fingerprint = prev.get("meta_fingerprint") if prev else None
            has_pending = bool(prev and prev.get("pending_asset_ids"))

            # 2) Fast-path: fingerprint match and no pending assets → no work.
            #    We still refresh the fingerprint slot (no-op if identical) and
            #    leave asset_ids untouched on disk.
            if (
                prev is not None
                and prev_fingerprint == meta.fingerprint()
                and not has_pending
            ):
                continue

            # 3) Decide: delta fetch (cheap, active-album case) or full
            #    fetch (first tick + reconciliation for removals).
            old_fp = prev.get("meta_fingerprint") if prev else None
            old_asset_count = (old_fp or {}).get("asset_count", 0)
            old_updated_at = (old_fp or {}).get("updated_at", "")

            # Gate for the delta path:
            #   - must be tracked already (prev exists, has asset_ids)
            #   - must have a prior timestamp (empty ⇒ migrated DB row)
            #   - asset_count must not have decreased (removals need full fetch)
            can_delta = (
                prev is not None
                and bool(prev.get("asset_ids"))
                and bool(old_updated_at)
                and meta.asset_count >= old_asset_count
            )

            if can_delta:
                delta_events = await self._poll_delta(
                    album_id=album_id,
                    prev=prev,
                    new_meta=meta,
                    old_updated_at=old_updated_at,
                )
                if delta_events is not None:
                    events.extend(delta_events["events"])
                    new_state[album_id] = delta_events["new_state"]
                    continue
                # delta_events is None ⇒ delta saw more additions than the
                # net count increase (mixed add+remove) ⇒ fall through to
                # the full-fetch path so removals get detected.

            # Full fetch: first tick, or count-decreased, or delta-unsafe.
            # Bypass the module-level album cache — this path runs when we
            # specifically need the current server state (e.g. to detect
            # asset removals), so a stale cached entry would silently delay
            # the event.
            album = await self._client.get_album(
                album_id, self._users_cache, use_cache=False,
            )
            if album is None:
                # Album was deleted between meta probe and full fetch — handle
                # the deletion the same way as above.
                if album_id in new_state:
                    from notify_bridge_core.models.events import EventType
                    from datetime import datetime, timezone
                    events.append(ServiceEvent(
                        event_type=EventType.COLLECTION_DELETED,
                        provider_type=ServiceProviderType.IMMICH,
                        provider_name=self._name,
                        collection_id=album_id,
                        collection_name=new_state.get(album_id, {}).get("name", "Unknown"),
                        timestamp=datetime.now(timezone.utc),
                    ))
                    del new_state[album_id]
                continue

            if prev is None:
                # First time seeing this album — store state, no event
                new_state[album_id] = _serialize_album_state(album, meta)
                continue

            # Reconstruct previous album data for comparison
            old_album = _deserialize_album_state(album_id, prev)
            pending = set(prev.get("pending_asset_ids", []))

            detected_events, updated_pending = detect_album_changes(
                old_album, album, pending, self._name, external_url
            )

            if detected_events:
                await self._enrich_with_shared_links(album_id, detected_events)
                events.extend(detected_events)

            # Update state
            state = _serialize_album_state(album, meta)
            state["pending_asset_ids"] = list(updated_pending)
            new_state[album_id] = state

        return events, new_state

    async def _poll_delta(
        self,
        *,
        album_id: str,
        prev: dict[str, Any],
        new_meta: ImmichAlbumMeta,
        old_updated_at: str,
    ) -> dict[str, Any] | None:
        """Delta-fetch path for an active album.

        Calls ``search/metadata`` with ``updatedAfter`` instead of pulling
        the full asset list. Returns a dict with ``events`` and ``new_state``
        on success, or ``None`` to signal the caller to retry via full fetch
        (used when a mixed add+remove is detected — the delta endpoint can't
        tell us *what* was removed, only that additions alone don't account
        for the net count change).

        Trades strict detection of removals-during-mixed-changes for a
        drastic reduction in bytes fetched per tick. On a 200k-asset album
        where 50 were just added, we fetch ~50 asset records instead of
        200 000.
        """
        from datetime import datetime, timezone

        from notify_bridge_core.models.events import EventType

        prev_asset_ids: set[str] = set(prev.get("asset_ids", []))
        prev_pending: set[str] = set(prev.get("pending_asset_ids", []))

        raw_assets = await self._client.search_album_assets_updated_after(
            album_id, old_updated_at
        )

        # Parse everything that came back. We need unprocessed entries too
        # (they feed the ``pending_asset_ids`` list used by the original
        # change detector's processed-later logic).
        delta_assets: list[ImmichAssetInfo] = []
        for raw in raw_assets:
            try:
                delta_assets.append(
                    ImmichAssetInfo.from_api_response(raw, self._users_cache)
                )
            except Exception as err:  # noqa: BLE001 — one bad record ≠ abort tick
                _LOGGER.warning(
                    "Skipping malformed asset record in delta response: %s", err
                )

        newly_added: list[ImmichAssetInfo] = []
        still_pending: set[str] = set()
        for asset in delta_assets:
            if asset.is_processed:
                if asset.id not in prev_asset_ids:
                    newly_added.append(asset)
            else:
                still_pending.add(asset.id)

        old_asset_count = int((prev.get("meta_fingerprint") or {}).get("asset_count", 0))
        net_change = new_meta.asset_count - old_asset_count

        # If delta found more "added" assets than the net count change,
        # a concurrent removal happened. Full fetch is the only way to
        # know what was removed — bail out so the caller retries.
        if net_change >= 0 and len(newly_added) > net_change:
            _LOGGER.info(
                "Delta for album %s found %d additions but net change is %d "
                "— falling back to full fetch for removal reconciliation",
                album_id, len(newly_added), net_change,
            )
            return None

        # Mirror case: positive net change we couldn't account for with the
        # delta results (possibly clock skew on ``updated_at``, or an asset
        # whose timestamp is before ``old_updated_at`` yet the album's
        # ``updatedAt`` bumped). Full fetch to avoid silently missing adds.
        if net_change > 0 and len(newly_added) < net_change:
            _LOGGER.info(
                "Delta for album %s found %d additions but net change is %d "
                "— falling back to full fetch to avoid missing assets",
                album_id, len(newly_added), net_change,
            )
            return None

        events: list[ServiceEvent] = []
        now = datetime.now(timezone.utc)
        external_url = self._external_domain or self._client.external_url
        album_url = f"{external_url}/albums/{album_id}"

        # Carry album-level attributes we know from the cheap meta probe.
        # Shared-link enrichment happens further down only if we emitted
        # any asset events.
        base_extra = {
            "album_url": album_url,
            "shared": new_meta.shared,
            "asset_count": new_meta.asset_count,
            "photo_count": 0,   # unknown without per-asset scan; templates tolerate 0
            "video_count": 0,
            "people": [],
            "owner": "",
        }

        # Metadata-only events (no asset fetch needed)
        old_fp = prev.get("meta_fingerprint") or {}
        if old_fp.get("name") and old_fp["name"] != new_meta.name:
            events.append(ServiceEvent(
                event_type=EventType.COLLECTION_RENAMED,
                provider_type=ServiceProviderType.IMMICH,
                provider_name=self._name,
                collection_id=album_id,
                collection_name=new_meta.name,
                timestamp=now,
                added_assets=[],
                removed_asset_ids=[],
                added_count=0,
                removed_count=0,
                old_name=old_fp["name"],
                new_name=new_meta.name,
                extra=dict(base_extra),
            ))

        if "shared" in old_fp and bool(old_fp["shared"]) != bool(new_meta.shared):
            events.append(ServiceEvent(
                event_type=EventType.SHARING_CHANGED,
                provider_type=ServiceProviderType.IMMICH,
                provider_name=self._name,
                collection_id=album_id,
                collection_name=new_meta.name,
                timestamp=now,
                added_assets=[],
                removed_asset_ids=[],
                added_count=0,
                removed_count=0,
                old_shared=bool(old_fp["shared"]),
                new_shared=bool(new_meta.shared),
                extra=dict(base_extra),
            ))

        if newly_added:
            total_added = len(newly_added)
            truncated = newly_added[:_MAX_ASSETS_PER_EVENT]
            media_assets = [
                asset_to_media(a, self._client.external_url) for a in truncated
            ]
            extra = dict(base_extra)
            if total_added > _MAX_ASSETS_PER_EVENT:
                extra["truncated"] = True
                extra["shown_count"] = _MAX_ASSETS_PER_EVENT
                _LOGGER.info(
                    "Delta-path truncated assets_added event for album %s: %d → %d",
                    album_id, total_added, _MAX_ASSETS_PER_EVENT,
                )
            events.append(ServiceEvent(
                event_type=EventType.ASSETS_ADDED,
                provider_type=ServiceProviderType.IMMICH,
                provider_name=self._name,
                collection_id=album_id,
                collection_name=new_meta.name,
                timestamp=now,
                added_assets=media_assets,
                removed_asset_ids=[],
                added_count=total_added,
                removed_count=0,
                extra=extra,
            ))

        if events:
            await self._enrich_with_shared_links(album_id, events)

        # Rebuild state.  asset_ids grows by the newly-added processed set.
        # pending is the union of the prior pending list (things still in
        # flight) and anything the delta confirmed as not-yet-processed.
        # When net_change is 0 or negative we trust the meta count over
        # our bookkeeping — skip-path will fix drift on the next full fetch.
        new_asset_ids = prev_asset_ids | {a.id for a in newly_added}
        # Discard any previously-pending IDs that just landed as processed.
        new_pending = (prev_pending | still_pending) - {a.id for a in newly_added}

        return {
            "events": events,
            "new_state": {
                "name": new_meta.name,
                "asset_ids": list(new_asset_ids),
                "shared": new_meta.shared,
                "pending_asset_ids": list(new_pending),
                "meta_fingerprint": new_meta.fingerprint(),
            },
        }

    async def _enrich_with_shared_links(
        self, album_id: str, events_to_enrich: list[ServiceEvent]
    ) -> None:
        """Attach public/protected share link URLs to events for this album.

        Uses the tick-scoped bulk cache populated lazily on first call, so a
        tracker with changes across N albums makes one ``/api/shared-links``
        request per tick instead of N.
        """
        if self._tick_shared_links is None:
            self._tick_shared_links = await self._client.get_all_shared_links_by_album()

        shared_links = self._tick_shared_links.get(album_id, [])
        public_link = None
        protected_link = None
        for link in shared_links:
            if link.is_accessible and not link.is_expired:
                if link.has_password:
                    protected_link = link
                else:
                    public_link = link
                    break  # prefer non-password link

        ext_domain = self._external_domain or self._client.external_url
        for evt in events_to_enrich:
            if public_link:
                evt.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
            elif protected_link:
                evt.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"

    def get_available_variables(self) -> list[TemplateVariableDefinition]:
        return list(IMMICH_VARIABLES)

    def get_provider_config_schema(self) -> dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "url": {
                    "type": "string",
                    "description": "Immich server URL",
                    "example": "http://immich:2283",
                },
                "api_key": {
                    "type": "string",
                    "description": "Immich API key",
                    "secret": True,
                },
                "external_domain": {
                    "type": "string",
                    "description": "Public-facing domain (optional)",
                    "example": "https://photos.example.com",
                },
            },
            "required": ["url", "api_key"],
        }

    async def list_collections(self) -> list[dict[str, Any]]:
        albums = await self._client.get_albums()
        return [
            {
                "id": a.get("id", ""),
                "name": a.get("albumName", "Unnamed"),
                "asset_count": a.get("assetCount", 0),
                "updated_at": a.get("updatedAt", ""),
            }
            for a in albums
        ]

    async def test_connection(self) -> dict[str, Any]:
        ok = await self._client.ping()
        if ok:
            config = await self._client.get_server_config()
            return {
                "ok": True,
                "message": "Connected to Immich",
                "external_domain": config,
            }
        return {"ok": False, "message": "Failed to connect to Immich"}


def _serialize_album_state(
    album: ImmichAlbumData,
    meta: ImmichAlbumMeta | None = None,
) -> dict[str, Any]:
    """Serialize album state for persistence.

    ``meta`` carries the fingerprint used for cheap no-change detection on
    subsequent polls. When omitted (legacy callers, tests) we synthesize a
    best-effort fingerprint from the full album — it will still match on the
    next tick if nothing changed, which is what matters.
    """
    if meta is None:
        fingerprint = {
            "updated_at": album.updated_at,
            "asset_count": len(album.asset_ids),
            "shared": album.shared,
            "name": album.name,
            "thumbnail_asset_id": album.thumbnail_asset_id or "",
        }
    else:
        fingerprint = meta.fingerprint()
    return {
        "name": album.name,
        "asset_ids": list(album.asset_ids),
        "shared": album.shared,
        "pending_asset_ids": [],
        "meta_fingerprint": fingerprint,
    }


def _deserialize_album_state(album_id: str, state: dict[str, Any]) -> ImmichAlbumData:
    """Create a minimal ImmichAlbumData from saved state for comparison."""
    return ImmichAlbumData(
        id=album_id,
        name=state.get("name", ""),
        asset_count=len(state.get("asset_ids", [])),
        photo_count=0,
        video_count=0,
        created_at="",
        updated_at="",
        shared=state.get("shared", False),
        owner="",
        thumbnail_asset_id=None,
        asset_ids=set(state.get("asset_ids", [])),
    )
