"""PhotoPrism (direct API) client and pure parsing helpers.

Talks to a PhotoPrism server using either an app password (used directly as a
Bearer token) or a username + password (exchanged for a session access token).
HTTP lives in ``PhotoprismClient``; the parsing/URL helpers are pure functions
so they can be unit-tested without a live server or aiohttp.

API shape (PhotoPrism ``/api/v1``, Bearer auth):
- ``POST /api/v1/session`` ``{username, password}`` -> ``{access_token, ...}``
    (only needed for the username + password auth method).
- ``GET /api/v1/photos`` ``?count&offset&order=newest&primary=true`` plus a
    filter (``s=<album_uid>``, ``q=subject:<uid>``, ``q=favorite:true``, or a
    custom ``q=``) -> a JSON list of photos. Metadata is inline (``TakenAt``,
    ``Lat``/``Lng``, ``PlaceLabel``, ``Title``, ``Description``, ``Portrait``,
    ``Width``/``Height``), so there is no per-asset enrichment call. The
    response headers carry ``X-Preview-Token`` (for thumbnail URLs) and
    ``X-Count`` (number of items returned, for pagination).
- ``GET /api/v1/albums`` / ``GET /api/v1/subjects`` -> albums / people.
- Image bytes: ``/api/v1/t/<sha1_hash>/<preview_token>/<size>`` - the preview
    token in the URL is enough (cookie-free access by design), so no auth
    header is needed to fetch thumbnails.
"""
from __future__ import annotations

import json
from datetime import datetime, timezone
from typing import Any

import async_timeout
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .const import PHOTOPRISM_SELECTION_COMPOSITE

_TIMEOUT = 30
_PAGE_SIZE = 1000
_MAX_ASSETS = 20_000

# PhotoPrism media types that render as a still image (videos are skipped).
_IMAGE_TYPES = {"image", "raw", "live", "animated"}


def normalize_base_url(url: str) -> str:
    """Strip trailing slashes and a trailing ``/api``/``/api/v1`` from a URL."""
    u = (url or "").strip().rstrip("/")
    for suffix in ("/api/v1", "/api"):
        if u.endswith(suffix):
            u = u[: -len(suffix)]
    return u.rstrip("/")


def build_image_url(base_url: str, file_hash: str, token: str, size: str) -> str:
    """Build the thumbnail URL for a file hash at the requested size.

    The preview ``token`` is included in the URL on purpose: PhotoPrism serves
    thumbnails cookie-free and gates them with this rotatable token, so no auth
    header is required to fetch the bytes.
    """
    base = normalize_base_url(base_url)
    return f"{base}/api/v1/t/{file_hash}/{token}/{size}"


def _to_epoch_ms(value: Any) -> int | None:
    """Parse an ISO-8601 timestamp to epoch milliseconds, or ``None``."""
    if not isinstance(value, str) or not value:
        return None
    try:
        iso = value.replace("Z", "+00:00")
        dt = datetime.fromisoformat(iso)
    except ValueError:
        return None
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    try:
        return int(dt.timestamp() * 1000)
    except (OverflowError, OSError, ValueError):
        return None


def location_label(city: Any, state: Any, country: Any) -> str | None:
    """Build a short ``"City, Country"`` label from place fields.

    Prefers ``city`` for the locality, falling back to ``state``. Appends the
    country when present. Used only when PhotoPrism's own ``PlaceLabel`` is
    unavailable.
    """
    parts: list[str] = []
    locality = None
    for candidate in (city, state):
        if isinstance(candidate, str) and candidate.strip() and candidate.strip().lower() != "unknown":
            locality = candidate.strip()
            break
    if locality:
        parts.append(locality)
    if isinstance(country, str) and country.strip() and country.strip().lower() not in ("unknown", "zz"):
        parts.append(country.strip())
    return ", ".join(parts) if parts else None


def _is_image(item: Any) -> bool:
    """True for photo items that render as a still image (skip videos)."""
    if not isinstance(item, dict):
        return False
    if not item.get("Hash") or not item.get("UID"):
        return False
    t = str(item.get("Type", "image")).lower()
    return t in _IMAGE_TYPES


def parse_photo_meta(item: dict[str, Any]) -> dict[str, Any]:
    """Extract the metadata we surface from a search photo item."""
    out: dict[str, Any] = {}
    captured = _to_epoch_ms(item.get("TakenAt")) or _to_epoch_ms(item.get("TakenAtLocal"))
    if captured is not None:
        out["captured_at"] = captured
    lat = item.get("Lat")
    lng = item.get("Lng")
    if isinstance(lat, (int, float)) and isinstance(lng, (int, float)):
        # 0/0 means "no fix" in PhotoPrism; treat it as no location.
        if not (abs(lat) < 1e-6 and abs(lng) < 1e-6):
            out["latitude"] = float(lat)
            out["longitude"] = float(lng)
    # PhotoPrism builds a human-readable ``PlaceLabel`` (e.g. "San Diego,
    # California, USA"); prefer it, falling back to city/country parts.
    label = item.get("PlaceLabel")
    if not (isinstance(label, str) and label.strip() and label.strip().lower() != "unknown"):
        label = location_label(
            item.get("PlaceCity"), item.get("PlaceState"), item.get("PlaceCountry")
        )
    if isinstance(label, str) and label.strip() and label.strip().lower() != "unknown":
        out["location"] = label.strip()
    # Only surface a real caption. PhotoPrism auto-generates ``Title`` from
    # place + date for every photo, so it would be noise as a caption.
    desc = item.get("Description")
    if isinstance(desc, str) and desc.strip():
        out["description"] = desc.strip()
    return out


def build_query_params(
    selection_type: str, selection_id: str | None
) -> dict[str, str]:
    """Build the search query params for a single (non-composite) member.

    ``album`` uses the ``s`` scope; ``person`` and ``favorites`` use ``q``
    filters; ``search`` passes the user's raw query through; ``all`` adds
    nothing (whole library).
    """
    if selection_type == "album" and selection_id:
        return {"s": selection_id}
    if selection_type == "person" and selection_id:
        return {"q": f"subject:{selection_id}"}
    if selection_type == "favorites":
        return {"q": "favorite:true"}
    if selection_type == "search" and selection_id:
        return {"q": selection_id}
    return {}


def parse_composite_selection(selection_id: str | None) -> dict[str, Any]:
    """Parse a composite selection id into ``{albums, people, favorites}``."""
    albums: list[str] = []
    people: list[str] = []
    favorites = False
    if selection_id:
        try:
            data = json.loads(selection_id)
        except (ValueError, TypeError):
            data = None
        if isinstance(data, dict):
            albums = [a for a in data.get("albums", []) if isinstance(a, str) and a]
            people = [p for p in data.get("people", []) if isinstance(p, str) and p]
            favorites = bool(data.get("favorites"))
    return {"albums": albums, "people": people, "favorites": favorites}


def build_composite_queries(
    selection_id: str | None, filter_query: str | None = None
) -> list[dict[str, str]]:
    """Build one search-param dict per composite union member.

    PhotoPrism has no OR across filters, so each album, person, the favorites
    flag and any custom query becomes its own request; the caller unions the
    results. An empty composite yields a single unfiltered query (the whole
    library).
    """
    sel = parse_composite_selection(selection_id)
    queries: list[dict[str, str]] = []
    for uid in sel["albums"]:
        queries.append({"s": uid})
    for uid in sel["people"]:
        queries.append({"q": f"subject:{uid}"})
    if sel["favorites"]:
        queries.append({"q": "favorite:true"})
    if isinstance(filter_query, str) and filter_query.strip():
        queries.append({"q": filter_query.strip()})
    if not queries:
        queries.append({})
    return queries


class PhotoprismAuthError(Exception):
    """Raised when PhotoPrism authentication fails."""


class PhotoprismClient:
    """Thin async wrapper over the PhotoPrism REST API."""

    def __init__(
        self,
        hass,
        base_url: str,
        *,
        auth_method: str,
        token: str | None = None,
        username: str | None = None,
        password: str | None = None,
    ) -> None:
        self.hass = hass
        self.base_url = normalize_base_url(base_url)
        self.auth_method = auth_method
        self._token = token
        self._username = username
        self._password = password
        # Bearer token used for API calls: the app password directly, or the
        # session access token obtained from username + password.
        self._bearer: str | None = token if auth_method == "app_password" else None
        # Preview token captured from the most recent search response, used to
        # build thumbnail URLs.
        self.preview_token: str | None = None

    @property
    def _headers(self) -> dict[str, str]:
        h = {"Accept": "application/json"}
        if self._bearer:
            h["Authorization"] = "Bearer " + self._bearer
        return h

    async def _login(self) -> None:
        """Exchange username + password for a session access token."""
        session = async_get_clientsession(self.hass)
        async with async_timeout.timeout(_TIMEOUT):
            async with session.post(
                self.base_url + "/api/v1/session",
                json={"username": self._username, "password": self._password},
                headers={"Accept": "application/json"},
            ) as resp:
                if resp.status != 200:
                    raise PhotoprismAuthError(f"session login failed: {resp.status}")
                data = await resp.json()
        token = data.get("access_token") if isinstance(data, dict) else None
        if not token:
            raise PhotoprismAuthError("session login returned no access_token")
        self._bearer = token

    async def async_authenticate(self) -> None:
        """Ensure a usable Bearer token is available."""
        if self.auth_method == "user_password":
            await self._login()
        elif not self._bearer:
            raise PhotoprismAuthError("no app password configured")

    async def _get(self, path: str, params: dict[str, str] | None = None) -> tuple[Any, dict[str, str]]:
        """GET returning ``(json, headers)``, re-authenticating once on 401."""
        if self._bearer is None:
            await self.async_authenticate()
        session = async_get_clientsession(self.hass)
        for attempt in (1, 2):
            async with async_timeout.timeout(_TIMEOUT):
                async with session.get(
                    self.base_url + path, params=params, headers=self._headers
                ) as resp:
                    if resp.status == 401 and attempt == 1 and self.auth_method == "user_password":
                        # Session token likely expired; re-login and retry once.
                        await self._login()
                        continue
                    resp.raise_for_status()
                    return await resp.json(), dict(resp.headers)
        raise PhotoprismAuthError("unauthorized after re-authentication")

    async def async_validate(self) -> None:
        """Authenticate and confirm the search endpoint responds."""
        await self.async_authenticate()
        await self._get("/api/v1/photos", {"count": "1", "public": "false"})

    async def async_list_albums(self) -> list[dict[str, Any]]:
        data, _ = await self._get(
            "/api/v1/albums", {"count": "1000", "offset": "0", "type": "album"}
        )
        return data if isinstance(data, list) else []

    async def async_list_people(self) -> list[dict[str, Any]]:
        data, _ = await self._get(
            "/api/v1/subjects", {"count": "1000", "type": "person"}
        )
        return data if isinstance(data, list) else []

    async def async_collect_assets(
        self,
        selection_type: str,
        selection_id: str | None = None,
        filter_query: str | None = None,
    ) -> list[dict[str, Any]]:
        """Collect image photo items for a selection.

        ``composite`` unions albums + people + favorites (+ optional query);
        everything else is a single filtered search. The preview token from the
        responses is captured on ``self.preview_token`` for URL building.
        """
        if selection_type == PHOTOPRISM_SELECTION_COMPOSITE:
            queries = build_composite_queries(selection_id, filter_query)
        else:
            queries = [build_query_params(selection_type, selection_id)]
        return await self._collect_union(queries)

    async def _collect_union(
        self, queries: list[dict[str, str]]
    ) -> list[dict[str, Any]]:
        """Union several searches (OR), deduped by photo UID."""
        seen: set[str] = set()
        out: list[dict[str, Any]] = []
        for query in queries:
            if len(out) >= _MAX_ASSETS:
                break
            for item in await self._search(query):
                uid = item.get("UID")
                if uid and uid not in seen:
                    seen.add(uid)
                    out.append(item)
        return out

    async def _search(self, query: dict[str, str]) -> list[dict[str, Any]]:
        """Page through ``/api/v1/photos`` for one filter, image items only."""
        collected: list[dict[str, Any]] = []
        offset = 0
        while len(collected) < _MAX_ASSETS:
            params = {
                "count": str(_PAGE_SIZE),
                "offset": str(offset),
                "order": "newest",
                "primary": "true",
                "public": "false",
                "merged": "false",
            }
            params.update(query)
            data, headers = await self._get("/api/v1/photos", params)
            token = headers.get("X-Preview-Token")
            if token:
                self.preview_token = token
            items = data if isinstance(data, list) else []
            for it in items:
                if _is_image(it):
                    collected.append(it)
            if len(items) < _PAGE_SIZE:
                break
            offset += _PAGE_SIZE
        return collected
