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

Talks to an Immich server using an API key. HTTP lives in ``ImmichClient``;
the parsing/URL helpers are pure functions so they can be unit-tested without
a live server or aiohttp.

API shape (Immich v1.13x / v3, ``/api`` prefix, ``x-api-key`` header):
- ``GET /api/server/about`` -> ``{version, ...}`` (used to validate URL + key)
- ``GET /api/albums`` -> ``[{id, albumName, assetCount}]``
- ``GET /api/people`` -> ``{people: [{id, name}]}``
- ``POST /api/search/metadata`` ``{albumIds|personIds, type, size, page}``
    -> ``{assets: {items: [...], total, nextPage}}``. List items carry
    ``id``/``type``/``localDateTime``/``fileCreatedAt``/``width``/``height``/
    ``originalFileName`` but NOT ``exifInfo``.
- ``GET /api/assets/{id}`` -> full asset incl ``exifInfo`` (lat/long, city,
    country, description) - used to enrich location/description per asset.
- Image bytes: ``/api/assets/{id}/thumbnail?size=preview|fullsize`` or
    ``/api/assets/{id}/original`` (all require the ``x-api-key`` header).
"""
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

_TIMEOUT = 30
_PAGE_SIZE = 1000
_MAX_ASSETS = 20_000


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


def build_image_url(base_url: str, asset_id: str, size: str) -> str:
    """Build the image URL for an asset at the requested size.

    ``preview`` / ``fullsize`` map to the thumbnail endpoint; ``original``
    fetches the untouched original file. The API key is NOT included here - it
    is sent as a request header so it never leaks into logs or the camera's
    ``current_url`` attribute.
    """
    base = normalize_base_url(base_url)
    if size == "original":
        return f"{base}/api/assets/{asset_id}/original"
    thumb_size = "fullsize" if size == "fullsize" else "preview"
    return f"{base}/api/assets/{asset_id}/thumbnail?size={thumb_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"`` style label from EXIF place fields.

    Prefers ``city`` for the locality, falling back to ``state``. Appends the
    country when present. Returns ``None`` when nothing usable is available.
    """
    parts: list[str] = []
    locality = None
    for candidate in (city, state):
        if isinstance(candidate, str) and candidate.strip():
            locality = candidate.strip()
            break
    if locality:
        parts.append(locality)
    if isinstance(country, str) and country.strip():
        parts.append(country.strip())
    return ", ".join(parts) if parts else None


def parse_search_page(payload: Any) -> tuple[list[dict[str, Any]], int | None]:
    """Return ``(image_items, next_page)`` from a search/metadata response.

    Filters out non-image assets and anything trashed/archived. ``next_page``
    is the page number to request next, or ``None`` when done.
    """
    assets = (payload or {}).get("assets") if isinstance(payload, dict) else None
    if not isinstance(assets, dict):
        return [], None
    items = assets.get("items")
    out = _filter_image_items(items)
    next_page = assets.get("nextPage")
    if isinstance(next_page, str) and next_page.isdigit():
        next_page = int(next_page)
    if not isinstance(next_page, int):
        next_page = None
    return out, next_page


def parse_random(payload: Any) -> list[dict[str, Any]]:
    """Return image items from a ``/api/search/random`` response.

    ``search/random`` returns a plain list of assets (no pagination wrapper).
    """
    if isinstance(payload, list):
        return _filter_image_items(payload)
    # Some cores wrap it like search/metadata; handle that too.
    if isinstance(payload, dict):
        assets = payload.get("assets")
        if isinstance(assets, dict):
            return _filter_image_items(assets.get("items"))
    return []


def _filter_image_items(items: Any) -> list[dict[str, Any]]:
    """Keep only non-trashed, non-archived image assets with an id."""
    out: list[dict[str, Any]] = []
    if isinstance(items, list):
        for it in items:
            if not isinstance(it, dict):
                continue
            if str(it.get("type", "")).upper() != "IMAGE":
                continue
            if it.get("isTrashed") or it.get("isArchived"):
                continue
            if not it.get("id"):
                continue
            out.append(it)
    return out


def build_search_body(
    selection_type: str, selection_id: str | None, filter_body: dict | None
) -> dict[str, Any]:
    """Build the ``search/metadata`` request body for a selection.

    Always constrains to images. For ``search`` the user-supplied filter is
    used as a base (with ``type`` forced to IMAGE). ``album``/``person`` add
    the id filter; ``favorites`` sets ``isFavorite``; ``all`` adds nothing.
    """
    body: dict[str, Any] = {"type": "IMAGE"}
    if selection_type == "search" and isinstance(filter_body, dict):
        body = dict(filter_body)
        body["type"] = "IMAGE"
    elif selection_type == "album" and selection_id:
        body["albumIds"] = [selection_id]
    elif selection_type == "person" and selection_id:
        body["personIds"] = [selection_id]
    elif selection_type == "favorites":
        body["isFavorite"] = True
    # ``all`` -> no extra filter (whole library).
    return body


def parse_composite_selection(selection_id: str | None) -> dict[str, Any]:
    """Parse a composite selection id into ``{albums, people, favorites}``.

    The id is a JSON object; anything malformed degrades to an empty
    composite (which means "all photos").
    """
    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_bodies(
    selection_id: str | None, filter_body: dict | None = None
) -> list[dict[str, Any]]:
    """Build one ``search/metadata`` body per composite union member.

    Immich has no OR, so each album, person, the favorites flag and any
    custom filter becomes its own image query; the caller unions the
    results. An empty composite yields a single unfiltered query -> the
    whole library ("all photos").
    """
    sel = parse_composite_selection(selection_id)
    bodies: list[dict[str, Any]] = []
    for aid in sel["albums"]:
        bodies.append({"type": "IMAGE", "albumIds": [aid]})
    for pid in sel["people"]:
        bodies.append({"type": "IMAGE", "personIds": [pid]})
    if sel["favorites"]:
        bodies.append({"type": "IMAGE", "isFavorite": True})
    if isinstance(filter_body, dict) and filter_body:
        member = dict(filter_body)
        member["type"] = "IMAGE"
        bodies.append(member)
    if not bodies:
        bodies.append({"type": "IMAGE"})
    return bodies


def parse_asset_exif(asset: Any) -> dict[str, Any]:
    """Extract the metadata we surface from a full asset detail response."""
    out: dict[str, Any] = {}
    if not isinstance(asset, dict):
        return out
    exif = asset.get("exifInfo")
    if not isinstance(exif, dict):
        return out
    captured = _to_epoch_ms(exif.get("dateTimeOriginal")) or _to_epoch_ms(
        asset.get("localDateTime")
    )
    if captured is not None:
        out["captured_at"] = captured
    lat = exif.get("latitude")
    lon = exif.get("longitude")
    if isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
        # Immich returns 0/0 or null when there is no fix; treat 0,0 as none.
        if not (abs(lat) < 1e-6 and abs(lon) < 1e-6):
            out["latitude"] = float(lat)
            out["longitude"] = float(lon)
    label = location_label(exif.get("city"), exif.get("state"), exif.get("country"))
    if label:
        out["location"] = label
    desc = exif.get("description")
    if isinstance(desc, str) and desc.strip():
        out["description"] = desc.strip()
    return out


class ImmichClient:
    """Thin async wrapper over the Immich REST API."""

    def __init__(self, hass, base_url: str, api_key: str) -> None:
        self.hass = hass
        self.base_url = normalize_base_url(base_url)
        self.api_key = api_key

    @property
    def headers(self) -> dict[str, str]:
        return {"x-api-key": self.api_key, "Accept": "application/json"}

    @property
    def image_headers(self) -> dict[str, str]:
        return {"x-api-key": self.api_key}

    async def _get(self, path: str) -> Any:
        session = async_get_clientsession(self.hass)
        async with async_timeout.timeout(_TIMEOUT):
            async with session.get(self.base_url + path, headers=self.headers) as resp:
                resp.raise_for_status()
                return await resp.json()

    async def _post(self, path: str, body: dict[str, Any]) -> Any:
        session = async_get_clientsession(self.hass)
        async with async_timeout.timeout(_TIMEOUT):
            async with session.post(
                self.base_url + path, headers=self.headers, json=body
            ) as resp:
                resp.raise_for_status()
                return await resp.json()

    async def async_validate(self) -> str | None:
        """Return the server version if the URL + key work, else raise."""
        data = await self._get("/api/server/about")
        return data.get("version") if isinstance(data, dict) else None

    async def async_list_albums(self) -> list[dict[str, Any]]:
        data = await self._get("/api/albums")
        return data if isinstance(data, list) else []

    async def async_list_people(self) -> list[dict[str, Any]]:
        data = await self._get("/api/people")
        if isinstance(data, dict):
            people = data.get("people")
            return people if isinstance(people, list) else []
        return data if isinstance(data, list) else []

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

        ``random`` uses ``/api/search/random`` (a single, unpaginated batch).
        Everything else pages through ``/api/search/metadata`` with a body
        built from the selection.
        """
        if selection_type == "random":
            body = {"size": min(_PAGE_SIZE, 250), "type": "IMAGE"}
            if isinstance(filter_body, dict):
                merged = dict(filter_body)
                merged.update(body)
                body = merged
            payload = await self._post("/api/search/random", body)
            return parse_random(payload)

        if selection_type == "people":
            # Immich treats multiple personIds in one query as AND (only photos
            # where everyone appears together). To get OR (any of the people),
            # query each person separately and union by asset id. See #19.
            ids = [p for p in (selection_id or "").split(",") if p]
            bodies = [{"type": "IMAGE", "personIds": [p]} for p in ids]
            return await self._collect_union(bodies)

        if selection_type == "albums":
            # Same OR behavior for a set of albums: query each album on its own
            # and union the results, deduped by asset id.
            ids = [a for a in (selection_id or "").split(",") if a]
            bodies = [{"type": "IMAGE", "albumIds": [a]} for a in ids]
            return await self._collect_union(bodies)

        if selection_type == "composite":
            # A mix of albums, people, favorites and/or a custom filter. Each
            # is queried on its own and unioned; an empty composite means the
            # whole library. See #19.
            bodies = build_composite_bodies(selection_id, filter_body)
            return await self._collect_union(bodies)

        base = build_search_body(selection_type, selection_id, filter_body)
        return await self._collect_metadata(base)

    async def _collect_metadata(self, base: dict[str, Any]) -> list[dict[str, Any]]:
        """Page through ``search/metadata`` for a prebuilt body."""
        collected: list[dict[str, Any]] = []
        page: int | None = 1
        while page is not None and len(collected) < _MAX_ASSETS:
            body = dict(base)
            body["size"] = _PAGE_SIZE
            body["page"] = page
            payload = await self._post("/api/search/metadata", body)
            items, next_page = parse_search_page(payload)
            collected.extend(items)
            page = next_page
        return collected

    async def _collect_union(
        self, bodies: list[dict[str, Any]]
    ) -> list[dict[str, Any]]:
        """Union several ``search/metadata`` queries (OR), deduped by asset id.

        Each body is queried on its own so the results are a union (any of),
        not Immich's default AND (only assets that match every filter at
        once). See #19.
        """
        seen: set[str] = set()
        out: list[dict[str, Any]] = []
        for body in bodies:
            if len(out) >= _MAX_ASSETS:
                break
            items = await self._collect_metadata(body)
            for it in items:
                aid = it.get("id")
                if aid and aid not in seen:
                    seen.add(aid)
                    out.append(it)
        return out

    async def async_get_asset(self, asset_id: str) -> dict[str, Any]:
        return await self._get(f"/api/assets/{asset_id}")
