"""
Book Catalog - book-centered gallery with grouped photos and inline title editing.
"""

import base64
import hashlib
import hmac
import json
import os
import re
import sqlite3
import time
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from urllib.parse import quote

import httpx
import jwt
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from pydantic import BaseModel

try:
    import importlib

    psycopg = importlib.import_module("psycopg")
except ImportError:  # pragma: no cover - local sqlite mode can run without psycopg
    psycopg = None

load_dotenv()


# --- Config -----------------------------------------------------------------

DB_PATH = Path(__file__).parent.parent / "books.db"
IMAGES_BASE = Path(os.getenv("IMAGES_PATH", r"C:\Users\ignac\OneDrive\Pictures\selected_libros"))
OPTIMIZED_IMAGES_BASE = Path(os.getenv("OPTIMIZED_IMAGES_PATH", str(Path(__file__).parent.parent / "optimized_images")))
DATABASE_URL = os.getenv("DATABASE_URL", "").strip()
DATABASE_ADAPTER = os.getenv("DATABASE_ADAPTER", "").strip().lower()
FOLDER_PRIORITY = {"front": 0, "root": 1, "side": 2}
AUTH_MODE = os.getenv("AUTH_MODE", "").strip().lower()
APP_PASSWORD = os.getenv("APP_PASSWORD", "").strip()
APP_SECRET_KEY = os.getenv("APP_SECRET_KEY", "change-me-in-production").strip() or "change-me-in-production"
SESSION_COOKIE_NAME = os.getenv("SESSION_COOKIE_NAME", "bibliothek_session").strip() or "bibliothek_session"
SESSION_TTL_SECONDS = int(os.getenv("SESSION_TTL_SECONDS", "2592000"))
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").strip().lower() in {"1", "true", "yes", "on"}
SUPABASE_URL = os.getenv("SUPABASE_URL", "").strip().rstrip("/")
SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY", "").strip()
SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "").strip()
SUPABASE_AUTH_REDIRECT_URL = os.getenv("SUPABASE_AUTH_REDIRECT_URL", "").strip()
OWNER_EMAILS = {
    email.strip().lower()
    for email in os.getenv("OWNER_EMAILS", "ignaciolagosruiz@gmail.com").split(",")
    if email.strip()
}
IMAGE_BUCKET = os.getenv("IMAGE_BUCKET", "catalog-images").strip() or "catalog-images"
IMAGE_ACCESS_MODE = os.getenv("IMAGE_ACCESS_MODE", "local").strip().lower() or "local"
REMOTE_IMAGE_BASE_URL = os.getenv("REMOTE_IMAGE_BASE_URL", "").strip().rstrip("/")
SIGNED_URL_TTL_SECONDS = int(os.getenv("SIGNED_URL_TTL_SECONDS", "604800"))
if DATABASE_ADAPTER in {"sqlite", "postgres", "rest"}:
    DB_MODE = DATABASE_ADAPTER
elif os.getenv("VERCEL") == "1" and SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY:
    DB_MODE = "rest"
elif DATABASE_URL:
    DB_MODE = "postgres"
else:
    DB_MODE = "sqlite"
SEARCH_OPERATOR = "ILIKE" if DB_MODE == "postgres" else "LIKE"
if not AUTH_MODE:
    if SUPABASE_URL and SUPABASE_ANON_KEY:
        AUTH_MODE = "supabase"
    elif APP_PASSWORD:
        AUTH_MODE = "password"
    else:
        AUTH_MODE = "none"
REVIEW_FIELDS = [
    "title",
    "subtitle",
    "author",
    "translator",
    "publisher",
    "year",
    "edition",
    "language",
    "series",
    "volume",
    "condition",
    "special_features",
    "other_text",
    "raw_ocr_text",
]
REVIEW_STATES = {"pending", "verified", "flagged", "corrected"}

app = FastAPI(title="Book Catalog Gallery")

SIGNED_URL_CACHE: dict[str, tuple[float, str]] = {}
JWKS_CLIENT = jwt.PyJWKClient(f"{SUPABASE_URL}/auth/v1/.well-known/jwks.json") if SUPABASE_URL else None


# --- Database ----------------------------------------------------------------


class HybridRow(dict):
    def __init__(self, columns, values):
        super().__init__(zip(columns, values))
        self._values = tuple(values)

    def __getitem__(self, key):
        if isinstance(key, int):
            return self._values[key]
        return super().__getitem__(key)


def pg_row_factory(cursor):
    columns = [column.name for column in cursor.description]

    def make_row(values):
        return HybridRow(columns, values)

    return make_row


def normalize_database_url(url: str) -> str:
    if url.startswith("postgres://"):
        return url.replace("postgres://", "postgresql://", 1)
    return url


class CompatConnection:
    def __init__(self):
        self.backend = DB_MODE
        if self.backend == "postgres":
            if psycopg is None:
                raise RuntimeError("psycopg is required when DATABASE_URL is configured")
            self.conn = psycopg.connect(normalize_database_url(DATABASE_URL), row_factory=pg_row_factory)
        else:
            self.conn = sqlite3.connect(str(DB_PATH))
            self.conn.row_factory = sqlite3.Row

    def _convert_query(self, query: str) -> str:
        if self.backend == "postgres":
            return query.replace("?", "%s")
        return query

    def execute(self, query: str, params=()):
        cursor = self.conn.cursor()
        cursor.execute(self._convert_query(query), params or ())
        return cursor

    def executemany(self, query: str, seq_of_params):
        cursor = self.conn.cursor()
        cursor.executemany(self._convert_query(query), seq_of_params)
        return cursor

    def commit(self):
        self.conn.commit()

    def close(self):
        self.conn.close()


def get_db():
    return CompatConnection()


def rest_headers(prefer_representation: bool = False) -> dict[str, str]:
    if not SUPABASE_URL or not SUPABASE_SERVICE_ROLE_KEY:
        raise RuntimeError("Supabase REST mode requires SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY")
    headers = {
        "Authorization": f"Bearer {SUPABASE_SERVICE_ROLE_KEY}",
        "apikey": SUPABASE_SERVICE_ROLE_KEY,
        "Content-Type": "application/json",
    }
    if prefer_representation:
        headers["Prefer"] = "return=representation"
    return headers


def rest_fetch_rows(select: str = "*", extra_params: Optional[dict[str, str]] = None) -> list[dict]:
    params = {
        "select": select,
        "order": "id.asc",
        "limit": "1000",
    }
    if extra_params:
        params.update(extra_params)
    response = httpx.get(
        f"{SUPABASE_URL}/rest/v1/book_images",
        headers=rest_headers(),
        params=params,
        timeout=60.0,
    )
    response.raise_for_status()
    return response.json()


def rest_fetch_row(image_id: int, select: str = "*") -> Optional[dict]:
    response = httpx.get(
        f"{SUPABASE_URL}/rest/v1/book_images",
        headers=rest_headers(),
        params={
            "select": select,
            "id": f"eq.{image_id}",
            "limit": "1",
        },
        timeout=30.0,
    )
    response.raise_for_status()
    payload = response.json()
    return payload[0] if payload else None


def rest_update_row(image_id: int, values: dict) -> Optional[dict]:
    response = httpx.patch(
        f"{SUPABASE_URL}/rest/v1/book_images",
        headers=rest_headers(prefer_representation=True),
        params={"id": f"eq.{image_id}"},
        json=values,
        timeout=30.0,
    )
    response.raise_for_status()
    payload = response.json()
    return payload[0] if payload else None


def rest_update_rows(image_ids: list[int], values: dict) -> list[dict]:
    if not image_ids:
        return []
    ids = ",".join(str(image_id) for image_id in image_ids)
    response = httpx.patch(
        f"{SUPABASE_URL}/rest/v1/book_images",
        headers=rest_headers(prefer_representation=True),
        params={"id": f"in.({ids})"},
        json=values,
        timeout=60.0,
    )
    response.raise_for_status()
    return response.json()


def ensure_book_image_columns():
    if DB_MODE != "sqlite":
        return

    conn = sqlite3.connect(str(DB_PATH))
    cols = {row[1] for row in conn.execute("PRAGMA table_info(book_images)").fetchall()}

    if "description" not in cols:
        conn.execute('ALTER TABLE book_images ADD COLUMN description TEXT DEFAULT ""')
    if "review_status" not in cols:
        conn.execute("ALTER TABLE book_images ADD COLUMN review_status TEXT DEFAULT 'pending'")
    if "review_updated_at" not in cols:
        conn.execute('ALTER TABLE book_images ADD COLUMN review_updated_at TEXT DEFAULT ""')
    if "image_url" not in cols:
        conn.execute('ALTER TABLE book_images ADD COLUMN image_url TEXT DEFAULT ""')
    if "thumb_url" not in cols:
        conn.execute('ALTER TABLE book_images ADD COLUMN thumb_url TEXT DEFAULT ""')
    if "storage_path" not in cols:
        conn.execute('ALTER TABLE book_images ADD COLUMN storage_path TEXT DEFAULT ""')
    if "thumb_storage_path" not in cols:
        conn.execute('ALTER TABLE book_images ADD COLUMN thumb_storage_path TEXT DEFAULT ""')

    conn.execute(
        "UPDATE book_images SET review_status = 'pending' WHERE review_status IS NULL OR TRIM(review_status) = ''"
    )
    conn.commit()
    conn.close()


ensure_book_image_columns()


# --- Helpers -----------------------------------------------------------------

def clean_text(value) -> str:
    if value is None:
        return ""
    if isinstance(value, list):
        value = ", ".join(str(item).strip() for item in value if str(item).strip())
    return str(value).strip()


def session_signature(payload: str) -> str:
    return hmac.new(APP_SECRET_KEY.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()


def create_session_token() -> str:
    expires_at = int(time.time()) + SESSION_TTL_SECONDS
    payload = f"shared:{expires_at}"
    return f"{payload}:{session_signature(payload)}"


def is_valid_session_token(token: str) -> bool:
    try:
        payload, signature = token.rsplit(":", 1)
        _, expires_at_text = payload.split(":", 1)
        expires_at = int(expires_at_text)
    except ValueError:
        return False

    expected = session_signature(payload)
    if not hmac.compare_digest(signature, expected):
        return False
    return expires_at >= int(time.time())


def request_is_authenticated(request: Request) -> bool:
    if AUTH_MODE != "password" or not APP_PASSWORD:
        return True
    token = clean_text(request.cookies.get(SESSION_COOKIE_NAME))
    return bool(token) and is_valid_session_token(token)


def extract_bearer_token(request: Request) -> str:
    auth_header = clean_text(request.headers.get("Authorization"))
    if auth_header.lower().startswith("bearer "):
        return auth_header[7:].strip()
    return ""


def supabase_issuer() -> str:
    return f"{SUPABASE_URL}/auth/v1" if SUPABASE_URL else ""


def verify_supabase_token_locally(token: str) -> dict:
    if not token or not SUPABASE_URL or JWKS_CLIENT is None:
        raise HTTPException(status_code=401, detail="Authentication required")

    header = jwt.get_unverified_header(token)
    alg = clean_text(header.get("alg")).upper()
    if alg.startswith("HS"):
        raise RuntimeError("Legacy symmetric JWTs require fallback validation")

    signing_key = JWKS_CLIENT.get_signing_key_from_jwt(token)
    claims = jwt.decode(
        token,
        signing_key.key,
        algorithms=[alg],
        issuer=supabase_issuer(),
        options={"verify_aud": False, "require": ["exp", "iss", "sub"]},
    )
    return claims


def verify_supabase_token_via_userinfo(token: str) -> dict:
    if not SUPABASE_URL or not SUPABASE_ANON_KEY:
        raise HTTPException(status_code=401, detail="Supabase auth is not configured")

    response = httpx.get(
        f"{SUPABASE_URL}/auth/v1/user",
        headers={
            "Authorization": f"Bearer {token}",
            "apikey": SUPABASE_ANON_KEY,
        },
        timeout=15.0,
    )
    if response.status_code != 200:
        raise HTTPException(status_code=401, detail="Invalid session")
    payload = response.json()
    return {
        "sub": payload.get("id"),
        "email": clean_text(payload.get("email")),
        "role": clean_text(payload.get("role") or payload.get("app_metadata", {}).get("role")),
        "claims": payload,
    }


def verify_supabase_access_token(token: str) -> dict:
    if not token:
        raise HTTPException(status_code=401, detail="Authentication required")
    try:
        claims = verify_supabase_token_locally(token)
        return {
            "sub": claims.get("sub"),
            "email": clean_text(claims.get("email")),
            "role": clean_text(claims.get("role")),
            "claims": claims,
        }
    except HTTPException:
        raise
    except Exception:  # noqa: BLE001
        return verify_supabase_token_via_userinfo(token)


def current_supabase_user(request: Request) -> dict:
    user = getattr(request.state, "user", None)
    if not user:
        raise HTTPException(status_code=401, detail="Authentication required")
    return user


def is_owner_email(email: str) -> bool:
    return clean_text(email).lower() in OWNER_EMAILS


def app_config() -> dict:
    return {
        "authMode": AUTH_MODE,
        "supabaseUrl": SUPABASE_URL,
        "supabaseAnonKey": SUPABASE_ANON_KEY,
        "authRedirectUrl": SUPABASE_AUTH_REDIRECT_URL,
        "inviteEnabled": bool(SUPABASE_SERVICE_ROLE_KEY),
    }


def quote_storage_path(path: str) -> str:
    return quote(path.lstrip("/"), safe="/")


def get_public_image_url(path: str) -> str:
    if REMOTE_IMAGE_BASE_URL:
        return f"{REMOTE_IMAGE_BASE_URL}/{quote_storage_path(path)}"
    if not SUPABASE_URL or not IMAGE_BUCKET:
        return ""
    return f"{SUPABASE_URL}/storage/v1/object/public/{IMAGE_BUCKET}/{quote_storage_path(path)}"


def get_signed_image_url(path: str) -> str:
    if not SUPABASE_URL or not IMAGE_BUCKET or not SUPABASE_SERVICE_ROLE_KEY:
        return ""

    cache_key = f"{IMAGE_BUCKET}:{path}"
    cached = SIGNED_URL_CACHE.get(cache_key)
    if cached and cached[0] > time.time() + 300:
        return cached[1]

    response = httpx.post(
        f"{SUPABASE_URL}/storage/v1/object/sign/{IMAGE_BUCKET}/{quote_storage_path(path)}",
        headers={
            "Authorization": f"Bearer {SUPABASE_SERVICE_ROLE_KEY}",
            "apikey": SUPABASE_SERVICE_ROLE_KEY,
            "Content-Type": "application/json",
        },
        json={"expiresIn": SIGNED_URL_TTL_SECONDS},
        timeout=20.0,
    )
    response.raise_for_status()
    payload = response.json()
    signed = clean_text(payload.get("signedURL") or payload.get("signedUrl") or payload.get("signed_url"))
    if not signed:
        return ""

    signed_url = signed if signed.startswith("http") else f"{SUPABASE_URL}/storage/v1{signed}"
    SIGNED_URL_CACHE[cache_key] = (time.time() + SIGNED_URL_TTL_SECONDS, signed_url)
    return signed_url


def resolve_hosted_url(path: str) -> str:
    cleaned = clean_text(path)
    if not cleaned:
        return ""
    if IMAGE_ACCESS_MODE == "local_optimized":
        return f"/media/{quote_storage_path(cleaned)}"
    if IMAGE_ACCESS_MODE == "signed":
        return get_signed_image_url(cleaned)
    if IMAGE_ACCESS_MODE == "public" or REMOTE_IMAGE_BASE_URL:
        return get_public_image_url(cleaned)
    return ""


def known_text(value) -> str:
    text = clean_text(value)
    if not text or text.lower() == "unknown":
        return ""
    return text


def normalize_review_status(value) -> str:
    text = clean_text(value).lower()
    return text if text in REVIEW_STATES else "pending"


def review_timestamp() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def normalize_title(value) -> str:
    title = known_text(value)
    if not title:
        return ""
    title = re.sub(r"[^\w\s]", " ", title.lower(), flags=re.UNICODE)
    title = re.sub(r"\s+", " ", title).strip()
    return title


def encode_group_key(normalized_title: str, image_id: int) -> str:
    if normalized_title:
        token = base64.urlsafe_b64encode(normalized_title.encode("utf-8")).decode("ascii")
        return f"title-{token.rstrip('=')}"
    return f"image-{image_id}"


def decode_group_key(group_key: str) -> dict:
    if group_key.startswith("image-"):
        try:
            return {"type": "image", "image_id": int(group_key.split("-", 1)[1])}
        except ValueError as exc:
            raise HTTPException(status_code=400, detail="Invalid image group key") from exc

    if group_key.startswith("title-"):
        token = group_key.split("-", 1)[1]
        padding = "=" * (-len(token) % 4)
        try:
            normalized_title = base64.urlsafe_b64decode((token + padding).encode("ascii")).decode("utf-8")
        except Exception as exc:  # noqa: BLE001
            raise HTTPException(status_code=400, detail="Invalid title group key") from exc
        return {"type": "title", "normalized_title": normalized_title}

    raise HTTPException(status_code=400, detail="Unknown group key format")


def photo_label(folder: str) -> str:
    return {
        "front": "Front cover",
        "root": "Title page / inside page",
        "side": "Spine",
    }.get(folder, "Book photo")


def extract_message_text(content) -> str:
    if isinstance(content, str):
        return content.strip()
    if content is None:
        return ""
    if isinstance(content, list):
        parts = []
        for chunk in content:
            if isinstance(chunk, str):
                text = chunk.strip()
            else:
                text = clean_text(getattr(chunk, "text", ""))
                if not text and isinstance(chunk, dict):
                    text = clean_text(chunk.get("text") or chunk.get("content"))
            if text:
                parts.append(text)
        return "\n".join(parts).strip()
    return clean_text(content)


def attach_image_urls(item: dict) -> dict:
    image_url = clean_text(item.get("image_url"))
    thumb_url = clean_text(item.get("thumb_url"))
    storage_path = clean_text(item.get("storage_path"))
    thumb_storage_path = clean_text(item.get("thumb_storage_path")) or storage_path

    display_url = image_url or resolve_hosted_url(storage_path)
    preview_url = thumb_url or resolve_hosted_url(thumb_storage_path) or display_url

    if not display_url:
        display_url = f"/photos/{item.get('source_folder', '')}/{item.get('source_filename', '')}"
    if not preview_url:
        preview_url = display_url

    item["display_url"] = display_url
    item["thumb_url"] = preview_url
    return item


def serialize_review_image(row: dict) -> dict:
    item = dict(row)
    folder = clean_text(item.get("source_folder"))
    item["photo_label"] = photo_label(folder)
    item["review_status"] = normalize_review_status(item.get("review_status"))
    item["review_updated_at"] = clean_text(item.get("review_updated_at"))
    item["group_key"] = encode_group_key(normalize_title(item.get("title")), item["id"])
    attach_image_urls(item)
    return item


def review_queue_item(row: dict) -> dict:
    item = serialize_review_image(row)
    return {
        "id": item["id"],
        "title": known_text(item.get("title")) or f"Image #{item['id']}",
        "author": known_text(item.get("author")),
        "language": known_text(item.get("language")),
        "source_folder": item.get("source_folder", ""),
        "source_filename": item.get("source_filename", ""),
        "display_url": item.get("display_url", ""),
        "thumb_url": item.get("thumb_url", ""),
        "photo_label": item["photo_label"],
        "review_status": item["review_status"],
        "review_updated_at": item["review_updated_at"],
    }


def photo_sort_key(row: dict) -> tuple:
    return (FOLDER_PRIORITY.get(clean_text(row.get("source_folder")), 9), row["id"])


def pick_cover_image(rows: list[dict]) -> dict:
    return sorted(rows, key=photo_sort_key)[0]


def most_common_value(rows: list[dict], field: str) -> str:
    values = [known_text(row.get(field)) for row in rows]
    values = [value for value in values if value]
    if not values:
        return ""
    return Counter(values).most_common(1)[0][0]


def year_sort_value(value) -> int:
    text = clean_text(value)
    match = re.search(r"(1[6-9]\d{2}|20\d{2}|2100)", text)
    if match:
        return int(match.group(1))
    return 9999


def image_brief(row: dict, with_urls: bool = True) -> dict:
    item = {
        "id": row["id"],
        "source_folder": row["source_folder"],
        "source_filename": row["source_filename"],
        "image_url": row.get("image_url", ""),
        "thumb_url": row.get("thumb_url", ""),
        "storage_path": row.get("storage_path", ""),
        "thumb_storage_path": row.get("thumb_storage_path", ""),
        "photo_label": photo_label(row["source_folder"]),
    }
    return attach_image_urls(item) if with_urls else item


def summarize_group(rows: list[dict], with_urls: bool = True, sample_limit: int = 4) -> dict:
    ordered_rows = sorted(rows, key=photo_sort_key)
    cover = pick_cover_image(ordered_rows)
    normalized_title = normalize_title(ordered_rows[0].get("title"))
    editable_title = most_common_value(ordered_rows, "title")
    display_title = editable_title or f"Untitled #{cover['id']}"
    described_count = sum(1 for row in ordered_rows if clean_text(row.get("description")))
    review_counts = Counter(normalize_review_status(row.get("review_status")) for row in ordered_rows)
    folders = sorted(
        {clean_text(row.get("source_folder")) for row in ordered_rows if clean_text(row.get("source_folder"))},
        key=lambda folder: FOLDER_PRIORITY.get(folder, 9),
    )

    return {
        "group_key": encode_group_key(normalized_title, cover["id"]),
        "title": display_title,
        "editable_title": editable_title,
        "author": most_common_value(ordered_rows, "author"),
        "publisher": most_common_value(ordered_rows, "publisher"),
        "year": most_common_value(ordered_rows, "year"),
        "language": most_common_value(ordered_rows, "language"),
        "image_count": len(ordered_rows),
        "described_count": described_count,
        "review_counts": {
            "pending": review_counts["pending"],
            "verified": review_counts["verified"],
            "flagged": review_counts["flagged"],
            "corrected": review_counts["corrected"],
        },
        "needs_review_count": review_counts["pending"] + review_counts["flagged"],
        "done_review_count": review_counts["verified"] + review_counts["corrected"],
        "folders": folders,
        "cover_image": image_brief(cover, with_urls=with_urls),
        "sample_images": [image_brief(row, with_urls=with_urls) for row in ordered_rows[:sample_limit]],
        "min_image_id": min(row["id"] for row in ordered_rows),
    }


def group_rows(rows: list[dict]) -> dict[str, list[dict]]:
    grouped = defaultdict(list)
    for row in rows:
        item = dict(row)
        key = encode_group_key(normalize_title(item.get("title")), item["id"])
        grouped[key].append(item)
    return grouped


def build_groups(rows: list[dict], with_urls: bool = True, sample_limit: int = 4) -> list[dict]:
    return [summarize_group(group, with_urls=with_urls, sample_limit=sample_limit) for group in group_rows(rows).values()]


def sort_groups(groups: list[dict], sort: str, order: str) -> list[dict]:
    reverse = order == "desc"

    def text_key(value) -> str:
        return known_text(value).lower() or "~~~~"

    if sort == "title":
        key_func = lambda group: (text_key(group["title"]), group["min_image_id"])
    elif sort == "author":
        key_func = lambda group: (text_key(group["author"]), text_key(group["title"]), group["min_image_id"])
    elif sort == "year":
        key_func = lambda group: (year_sort_value(group["year"]), text_key(group["title"]), group["min_image_id"])
    elif sort == "image_count":
        key_func = lambda group: (group["image_count"], text_key(group["title"]), group["min_image_id"])
    else:
        key_func = lambda group: group["min_image_id"]

    return sorted(groups, key=key_func, reverse=reverse)


def build_filters(
    search: Optional[str],
    folder: Optional[str],
    language: Optional[str],
    has_title: Optional[bool],
) -> tuple[str, list]:
    conditions = []
    params = []

    if search:
        conditions.append(
            f"(title {SEARCH_OPERATOR} ? OR author {SEARCH_OPERATOR} ? OR publisher {SEARCH_OPERATOR} ? OR raw_ocr_text {SEARCH_OPERATOR} ?)"
        )
        term = f"%{search}%"
        params.extend([term, term, term, term])

    if folder:
        conditions.append("source_folder = ?")
        params.append(folder)

    if language:
        conditions.append(f"language {SEARCH_OPERATOR} ?")
        params.append(f"%{language}%")

    if has_title is True:
        conditions.append("title != 'Unknown' AND title != ''")
    elif has_title is False:
        conditions.append("(title = 'Unknown' OR title = '')")

    where = " WHERE " + " AND ".join(conditions) if conditions else ""
    return where, params


def filter_rows_python(
    rows: list[dict],
    search: Optional[str],
    folder: Optional[str],
    language: Optional[str],
    has_title: Optional[bool],
) -> list[dict]:
    filtered = []
    needle = clean_text(search).lower()
    for row in rows:
        if folder and clean_text(row.get("source_folder")) != folder:
            continue
        if language and language.lower() not in clean_text(row.get("language")).lower():
            continue
        title_known = bool(known_text(row.get("title")))
        if has_title is True and not title_known:
            continue
        if has_title is False and title_known:
            continue
        if needle:
            haystack = " ".join(
                [
                    clean_text(row.get("title")),
                    clean_text(row.get("author")),
                    clean_text(row.get("publisher")),
                    clean_text(row.get("raw_ocr_text")),
                ]
            ).lower()
            if needle not in haystack:
                continue
        filtered.append(dict(row))
    return filtered


def postgrest_escape_like(value: str) -> str:
    text = clean_text(value)
    return text.replace("%", "").replace(",", " ").replace("(", " ").replace(")", " ")


def rest_filter_params(
    search: Optional[str],
    folder: Optional[str],
    language: Optional[str],
    has_title: Optional[bool],
) -> dict[str, str]:
    params: dict[str, str] = {}
    clauses: list[str] = []
    if search:
        needle = postgrest_escape_like(search)
        clauses.append(
            f"or(title.ilike.*{needle}*,author.ilike.*{needle}*,publisher.ilike.*{needle}*,raw_ocr_text.ilike.*{needle}*)"
        )
    if folder:
        clauses.append(f"source_folder.eq.{folder}")
    if language:
        clauses.append(f"language.ilike.*{postgrest_escape_like(language)}*")
    if has_title is True:
        clauses.append("title.not.eq.Unknown")
        clauses.append("title.not.eq.")
    elif has_title is False:
        clauses.append("or(title.eq.Unknown,title.eq.)")
    if clauses:
        params["and"] = f"({','.join(clauses)})"
    return params


def sort_images_python(rows: list[dict], sort: str, order: str) -> list[dict]:
    reverse = order == "desc"

    def text_key(value) -> str:
        return clean_text(value).lower()

    if sort == "title":
        key_func = lambda row: (text_key(row.get("title")), row.get("id", 0))
    elif sort == "author":
        key_func = lambda row: (text_key(row.get("author")), text_key(row.get("title")), row.get("id", 0))
    elif sort == "year":
        key_func = lambda row: (year_sort_value(row.get("year")), text_key(row.get("title")), row.get("id", 0))
    elif sort == "source_folder":
        key_func = lambda row: (FOLDER_PRIORITY.get(clean_text(row.get("source_folder")), 9), row.get("id", 0))
    elif sort == "source_filename":
        key_func = lambda row: (text_key(row.get("source_filename")), row.get("id", 0))
    else:
        key_func = lambda row: row.get("id", 0)

    return sorted(rows, key=key_func, reverse=reverse)


def rows_for_group_key(rows: list[dict], group_key: str) -> list[dict]:
    decoded = decode_group_key(group_key)
    if decoded["type"] == "image":
        return [row for row in rows if row["id"] == decoded["image_id"]]

    normalized_title = decoded["normalized_title"]
    return [row for row in rows if normalize_title(row.get("title")) == normalized_title]


def build_group_detail(rows: list[dict]) -> dict:
    ordered_rows = sorted(rows, key=photo_sort_key)
    summary = summarize_group(ordered_rows)
    primary_id = summary["cover_image"]["id"]

    images = []
    for row in ordered_rows:
        item = dict(row)
        images.append(serialize_review_image(item))

    summary["images"] = images
    summary["primary_image_id"] = primary_id
    summary["primary_image_index"] = next(
        (index for index, image in enumerate(images) if image["id"] == primary_id),
        0,
    )
    return summary


PUBLIC_PATHS = {"/login", "/logout", "/healthz"}
PUBLIC_PREFIXES = ("/_vercel", "/favicon", "/.well-known")


@app.middleware("http")
async def password_gate(request: Request, call_next):
    path = request.url.path
    if path == "/" and AUTH_MODE in {"supabase", "none"}:
        return await call_next(request)
    if path in PUBLIC_PATHS or any(path.startswith(prefix) for prefix in PUBLIC_PREFIXES):
        return await call_next(request)

    if AUTH_MODE == "password":
        if request_is_authenticated(request):
            return await call_next(request)
        if path.startswith("/api/") or path.startswith("/photos/") or path.startswith("/media/"):
            return JSONResponse({"detail": "Authentication required"}, status_code=401)
        return HTMLResponse(LOGIN_HTML, status_code=401)

    if AUTH_MODE == "supabase":
        if path.startswith("/api/") or path.startswith("/photos/") or path.startswith("/media/"):
            token = extract_bearer_token(request)
            try:
                request.state.user = verify_supabase_access_token(token)
            except HTTPException as exc:
                return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
        return await call_next(request)

    return await call_next(request)


class LoginRequest(BaseModel):
    password: str


@app.post("/login")
def login(req: LoginRequest):
    if AUTH_MODE != "password":
        raise HTTPException(status_code=404, detail="Password login is disabled")
    if APP_PASSWORD and req.password != APP_PASSWORD:
        raise HTTPException(status_code=401, detail="Incorrect password")

    response = JSONResponse({"ok": True})
    response.set_cookie(
        SESSION_COOKIE_NAME,
        create_session_token(),
        httponly=True,
        max_age=SESSION_TTL_SECONDS,
        secure=COOKIE_SECURE,
        samesite="lax",
        path="/",
    )
    return response


@app.post("/logout")
def logout():
    response = JSONResponse({"ok": True})
    response.delete_cookie(SESSION_COOKIE_NAME, path="/")
    return response


@app.get("/api/auth/me")
def auth_me(request: Request):
    if AUTH_MODE == "supabase":
        user = current_supabase_user(request)
        return {
            "authenticated": True,
            "email": user.get("email", ""),
            "user_id": user.get("sub", ""),
            "is_owner": is_owner_email(user.get("email", "")),
            "auth_mode": AUTH_MODE,
        }
    return {"authenticated": True, "auth_mode": AUTH_MODE, "is_owner": True}


class InviteMemberRequest(BaseModel):
    email: str


@app.post("/api/invite-member")
def invite_member(req: InviteMemberRequest, request: Request):
    if AUTH_MODE != "supabase":
        raise HTTPException(status_code=400, detail="Supabase Auth is not enabled")
    if not SUPABASE_SERVICE_ROLE_KEY or not SUPABASE_URL:
        raise HTTPException(status_code=500, detail="Supabase service role key is missing")

    user = current_supabase_user(request)
    requester_email = clean_text(user.get("email")).lower()
    if not is_owner_email(requester_email):
        raise HTTPException(status_code=403, detail="Only owners can invite members")

    invite_email = clean_text(req.email).lower()
    if not invite_email or "@" not in invite_email:
        raise HTTPException(status_code=400, detail="Please enter a valid email")

    params = {}
    if SUPABASE_AUTH_REDIRECT_URL:
        params["redirect_to"] = SUPABASE_AUTH_REDIRECT_URL

    response = httpx.post(
        f"{SUPABASE_URL}/auth/v1/invite",
        params=params,
        headers={
            "Authorization": f"Bearer {SUPABASE_SERVICE_ROLE_KEY}",
            "apikey": SUPABASE_SERVICE_ROLE_KEY,
            "Content-Type": "application/json",
        },
        json={
            "email": invite_email,
            "data": {
                "role": "member",
                "invited_by": requester_email,
            },
        },
        timeout=20.0,
    )

    if response.status_code == 422:
        raise HTTPException(status_code=409, detail="That email is already invited or already has access")
    if response.status_code >= 400:
        detail = clean_text(response.text) or "Failed to send invite"
        raise HTTPException(status_code=400, detail=detail[:300])

    return {"ok": True, "email": invite_email}


@app.get("/healthz")
def healthz():
    return {"ok": True, "db_mode": DB_MODE, "auth_mode": AUTH_MODE}


# --- API Routes --------------------------------------------------------------

@app.get("/api/images")
def list_images(
    search: Optional[str] = Query(None),
    folder: Optional[str] = Query(None),
    language: Optional[str] = Query(None),
    has_title: Optional[bool] = Query(None),
    sort: str = Query("id"),
    order: str = Query("asc"),
    page: int = Query(1, ge=1),
    per_page: int = Query(30, ge=1, le=100),
):
    if DB_MODE == "rest":
        extra_params = rest_filter_params(search, folder, language, has_title)
        rows = rest_fetch_rows(
            "id,title,subtitle,author,publisher,year,language,source_folder,source_filename,description,review_status,review_updated_at,image_url,thumb_url,storage_path,thumb_storage_path,raw_ocr_text",
            extra_params=extra_params,
        )
        rows = sort_images_python(rows, sort, order)
        total = len(rows)
        offset = (page - 1) * per_page
        images = [serialize_review_image(row) for row in rows[offset: offset + per_page]]
        return {
            "images": images,
            "total": total,
            "page": page,
            "per_page": per_page,
            "pages": max(1, (total + per_page - 1) // per_page),
        }

    conn = get_db()
    where, params = build_filters(search, folder, language, has_title)
    total = conn.execute(f"SELECT COUNT(*) FROM book_images{where}", params).fetchone()[0]

    allowed_sorts = {"id", "title", "author", "year", "source_folder", "source_filename"}
    sort_col = sort if sort in allowed_sorts else "id"
    order_dir = "DESC" if order == "desc" else "ASC"

    offset = (page - 1) * per_page
    query = f"""
        SELECT id, title, subtitle, author, publisher, year, language,
               source_folder, source_filename, description, review_status, review_updated_at,
               image_url, thumb_url, storage_path, thumb_storage_path
        FROM book_images{where}
        ORDER BY {sort_col} {order_dir}
        LIMIT ? OFFSET ?
    """
    rows = conn.execute(query, params + [per_page, offset]).fetchall()
    conn.close()

    images = []
    for row in rows:
        images.append(serialize_review_image(dict(row)))

    return {
        "images": images,
        "total": total,
        "page": page,
        "per_page": per_page,
        "pages": max(1, (total + per_page - 1) // per_page),
    }


@app.get("/api/images/{image_id}")
def get_image_detail(image_id: int):
    if DB_MODE == "rest":
        row = rest_fetch_row(image_id)
        if not row:
            raise HTTPException(status_code=404, detail="Image not found")
        image = serialize_review_image(row)
        group_key = image["group_key"]
        all_rows = rest_fetch_rows("id,title,author,source_folder,source_filename,image_url,thumb_url,storage_path,thumb_storage_path")
        related = []
        for related_row in sorted(rows_for_group_key(all_rows, group_key), key=photo_sort_key):
            if related_row["id"] == image_id:
                continue
            related.append(
                {
                    "id": related_row["id"],
                    "title": related_row["title"],
                    "author": related_row["author"],
                    "folder": related_row["source_folder"],
                    "filename": related_row["source_filename"],
                    "reason": "same book",
                }
            )
        image["related"] = related[:20]
        return image

    conn = get_db()
    row = conn.execute("SELECT * FROM book_images WHERE id = ?", (image_id,)).fetchone()
    if not row:
        conn.close()
        raise HTTPException(status_code=404, detail="Image not found")

    image = serialize_review_image(dict(row))
    group_key = image["group_key"]

    all_rows = [
        dict(r)
        for r in conn.execute(
            "SELECT id, title, author, source_folder, source_filename, image_url, thumb_url, storage_path, thumb_storage_path FROM book_images"
        ).fetchall()
    ]
    conn.close()

    related = []
    for related_row in sorted(rows_for_group_key(all_rows, group_key), key=photo_sort_key):
        if related_row["id"] == image_id:
            continue
        related.append(
            {
                "id": related_row["id"],
                "title": related_row["title"],
                "author": related_row["author"],
                "folder": related_row["source_folder"],
                "filename": related_row["source_filename"],
                "reason": "same book",
            }
        )

    image["related"] = related[:20]
    return image


@app.get("/api/books")
def list_books(
    search: Optional[str] = Query(None),
    folder: Optional[str] = Query(None),
    language: Optional[str] = Query(None),
    has_title: Optional[bool] = Query(None),
    sort: str = Query("title"),
    order: str = Query("asc"),
    page: int = Query(1, ge=1),
    per_page: int = Query(24, ge=1, le=100),
):
    if DB_MODE == "rest":
        extra_params = rest_filter_params(search, folder, language, has_title)
        rows = rest_fetch_rows(
            "id,title,author,publisher,year,language,source_folder,source_filename,description,review_status,review_updated_at,image_url,thumb_url,storage_path,thumb_storage_path",
            extra_params=extra_params,
        )
        grouped_map = group_rows(rows)
        groups = sort_groups([summarize_group(group, with_urls=False, sample_limit=0) for group in grouped_map.values()], sort, order)
        total = len(groups)
        offset = (page - 1) * per_page
        paged = [summarize_group(grouped_map[group["group_key"]], with_urls=True, sample_limit=0) for group in groups[offset: offset + per_page]]
        return {
            "books": paged,
            "total": total,
            "page": page,
            "per_page": per_page,
            "pages": max(1, (total + per_page - 1) // per_page),
        }

    conn = get_db()
    where, params = build_filters(search, folder, language, has_title)
    rows = [
        dict(r)
        for r in conn.execute(
            f"""
            SELECT id, title, author, publisher, year, language,
                   source_folder, source_filename, description, review_status, review_updated_at,
                   image_url, thumb_url, storage_path, thumb_storage_path
            FROM book_images{where}
            """,
            params,
        ).fetchall()
    ]
    conn.close()

    groups = sort_groups(build_groups(rows, sample_limit=0), sort, order)
    total = len(groups)
    offset = (page - 1) * per_page

    return {
        "books": groups[offset: offset + per_page],
        "total": total,
        "page": page,
        "per_page": per_page,
        "pages": max(1, (total + per_page - 1) // per_page),
    }


@app.get("/api/books/{group_key}")
def get_book_group(group_key: str):
    if DB_MODE == "rest":
        rows = rest_fetch_rows()
        grouped_rows = rows_for_group_key(rows, group_key)
        if not grouped_rows:
            raise HTTPException(status_code=404, detail="Book group not found")
        return build_group_detail(grouped_rows)

    conn = get_db()
    rows = [dict(r) for r in conn.execute("SELECT * FROM book_images").fetchall()]
    conn.close()

    grouped_rows = rows_for_group_key(rows, group_key)
    if not grouped_rows:
        raise HTTPException(status_code=404, detail="Book group not found")
    return build_group_detail(grouped_rows)


class UpdateGroupTitleRequest(BaseModel):
    title: str


@app.patch("/api/book-groups/{group_key}/title")
def update_book_group_title(group_key: str, req: UpdateGroupTitleRequest):
    new_title = clean_text(req.title)
    if not new_title or new_title.lower() == "unknown":
        raise HTTPException(status_code=400, detail="Please enter a real title")

    if DB_MODE == "rest":
        rows = rest_fetch_rows("id,title")
        grouped_rows = rows_for_group_key(rows, group_key)
        if not grouped_rows:
            raise HTTPException(status_code=404, detail="Book group not found")
        image_ids = [row["id"] for row in grouped_rows]
        rest_update_rows(image_ids, {"title": new_title})
        focus_image_id = grouped_rows[0]["id"]
        return {
            "ok": True,
            "updated": len(grouped_rows),
            "title": new_title,
            "group_key": encode_group_key(normalize_title(new_title), focus_image_id),
        }

    conn = get_db()
    rows = [dict(r) for r in conn.execute("SELECT id, title FROM book_images").fetchall()]
    grouped_rows = rows_for_group_key(rows, group_key)
    if not grouped_rows:
        conn.close()
        raise HTTPException(status_code=404, detail="Book group not found")

    updates = [(new_title, row["id"]) for row in grouped_rows]
    conn.executemany("UPDATE book_images SET title = ? WHERE id = ?", updates)
    conn.commit()
    conn.close()

    focus_image_id = grouped_rows[0]["id"]
    return {
        "ok": True,
        "updated": len(grouped_rows),
        "title": new_title,
        "group_key": encode_group_key(normalize_title(new_title), focus_image_id),
    }


@app.get("/api/review/dashboard")
def get_review_dashboard():
    if DB_MODE == "rest":
        rows = rest_fetch_rows()
        counts = Counter(normalize_review_status(row.get("review_status")) for row in rows)
        next_row = next((row for row in rows if normalize_review_status(row.get("review_status")) == "pending"), None)
        flagged_rows = [row for row in rows if normalize_review_status(row.get("review_status")) == "flagged"]
        return {
            "counts": {
                "total": len(rows),
                "pending": counts["pending"],
                "verified": counts["verified"],
                "flagged": counts["flagged"],
                "corrected": counts["corrected"],
                "done": counts["verified"] + counts["corrected"],
                "remaining": counts["pending"] + counts["flagged"],
            },
            "next_image": serialize_review_image(next_row) if next_row else None,
            "flagged": [review_queue_item(row) for row in flagged_rows],
        }

    conn = get_db()
    rows = [dict(r) for r in conn.execute("SELECT * FROM book_images ORDER BY id").fetchall()]
    conn.close()

    counts = Counter(normalize_review_status(row.get("review_status")) for row in rows)
    next_row = next(
        (row for row in rows if normalize_review_status(row.get("review_status")) == "pending"),
        None,
    )
    flagged_rows = [row for row in rows if normalize_review_status(row.get("review_status")) == "flagged"]

    return {
        "counts": {
            "total": len(rows),
            "pending": counts["pending"],
            "verified": counts["verified"],
            "flagged": counts["flagged"],
            "corrected": counts["corrected"],
            "done": counts["verified"] + counts["corrected"],
            "remaining": counts["pending"] + counts["flagged"],
        },
        "next_image": serialize_review_image(next_row) if next_row else None,
        "flagged": [review_queue_item(row) for row in flagged_rows],
    }


def update_review_state(image_id: int, review_state: str) -> dict:
    if DB_MODE == "rest":
        row = rest_fetch_row(image_id)
        if not row:
            raise HTTPException(status_code=404, detail="Image not found")
        updated = rest_update_row(image_id, {"review_status": review_state, "review_updated_at": review_timestamp()})
        return serialize_review_image(updated or row)

    conn = get_db()
    row = conn.execute("SELECT * FROM book_images WHERE id = ?", (image_id,)).fetchone()
    if not row:
        conn.close()
        raise HTTPException(status_code=404, detail="Image not found")

    updated_at = review_timestamp()
    conn.execute(
        "UPDATE book_images SET review_status = ?, review_updated_at = ? WHERE id = ?",
        (review_state, updated_at, image_id),
    )
    conn.commit()
    updated = conn.execute("SELECT * FROM book_images WHERE id = ?", (image_id,)).fetchone()
    conn.close()
    return serialize_review_image(dict(updated))


@app.post("/api/review/{image_id}/verify")
def verify_review_image(image_id: int):
    return {"ok": True, "image": update_review_state(image_id, "verified")}


@app.post("/api/review/{image_id}/flag")
def flag_review_image(image_id: int):
    return {"ok": True, "image": update_review_state(image_id, "flagged")}


class ReviewCorrectionRequest(BaseModel):
    title: str = ""
    subtitle: str = ""
    author: str = ""
    translator: str = ""
    publisher: str = ""
    year: str = ""
    edition: str = ""
    language: str = ""
    series: str = ""
    volume: str = ""
    condition: str = ""
    special_features: str = ""
    other_text: str = ""
    raw_ocr_text: str = ""


@app.patch("/api/review/{image_id}")
def save_review_correction(image_id: int, req: ReviewCorrectionRequest):
    if DB_MODE == "rest":
        row = rest_fetch_row(image_id)
        if not row:
            raise HTTPException(status_code=404, detail="Image not found")
        payload = {field: clean_text(getattr(req, field)) for field in REVIEW_FIELDS}
        payload["review_status"] = "corrected"
        payload["review_updated_at"] = review_timestamp()
        updated = rest_update_row(image_id, payload)
        return {"ok": True, "image": serialize_review_image(updated or row)}

    conn = get_db()
    row = conn.execute("SELECT * FROM book_images WHERE id = ?", (image_id,)).fetchone()
    if not row:
        conn.close()
        raise HTTPException(status_code=404, detail="Image not found")

    payload = {field: clean_text(getattr(req, field)) for field in REVIEW_FIELDS}
    assignments = ", ".join(f"{field} = ?" for field in REVIEW_FIELDS)
    values: list[object] = [payload[field] for field in REVIEW_FIELDS]
    values.extend(["corrected", review_timestamp(), image_id])

    conn.execute(
        f"UPDATE book_images SET {assignments}, review_status = ?, review_updated_at = ? WHERE id = ?",
        values,
    )
    conn.commit()
    updated = conn.execute("SELECT * FROM book_images WHERE id = ?", (image_id,)).fetchone()
    conn.close()
    return {"ok": True, "image": serialize_review_image(dict(updated))}


@app.get("/api/stats")
def get_stats():
    if DB_MODE == "rest":
        rows = rest_fetch_rows("id,title,author,language,source_folder,source_filename,description,review_status,image_url,thumb_url,storage_path,thumb_storage_path")
        total = len(rows)
        with_title = sum(1 for row in rows if known_text(row.get("title")))
        with_author = sum(1 for row in rows if known_text(row.get("author")))
        with_desc = sum(1 for row in rows if clean_text(row.get("description")))
        review_counts = Counter(normalize_review_status(row.get("review_status")) for row in rows)
        folder_counts = Counter(clean_text(row.get("source_folder")) for row in rows if clean_text(row.get("source_folder")))
        language_counts = Counter(known_text(row.get("language")) for row in rows if known_text(row.get("language")))
        return {
            "total": total,
            "book_groups": len(group_rows(rows)),
            "with_title": with_title,
            "with_author": with_author,
            "with_description": with_desc,
            "review": {
                "pending": review_counts["pending"],
                "verified": review_counts["verified"],
                "flagged": review_counts["flagged"],
                "corrected": review_counts["corrected"],
                "done": review_counts["verified"] + review_counts["corrected"],
            },
            "folders": [{"folder": folder, "count": count} for folder, count in sorted(folder_counts.items())],
            "languages": [
                {"language": language, "count": count}
                for language, count in sorted(language_counts.items(), key=lambda item: (-item[1], item[0]))
            ],
        }

    conn = get_db()
    rows = [
        dict(r)
        for r in conn.execute(
            "SELECT id, title, author, language, source_folder, source_filename, description, review_status FROM book_images"
        ).fetchall()
    ]
    total = len(rows)
    with_title = sum(1 for row in rows if known_text(row.get("title")))
    with_author = sum(1 for row in rows if known_text(row.get("author")))
    with_desc = sum(1 for row in rows if clean_text(row.get("description")))
    review_counts = Counter(normalize_review_status(row.get("review_status")) for row in rows)
    folders = conn.execute(
        "SELECT source_folder, COUNT(*) FROM book_images GROUP BY source_folder ORDER BY source_folder"
    ).fetchall()
    languages = conn.execute(
        "SELECT language, COUNT(*) as cnt FROM book_images WHERE language != '' AND language != 'Unknown' GROUP BY language ORDER BY cnt DESC"
    ).fetchall()
    conn.close()

    return {
        "total": total,
        "book_groups": len(group_rows(rows)),
        "with_title": with_title,
        "with_author": with_author,
        "with_description": with_desc,
        "review": {
            "pending": review_counts["pending"],
            "verified": review_counts["verified"],
            "flagged": review_counts["flagged"],
            "corrected": review_counts["corrected"],
            "done": review_counts["verified"] + review_counts["corrected"],
        },
        "folders": [{"folder": row[0], "count": row[1]} for row in folders],
        "languages": [{"language": row[0], "count": row[1]} for row in languages],
    }


@app.get("/api/languages")
def get_languages():
    if DB_MODE == "rest":
        rows = rest_fetch_rows("language")
        return sorted({known_text(row.get("language")) for row in rows if known_text(row.get("language"))})

    conn = get_db()
    rows = conn.execute(
        "SELECT DISTINCT language FROM book_images WHERE language != '' AND language != 'Unknown' ORDER BY language"
    ).fetchall()
    conn.close()
    return [row[0] for row in rows]


# --- Serve book images -------------------------------------------------------

@app.get("/photos/{folder}/{filename}")
def serve_photo(folder: str, filename: str):
    if folder == "root":
        path = IMAGES_BASE / filename
    else:
        path = IMAGES_BASE / folder / filename
    if not path.exists():
        raise HTTPException(status_code=404, detail="Image file not found")
    return FileResponse(str(path), media_type="image/jpeg")


@app.get("/media/{asset_path:path}")
def serve_optimized_media(asset_path: str):
    base = OPTIMIZED_IMAGES_BASE.resolve()
    path = (base / asset_path).resolve()
    if path != base and base not in path.parents:
        raise HTTPException(status_code=400, detail="Invalid asset path")
    if not path.exists() or not path.is_file():
        raise HTTPException(status_code=404, detail="Optimized asset not found")
    return FileResponse(str(path), media_type="image/jpeg")


# --- AI description generation ----------------------------------------------

class GenerateRequest(BaseModel):
    image_id: int


@app.post("/api/generate-description")
def generate_description(req: GenerateRequest):
    db_conn = None
    if DB_MODE == "rest":
        image = rest_fetch_row(req.image_id)
        if not image:
            raise HTTPException(status_code=404, detail="Image not found")
    else:
        db_conn = get_db()
        row = db_conn.execute("SELECT * FROM book_images WHERE id = ?", (req.image_id,)).fetchone()
        if not row:
            db_conn.close()
            raise HTTPException(status_code=404, detail="Image not found")
        image = dict(row)

    info_parts = []
    for key, label in [
        ("title", "Title"),
        ("subtitle", "Subtitle"),
        ("author", "Author"),
        ("publisher", "Publisher"),
        ("year", "Year"),
        ("edition", "Edition"),
        ("language", "Language"),
        ("translator", "Translator"),
        ("series", "Series"),
        ("volume", "Volume"),
        ("condition", "Condition"),
        ("special_features", "Special features"),
        ("other_text", "Other details"),
    ]:
        value = image.get(key, "")
        if value and value != "Unknown":
            info_parts.append(f"{label}: {value}")

    book_info = "\n".join(info_parts)
    ocr_text = (image["raw_ocr_text"] or "")[:1500]
    photo_type = image["source_folder"]
    photo_desc = {
        "root": "title page or inner page",
        "front": "front cover",
        "side": "spine",
    }.get(photo_type, "book photo")

    try:
        from mistralai.client import Mistral

        client = Mistral(api_key=os.getenv("MISTRAL_API_KEY"))
        response = client.chat.complete(
            model="mistral-small-latest",
            messages=[
                {
                    "role": "user",
                    "content": f"""You are writing a brief, appealing description for an antiquarian/second-hand book listing.

This photo shows the **{photo_desc}** of a book. Based on the OCR text and extracted metadata below, write:
1. A one-line description of what this photo shows (e.g. \"Title page of a 1902 English edition published in Buenos Aires\")
2. A 2-3 sentence selling description highlighting what makes this book interesting or collectible (age, rarity, publisher, condition, historical significance, etc.)

Keep it professional but warm - like a knowledgeable bookseller. Write in English.

EXTRACTED METADATA:
{book_info}

RAW OCR TEXT FROM PHOTO:
{ocr_text}""",
                }
            ],
            max_tokens=400,
        )
        description = extract_message_text(response.choices[0].message.content)
        if not description:
            raise RuntimeError("Mistral returned an empty description")
    except Exception as exc:  # noqa: BLE001
        parts = []
        if image["title"] and image["title"] != "Unknown":
            parts.append(f'"{image["title"]}"')
        if image["author"] and image["author"] != "Unknown":
            parts.append(f"by {image['author']}")
        if image["publisher"]:
            parts.append(f"published by {image['publisher']}")
        if image["year"]:
            parts.append(f"({image['year']})")
        description = (
            f"Photo of {photo_desc}. " + " ".join(parts)
            if parts
            else f"Book photo ({photo_desc})"
        )
        description += f"\n\n[AI generation failed: {str(exc)[:100]}]"

    if DB_MODE == "rest":
        rest_update_row(req.image_id, {"description": description})
    else:
        assert db_conn is not None
        db_conn.execute("UPDATE book_images SET description = ? WHERE id = ?", (description, req.image_id))
        db_conn.commit()
        db_conn.close()
    return {"description": description}


class BatchGenerateRequest(BaseModel):
    start_id: int = 1
    count: int = 10


@app.post("/api/generate-descriptions-batch")
def generate_descriptions_batch(req: BatchGenerateRequest):
    if DB_MODE == "rest":
        rows = rest_fetch_rows("id,description")
        rows = [row for row in rows if not clean_text(row.get("description")) and row.get("id", 0) >= req.start_id]
        rows = rows[: req.count]
    else:
        conn = get_db()
        rows = conn.execute(
            "SELECT id FROM book_images WHERE (description IS NULL OR description = '') AND id >= ? ORDER BY id LIMIT ?",
            (req.start_id, req.count),
        ).fetchall()
        conn.close()

    results = []
    for row in rows:
        image_id = row["id"] if isinstance(row, dict) else row[0]
        try:
            generate_description(GenerateRequest(image_id=image_id))
            results.append({"id": image_id, "status": "ok"})
            time.sleep(1.5)
        except Exception as exc:  # noqa: BLE001
            results.append({"id": image_id, "status": f"error: {exc}"})

    if DB_MODE == "rest":
        remaining = sum(1 for row in rest_fetch_rows("description") if not clean_text(row.get("description")))
    else:
        conn = get_db()
        remaining = conn.execute(
            "SELECT COUNT(*) FROM book_images WHERE description IS NULL OR description = ''"
        ).fetchone()[0]
        conn.close()

    return {"generated": len(results), "results": results, "remaining": remaining}


# --- Chat endpoint -----------------------------------------------------------

class ChatRequest(BaseModel):
    question: str


@app.post("/api/chat")
def chat_about_books(req: ChatRequest):
    if DB_MODE == "rest":
        books = [
            row
            for row in rest_fetch_rows("id,title,author,publisher,year,language,series,source_folder")
            if clean_text(row.get("title")) and clean_text(row.get("title")).lower() != "unknown"
        ]
        books = sorted(books, key=lambda row: clean_text(row.get("title")).lower())
    else:
        conn = get_db()
        books = conn.execute(
            "SELECT id, title, author, publisher, year, language, series, source_folder FROM book_images WHERE title != 'Unknown' AND title != '' ORDER BY title"
        ).fetchall()
        conn.close()

    catalog_lines = []
    for book in books:
        if isinstance(book, dict):
            book_id = book.get("id")
            title = book.get("title")
            author = book.get("author")
            publisher = book.get("publisher")
            year = book.get("year")
            language = book.get("language")
            series = book.get("series")
        else:
            book_id, title, author, publisher, year, language, series = book[0:7]

        parts = [f"#{book_id}"]
        if title:
            parts.append(f'"{title}"')
        if author and author != "Unknown":
            parts.append(f"by {author}")
        if publisher:
            parts.append(f"({publisher})")
        if year:
            parts.append(f"[{year}]")
        if language and language != "Unknown":
            parts.append(f"lang:{language}")
        if series:
            parts.append(f"series:{series}")
        catalog_lines.append(" ".join(parts))

    catalog_text = "\n".join(catalog_lines)

    try:
        from mistralai.client import Mistral

        client = Mistral(api_key=os.getenv("MISTRAL_API_KEY"))
        response = client.chat.complete(
            model="mistral-small-latest",
            messages=[
                {
                    "role": "system",
                    "content": f"""You are a helpful librarian/bookseller assistant. Answer questions about this book photo collection.
Be specific - cite book titles, authors, and image IDs (#N) when relevant.

CATALOG ({len(books)} identified images):
{catalog_text}""",
                },
                {"role": "user", "content": req.question},
            ],
            max_tokens=2000,
        )
        answer = extract_message_text(response.choices[0].message.content)
        return {"answer": answer or "AI chat returned an empty response."}
    except Exception as exc:  # noqa: BLE001
        return {"answer": f"AI chat unavailable: {exc}. Try searching in the gallery instead."}


# --- Frontend ----------------------------------------------------------------

@app.get("/", response_class=HTMLResponse)
def serve_frontend():
    config_json = json.dumps(app_config()).replace("</", "<\\/")
    html = FRONTEND_HTML.replace("__APP_CONFIG_JSON__", config_json)
    return HTMLResponse(html)


LOGIN_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Bibliothek - Family Access</title>
<style>
  :root {
    --bg: #09110f;
    --panel: rgba(19, 34, 29, 0.96);
    --line: rgba(213, 178, 107, 0.2);
    --line-strong: rgba(213, 178, 107, 0.45);
    --text: #ecede5;
    --muted: #b9bfaf;
    --accent: #d5b26b;
    --accent-2: #89c0a7;
    --danger: #ef7d72;
  }
  * { box-sizing: border-box; }
  body {
    margin: 0;
    min-height: 100vh;
    display: grid;
    place-items: center;
    padding: 18px;
    color: var(--text);
    font-family: Aptos, "Segoe UI", sans-serif;
    background:
      radial-gradient(circle at top left, rgba(213,178,107,0.12), transparent 28%),
      radial-gradient(circle at top right, rgba(137,192,167,0.12), transparent 24%),
      linear-gradient(180deg, #10211c 0%, #09110f 66%, #060b09 100%);
  }
  .card {
    width: min(440px, 100%);
    border-radius: 28px;
    padding: 28px;
    border: 1px solid var(--line);
    background: var(--panel);
    box-shadow: 0 24px 70px rgba(0,0,0,0.32);
  }
  .eyebrow {
    color: var(--accent-2);
    font-size: 11px;
    letter-spacing: 0.12em;
    text-transform: uppercase;
  }
  h1 {
    margin: 10px 0 8px;
    font-family: "Baskerville Old Face", Baskerville, "Palatino Linotype", Georgia, serif;
    font-size: 38px;
    line-height: 1.05;
  }
  p {
    margin: 0 0 20px;
    color: var(--muted);
    line-height: 1.65;
  }
  input {
    width: 100%;
    padding: 14px 16px;
    border-radius: 16px;
    border: 1px solid var(--line);
    background: rgba(255,255,255,0.04);
    color: var(--text);
    outline: none;
    font: inherit;
  }
  input:focus { border-color: var(--line-strong); }
  button {
    width: 100%;
    margin-top: 12px;
    padding: 14px 16px;
    border: none;
    border-radius: 16px;
    background: linear-gradient(135deg, rgba(213,178,107,0.92), rgba(137,192,167,0.82));
    color: #10211c;
    font: inherit;
    font-weight: 700;
    cursor: pointer;
  }
  button:disabled { opacity: 0.6; cursor: wait; }
  .status {
    margin-top: 12px;
    min-height: 20px;
    color: var(--muted);
    font-size: 13px;
  }
  .status.error { color: var(--danger); }
</style>
</head>
<body>
  <form class="card" onsubmit="login(event)">
    <div class="eyebrow">Private family link</div>
    <h1>Bibliothek</h1>
    <p>Enter the shared family password to browse the catalog, review OCR, and help clean up the collection.</p>
    <input id="passwordInput" type="password" placeholder="Shared password" autocomplete="current-password" autofocus>
    <button id="loginBtn" type="submit">Enter catalog</button>
    <div class="status" id="status"></div>
  </form>

<script>
async function login(event) {
  event.preventDefault();
  const input = document.getElementById('passwordInput');
  const button = document.getElementById('loginBtn');
  const status = document.getElementById('status');
  button.disabled = true;
  button.textContent = 'Checking...';
  status.textContent = '';
  status.className = 'status';
  try {
    const response = await fetch('/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ password: input.value }),
    });
    const data = await response.json().catch(() => ({}));
    if (!response.ok) throw new Error(data.detail || 'Wrong password');
    window.location.href = '/';
  } catch (error) {
    status.textContent = error.message;
    status.className = 'status error';
    button.disabled = false;
    button.textContent = 'Enter catalog';
    input.select();
  }
}
</script>
</body>
</html>
"""


FRONTEND_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#10211c">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Bibliothek">
<title>Bibliothek - Book Catalog</title>
<style>
  :root {
    --bg: #09110f;
    --bg-2: #10211c;
    --panel: rgba(17, 30, 26, 0.9);
    --panel-2: rgba(23, 40, 35, 0.92);
    --panel-3: rgba(32, 54, 47, 0.96);
    --line: rgba(184, 157, 103, 0.18);
    --line-strong: rgba(214, 180, 117, 0.34);
    --text: #ecede5;
    --muted: #b9bfaf;
    --soft: #879486;
    --accent: #d5b26b;
    --accent-2: #89c0a7;
    --danger: #ef7d72;
    --shadow: 0 22px 70px rgba(0, 0, 0, 0.28);
    --radius: 18px;
  }

  * { box-sizing: border-box; }
  html { background: var(--bg); }
  body {
    margin: 0;
    min-height: 100vh;
    color: var(--text);
    font-family: Aptos, "Segoe UI", "Trebuchet MS", sans-serif;
    background:
      radial-gradient(circle at top left, rgba(213, 178, 107, 0.12), transparent 32%),
      radial-gradient(circle at top right, rgba(137, 192, 167, 0.1), transparent 28%),
      linear-gradient(180deg, #10211c 0%, #09110f 62%, #060b09 100%);
  }

  body::before {
    content: "";
    position: fixed;
    inset: 0;
    pointer-events: none;
    background:
      linear-gradient(135deg, rgba(255,255,255,0.02), transparent 35%),
      repeating-linear-gradient(90deg, rgba(255,255,255,0.015), rgba(255,255,255,0.015) 1px, transparent 1px, transparent 110px);
    opacity: 0.55;
  }

  button, input, select { font: inherit; }

  .shell {
    position: relative;
    z-index: 1;
    min-height: 100vh;
    padding-bottom: calc(28px + env(safe-area-inset-bottom));
  }

  .header {
    position: sticky;
    top: 0;
    z-index: 40;
    display: flex;
    align-items: center;
    gap: 18px;
    padding: 18px 24px;
    background: rgba(8, 16, 14, 0.84);
    border-bottom: 1px solid var(--line);
    backdrop-filter: blur(18px);
  }

  .brand {
    display: flex;
    align-items: center;
    gap: 12px;
    min-width: 0;
  }

  .brand-mark {
    width: 42px;
    height: 42px;
    border-radius: 12px;
    display: grid;
    place-items: center;
    background: linear-gradient(135deg, rgba(213, 178, 107, 0.28), rgba(137, 192, 167, 0.18));
    border: 1px solid var(--line-strong);
    box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
  }

  .brand-text { min-width: 0; }
  .brand-title {
    margin: 0;
    font-family: "Baskerville Old Face", Baskerville, "Palatino Linotype", Georgia, serif;
    font-size: 24px;
    letter-spacing: 0.02em;
  }
  .brand-note {
    margin-top: 2px;
    color: var(--soft);
    font-size: 12px;
    letter-spacing: 0.04em;
    text-transform: uppercase;
  }

  .nav-tabs {
    display: inline-flex;
    gap: 6px;
    padding: 5px;
    border-radius: 999px;
    background: rgba(255,255,255,0.03);
    border: 1px solid var(--line);
  }

  .nav-tab {
    border: none;
    background: transparent;
    color: var(--muted);
    padding: 10px 16px;
    border-radius: 999px;
    cursor: pointer;
    transition: background 0.16s ease, color 0.16s ease, transform 0.16s ease;
  }

  .nav-tab:hover { color: var(--text); }
  .nav-tab.active {
    background: linear-gradient(135deg, rgba(213, 178, 107, 0.18), rgba(137, 192, 167, 0.2));
    color: var(--text);
    transform: translateY(-1px);
  }

  .stats-bar {
    margin-left: auto;
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: 10px;
  }

  .header-tools {
    margin-left: auto;
    display: flex;
    align-items: center;
    gap: 12px;
    flex-wrap: wrap;
    justify-content: flex-end;
  }

  .stat-pill {
    padding: 8px 12px;
    border-radius: 999px;
    background: rgba(255,255,255,0.03);
    border: 1px solid var(--line);
    font-size: 12px;
    color: var(--muted);
    white-space: nowrap;
  }

  .stat-pill strong { color: var(--accent); }

  .panel { display: none; }
  .panel.active { display: block; }

  .toolbar,
  .batch-bar,
  .chat-wrap {
    width: min(1400px, calc(100% - 32px));
    margin: 0 auto;
  }

  .toolbar {
    margin-top: 22px;
    display: grid;
    grid-template-columns: minmax(220px, 1.8fr) repeat(4, minmax(130px, 0.7fr));
    gap: 12px;
  }

  .search-box,
  .filter-select,
  .chat-input {
    width: 100%;
    padding: 14px 16px;
    border-radius: 14px;
    border: 1px solid var(--line);
    background: var(--panel);
    color: var(--text);
    outline: none;
    box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
  }

  .search-box::placeholder,
  .chat-input::placeholder { color: var(--soft); }
  .search-box:focus,
  .filter-select:focus,
  .chat-input:focus { border-color: var(--line-strong); }

  .batch-bar {
    margin-top: 14px;
    padding: 16px 18px;
    border-radius: var(--radius);
    background: linear-gradient(135deg, rgba(23, 40, 35, 0.95), rgba(15, 27, 24, 0.94));
    border: 1px solid var(--line);
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 12px;
    box-shadow: var(--shadow);
  }

  .bar-copy {
    flex: 1 1 260px;
    color: var(--muted);
    font-size: 13px;
    line-height: 1.5;
  }

  .bar-copy strong { color: var(--accent); }

  .gen-btn,
  .ghost-btn,
  .chat-send,
  .page-btn,
  .photo-nav,
  .detail-close,
  .stage-nav {
    border: none;
    cursor: pointer;
    transition: transform 0.15s ease, opacity 0.15s ease, background 0.15s ease;
  }

  .gen-btn,
  .chat-send {
    padding: 12px 18px;
    border-radius: 12px;
    background: linear-gradient(135deg, rgba(213, 178, 107, 0.86), rgba(153, 207, 175, 0.78));
    color: #10211c;
    font-weight: 700;
    box-shadow: 0 12px 28px rgba(0,0,0,0.2);
  }

  .gen-btn:hover,
  .chat-send:hover,
  .page-btn:hover,
  .ghost-btn:hover,
  .photo-nav:hover,
  .detail-close:hover,
  .stage-nav:hover {
    transform: translateY(-1px);
  }

  .gen-btn:disabled { opacity: 0.5; cursor: wait; transform: none; }

  .batch-status {
    font-size: 12px;
    color: var(--soft);
  }

  .library-grid-wrap,
  .pagination {
    width: min(1400px, calc(100% - 32px));
    margin: 0 auto;
  }

  .library-grid {
    margin-top: 20px;
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
    gap: 18px;
  }

  .book-card {
    position: relative;
    overflow: hidden;
    border-radius: 24px;
    background: linear-gradient(180deg, rgba(28, 45, 39, 0.95), rgba(14, 23, 20, 0.98));
    border: 1px solid var(--line);
    cursor: pointer;
    box-shadow: var(--shadow);
  }

  .book-card::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.18));
    pointer-events: none;
  }

  .book-card:hover { border-color: var(--line-strong); }

  .card-media {
    position: relative;
    aspect-ratio: 3 / 4;
    overflow: hidden;
    background: rgba(0,0,0,0.22);
  }

  .card-image {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
  }

  .card-count {
    position: absolute;
    top: 14px;
    right: 14px;
    padding: 8px 10px;
    border-radius: 999px;
    background: rgba(6, 11, 9, 0.72);
    border: 1px solid rgba(255,255,255,0.1);
    color: var(--text);
    font-size: 11px;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    z-index: 1;
  }

  .card-body {
    position: relative;
    z-index: 1;
    padding: 18px;
  }

  .card-overline {
    color: var(--accent-2);
    font-size: 11px;
    letter-spacing: 0.08em;
    text-transform: uppercase;
  }

  .card-title {
    margin-top: 8px;
    font-family: "Baskerville Old Face", Baskerville, "Palatino Linotype", Georgia, serif;
    font-size: 25px;
    line-height: 1.12;
  }

  .card-meta {
    margin-top: 10px;
    color: var(--muted);
    font-size: 13px;
    line-height: 1.5;
    min-height: 40px;
  }

  .card-strip {
    margin-top: 14px;
    display: flex;
    gap: 8px;
  }

  .mini-thumb {
    width: 54px;
    height: 68px;
    border-radius: 10px;
    object-fit: cover;
    border: 1px solid rgba(255,255,255,0.08);
    background: rgba(0,0,0,0.25);
  }

  .tag-row {
    margin-top: 16px;
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
  }

  .badge {
    padding: 6px 10px;
    border-radius: 999px;
    border: 1px solid rgba(255,255,255,0.08);
    background: rgba(255,255,255,0.035);
    color: var(--muted);
    font-size: 11px;
  }

  .badge.has-desc {
    color: #10211c;
    background: rgba(137, 192, 167, 0.92);
    border-color: transparent;
  }

  .empty-state {
    margin-top: 24px;
    padding: 56px 24px;
    border-radius: 22px;
    text-align: center;
    color: var(--soft);
    border: 1px dashed var(--line-strong);
    background: rgba(15, 27, 24, 0.74);
  }

  .pagination {
    margin-top: 20px;
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 10px;
  }

  .page-btn,
  .ghost-btn {
    padding: 11px 16px;
    border-radius: 12px;
    background: rgba(255,255,255,0.04);
    border: 1px solid var(--line);
    color: var(--text);
  }

  .page-btn:disabled,
  .ghost-btn:disabled {
    opacity: 0.38;
    cursor: default;
    transform: none;
  }

  .logout-btn {
    white-space: nowrap;
  }

  .page-info {
    color: var(--muted);
    font-size: 13px;
  }

  .detail-overlay {
    position: fixed;
    inset: 0;
    z-index: 80;
    display: none;
    background: rgba(5, 9, 8, 0.9);
    backdrop-filter: blur(18px);
    padding: 18px;
  }

  .detail-overlay.open { display: block; }

  .detail-shell {
    position: relative;
    width: min(1500px, 100%);
    height: 100%;
    margin: 0 auto;
    display: grid;
    grid-template-columns: minmax(0, 1.35fr) 430px;
    gap: 16px;
  }

  .detail-close {
    position: absolute;
    top: 18px;
    right: 18px;
    width: 44px;
    height: 44px;
    border-radius: 999px;
    background: rgba(0,0,0,0.55);
    color: var(--text);
    z-index: 2;
  }

  .detail-stage,
  .detail-panel {
    min-height: 0;
    border-radius: 28px;
    border: 1px solid var(--line);
    box-shadow: var(--shadow);
  }

  .detail-stage {
    display: flex;
    flex-direction: column;
    background: linear-gradient(180deg, rgba(12, 21, 18, 0.96), rgba(7, 12, 10, 0.98));
    overflow: hidden;
  }

  .stage-topbar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 12px;
    padding: 16px 18px;
    border-bottom: 1px solid var(--line);
    color: var(--muted);
    font-size: 13px;
  }

  .stage-nav-row {
    display: flex;
    gap: 8px;
  }

  .stage-nav,
  .photo-nav {
    width: 44px;
    height: 44px;
    border-radius: 999px;
    background: rgba(255,255,255,0.05);
    border: 1px solid rgba(255,255,255,0.08);
    color: var(--text);
  }

  .stage-frame {
    position: relative;
    flex: 1;
    min-height: 360px;
    display: grid;
    place-items: center;
    padding: 24px 68px;
    background:
      radial-gradient(circle at center, rgba(213,178,107,0.08), transparent 44%),
      linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.08));
  }

  .stage-image {
    max-width: 100%;
    max-height: 100%;
    object-fit: contain;
    border-radius: 20px;
    box-shadow: 0 24px 65px rgba(0, 0, 0, 0.36);
    background: rgba(0,0,0,0.3);
  }

  .photo-nav {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
  }

  .photo-nav.prev { left: 16px; }
  .photo-nav.next { right: 16px; }

  .stage-caption {
    padding: 0 18px 16px;
    color: var(--muted);
    font-size: 13px;
  }

  .thumb-row {
    padding: 0 18px 18px;
    display: flex;
    gap: 10px;
    overflow-x: auto;
  }

  .thumb-btn {
    flex: 0 0 auto;
    width: 76px;
    border: 1px solid rgba(255,255,255,0.08);
    border-radius: 14px;
    overflow: hidden;
    background: rgba(255,255,255,0.03);
    padding: 0;
    cursor: pointer;
  }

  .thumb-btn.active { border-color: var(--line-strong); }
  .thumb-btn img {
    width: 100%;
    aspect-ratio: 3 / 4;
    display: block;
    object-fit: cover;
  }

  .detail-panel {
    overflow-y: auto;
    background: linear-gradient(180deg, rgba(21, 36, 31, 0.98), rgba(11, 18, 15, 0.98));
    padding: 22px;
  }

  .eyebrow {
    color: var(--accent-2);
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.1em;
  }

  .title-input {
    width: 100%;
    margin-top: 10px;
    padding: 0;
    border: none;
    background: transparent;
    color: var(--text);
    outline: none;
    font-family: "Baskerville Old Face", Baskerville, "Palatino Linotype", Georgia, serif;
    font-size: 36px;
    line-height: 1.05;
  }

  .title-input::placeholder { color: rgba(236, 237, 229, 0.38); }

  .title-hint {
    margin-top: 8px;
    color: var(--soft);
    font-size: 12px;
    line-height: 1.5;
  }

  .title-hint.error { color: var(--danger); }
  .title-hint.ok { color: var(--accent-2); }

  .detail-actions {
    margin-top: 18px;
    display: flex;
    gap: 10px;
    flex-wrap: wrap;
  }

  .byline {
    margin-top: 14px;
    color: var(--muted);
    font-size: 14px;
    line-height: 1.6;
  }

  .meta-grid {
    margin-top: 20px;
    display: grid;
    grid-template-columns: 110px minmax(0, 1fr);
    gap: 8px 14px;
    font-size: 13px;
  }

  .meta-label {
    color: var(--soft);
    text-transform: uppercase;
    letter-spacing: 0.05em;
    font-size: 11px;
  }

  .meta-value {
    min-width: 0;
    color: var(--text);
    word-break: break-word;
  }

  .section-title {
    margin-top: 24px;
    margin-bottom: 10px;
    color: var(--soft);
    text-transform: uppercase;
    letter-spacing: 0.08em;
    font-size: 11px;
  }

  .desc-box,
  .ocr-box {
    border-radius: 16px;
    border: 1px solid rgba(255,255,255,0.05);
    background: rgba(255,255,255,0.04);
    padding: 14px 15px;
    line-height: 1.65;
    white-space: pre-wrap;
  }

  .desc-box { font-size: 13px; color: var(--text); }
  .desc-empty { color: var(--soft); font-style: italic; }

  .ocr-box {
    max-height: 240px;
    overflow-y: auto;
    font-size: 12px;
    color: var(--muted);
    font-family: Consolas, "Cascadia Code", monospace;
  }

  .photo-chip-row {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    margin-top: 14px;
  }

  .photo-chip {
    padding: 6px 10px;
    border-radius: 999px;
    background: rgba(255,255,255,0.04);
    border: 1px solid rgba(255,255,255,0.07);
    color: var(--muted);
    font-size: 11px;
  }

  .badge.review-done {
    color: #10211c;
    background: rgba(137, 192, 167, 0.9);
    border-color: transparent;
  }

  .badge.review-flagged {
    background: rgba(239, 125, 114, 0.14);
    border-color: rgba(239, 125, 114, 0.25);
    color: #f2a49a;
  }

  .game-shell {
    width: min(1400px, calc(100% - 32px));
    margin: 22px auto 0;
    display: grid;
    grid-template-columns: minmax(0, 1.08fr) 360px;
    gap: 18px;
  }

  .game-board,
  .queue-panel,
  .modal-shell {
    border-radius: 26px;
    border: 1px solid var(--line);
    background: linear-gradient(180deg, rgba(20, 35, 30, 0.98), rgba(10, 18, 15, 0.98));
    box-shadow: var(--shadow);
  }

  .game-board {
    padding: 22px;
  }

  .game-title {
    margin: 10px 0 8px;
    font-family: "Baskerville Old Face", Baskerville, "Palatino Linotype", Georgia, serif;
    font-size: 34px;
    line-height: 1.08;
  }

  .game-copy,
  .modal-note {
    color: var(--muted);
    font-size: 14px;
    line-height: 1.6;
  }

  .game-stage {
    margin-top: 18px;
    min-height: 560px;
    display: grid;
  }

  .review-card,
  .review-empty {
    border-radius: 24px;
    border: 1px solid rgba(255,255,255,0.06);
    background: linear-gradient(180deg, rgba(28, 46, 40, 0.98), rgba(14, 22, 19, 0.98));
    overflow: hidden;
  }

  .review-card {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 350px;
    transition: transform 0.28s ease, opacity 0.28s ease, filter 0.28s ease;
  }

  .review-card.swipe-right { animation: swipeRight 0.28s ease forwards; }
  .review-card.swipe-left { animation: swipeLeft 0.28s ease forwards; }

  @keyframes swipeRight {
    from { opacity: 1; transform: translateX(0) rotate(0deg); filter: saturate(1); }
    to { opacity: 0; transform: translateX(80px) rotate(3deg); filter: saturate(1.3); }
  }

  @keyframes swipeLeft {
    from { opacity: 1; transform: translateX(0) rotate(0deg); filter: saturate(1); }
    to { opacity: 0; transform: translateX(-80px) rotate(-3deg); filter: saturate(0.7); }
  }

  .review-media {
    position: relative;
    min-height: 100%;
    background: radial-gradient(circle at center, rgba(213, 178, 107, 0.1), transparent 48%), rgba(0,0,0,0.2);
    display: grid;
    place-items: center;
    padding: 24px;
  }

  .review-media img {
    width: 100%;
    max-height: 100%;
    object-fit: contain;
    border-radius: 20px;
    box-shadow: 0 24px 65px rgba(0, 0, 0, 0.38);
    background: rgba(0,0,0,0.18);
  }

  .review-badge {
    position: absolute;
    top: 18px;
    left: 18px;
    padding: 8px 11px;
    border-radius: 999px;
    background: rgba(6, 11, 9, 0.78);
    border: 1px solid rgba(255,255,255,0.09);
    color: var(--text);
    font-size: 11px;
    letter-spacing: 0.08em;
    text-transform: uppercase;
  }

  .review-side {
    padding: 22px;
    border-left: 1px solid rgba(255,255,255,0.05);
    display: flex;
    flex-direction: column;
  }

  .review-title {
    margin: 8px 0 10px;
    font-family: "Baskerville Old Face", Baskerville, "Palatino Linotype", Georgia, serif;
    font-size: 30px;
    line-height: 1.08;
  }

  .review-subcopy {
    color: var(--muted);
    font-size: 13px;
    line-height: 1.6;
  }

  .review-grid {
    margin-top: 18px;
    display: grid;
    grid-template-columns: 88px minmax(0, 1fr);
    gap: 8px 12px;
    font-size: 13px;
  }

  .review-label {
    color: var(--soft);
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.06em;
  }

  .review-value {
    color: var(--text);
    min-width: 0;
    word-break: break-word;
  }

  .review-actions {
    margin-top: auto;
    padding-top: 20px;
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 12px;
  }

  .review-btn {
    min-height: 70px;
    border-radius: 18px;
    font-size: 17px;
    font-weight: 700;
    letter-spacing: 0.03em;
    color: #10211c;
  }

  .review-btn.reject {
    background: linear-gradient(135deg, rgba(239,125,114,0.98), rgba(233,157,108,0.92));
  }

  .review-btn.approve {
    background: linear-gradient(135deg, rgba(112, 213, 154, 0.96), rgba(213, 178, 107, 0.86));
  }

  .review-btn small {
    display: block;
    margin-top: 4px;
    font-size: 12px;
    font-weight: 600;
    opacity: 0.72;
  }

  .review-empty {
    padding: 42px 28px;
    display: grid;
    place-items: center;
    text-align: center;
    color: var(--muted);
  }

  .queue-panel {
    padding: 18px;
    display: flex;
    flex-direction: column;
    min-height: 560px;
  }

  .queue-head {
    display: flex;
    justify-content: space-between;
    gap: 12px;
    align-items: baseline;
    margin-bottom: 14px;
  }

  .queue-title {
    font-family: "Baskerville Old Face", Baskerville, "Palatino Linotype", Georgia, serif;
    font-size: 24px;
  }

  .queue-count {
    color: var(--soft);
    font-size: 12px;
  }

  .queue-list {
    display: flex;
    flex-direction: column;
    gap: 10px;
    overflow-y: auto;
  }

  .queue-item {
    display: grid;
    grid-template-columns: 68px minmax(0, 1fr);
    gap: 12px;
    padding: 10px;
    border-radius: 18px;
    background: rgba(255,255,255,0.035);
    border: 1px solid rgba(255,255,255,0.06);
  }

  .queue-item img {
    width: 68px;
    height: 90px;
    border-radius: 12px;
    object-fit: cover;
    background: rgba(0,0,0,0.2);
  }

  .queue-item-title {
    font-size: 14px;
    font-weight: 700;
    line-height: 1.3;
  }

  .queue-item-meta {
    margin-top: 5px;
    color: var(--muted);
    font-size: 12px;
    line-height: 1.5;
  }

  .queue-item-actions {
    margin-top: 8px;
    display: flex;
    gap: 8px;
    flex-wrap: wrap;
  }

  .modal-overlay {
    position: fixed;
    inset: 0;
    z-index: 130;
    display: none;
    align-items: center;
    justify-content: center;
    padding: 18px;
    background: rgba(5, 8, 7, 0.88);
    backdrop-filter: blur(14px);
  }

  .modal-overlay.open { display: flex; }

  .modal-shell {
    position: relative;
    width: min(1180px, 100%);
    max-height: calc(100vh - 36px);
    overflow: hidden;
  }

  .modal-close {
    position: absolute;
    top: 16px;
    right: 16px;
    width: 42px;
    height: 42px;
    border-radius: 999px;
    background: rgba(255,255,255,0.06);
    border: 1px solid rgba(255,255,255,0.08);
    color: var(--text);
  }

  .modal-header {
    padding: 22px 22px 12px;
    border-bottom: 1px solid rgba(255,255,255,0.05);
  }

  .modal-heading {
    margin: 8px 0 8px;
    font-family: "Baskerville Old Face", Baskerville, "Palatino Linotype", Georgia, serif;
    font-size: 32px;
    line-height: 1.08;
  }

  .modal-body {
    display: grid;
    grid-template-columns: 280px minmax(0, 1fr);
    gap: 0;
    max-height: calc(100vh - 170px);
  }

  .modal-preview {
    padding: 20px;
    border-right: 1px solid rgba(255,255,255,0.05);
    overflow-y: auto;
  }

  .modal-preview img {
    width: 100%;
    border-radius: 18px;
    object-fit: cover;
    background: rgba(0,0,0,0.2);
    margin-bottom: 12px;
  }

  .modal-form-wrap {
    padding: 20px;
    overflow-y: auto;
  }

  .field-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 12px;
  }

  .form-field {
    display: flex;
    flex-direction: column;
    gap: 6px;
  }

  .form-field.wide { grid-column: 1 / -1; }

  .form-field span {
    color: var(--soft);
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.06em;
  }

  .form-field input,
  .form-field textarea {
    width: 100%;
    border-radius: 14px;
    border: 1px solid rgba(255,255,255,0.08);
    background: rgba(255,255,255,0.04);
    color: var(--text);
    padding: 12px 14px;
    outline: none;
    resize: vertical;
  }

  .form-field textarea { min-height: 110px; }

  .form-field input:focus,
  .form-field textarea:focus { border-color: var(--line-strong); }

  .chat-wrap {
    margin-top: 22px;
    max-width: 980px;
  }

  .suggestions {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    margin-bottom: 14px;
  }

  .sug-chip {
    padding: 8px 12px;
    border-radius: 999px;
    background: rgba(255,255,255,0.035);
    border: 1px solid var(--line);
    color: var(--muted);
    cursor: pointer;
  }

  .sug-chip:hover { color: var(--text); border-color: var(--line-strong); }

  .chat-msgs {
    min-height: 320px;
    max-height: 58vh;
    overflow-y: auto;
    border-radius: 24px;
    background: linear-gradient(180deg, rgba(18, 31, 27, 0.96), rgba(10, 17, 15, 0.98));
    border: 1px solid var(--line);
    padding: 20px;
    box-shadow: var(--shadow);
  }

  .chat-msg { margin-bottom: 14px; }
  .chat-msg.user { text-align: right; }

  .chat-bubble {
    display: inline-block;
    max-width: min(760px, 85%);
    padding: 13px 16px;
    border-radius: 18px;
    line-height: 1.7;
    font-size: 14px;
    text-align: left;
  }

  .chat-msg.user .chat-bubble {
    background: linear-gradient(135deg, rgba(213,178,107,0.92), rgba(153,207,175,0.82));
    color: #10211c;
  }

  .chat-msg.ai .chat-bubble {
    background: rgba(255,255,255,0.05);
    color: var(--text);
    border: 1px solid rgba(255,255,255,0.06);
  }

  .chat-row {
    display: flex;
    gap: 10px;
    margin-top: 12px;
  }

  .notice {
    position: fixed;
    left: 50%;
    bottom: 18px;
    transform: translate(-50%, 20px);
    opacity: 0;
    pointer-events: none;
    padding: 12px 16px;
    border-radius: 14px;
    background: rgba(6, 11, 9, 0.9);
    border: 1px solid var(--line-strong);
    color: var(--text);
    z-index: 120;
    transition: opacity 0.18s ease, transform 0.18s ease;
    box-shadow: var(--shadow);
  }

  .notice.show {
    opacity: 1;
    transform: translate(-50%, 0);
  }

  .notice.error { border-color: rgba(239, 125, 114, 0.5); }

  .auth-screen {
    position: fixed;
    inset: 0;
    z-index: 160;
    display: none;
    align-items: center;
    justify-content: center;
    padding: 18px;
    background: rgba(6, 10, 9, 0.88);
    backdrop-filter: blur(16px);
  }

  .auth-screen.show { display: flex; }

  .auth-card {
    width: min(460px, 100%);
    border-radius: 28px;
    padding: 28px;
    border: 1px solid var(--line);
    background: linear-gradient(180deg, rgba(21, 36, 31, 0.98), rgba(11, 18, 15, 0.98));
    box-shadow: var(--shadow);
  }

  .auth-title {
    margin: 10px 0 8px;
    font-family: "Baskerville Old Face", Baskerville, "Palatino Linotype", Georgia, serif;
    font-size: 38px;
    line-height: 1.06;
  }

  .auth-copy,
  .invite-help {
    color: var(--muted);
    line-height: 1.65;
  }

  .auth-status,
  .invite-status {
    min-height: 22px;
    margin-top: 12px;
    color: var(--soft);
    font-size: 13px;
  }

  .auth-status.error,
  .invite-status.error { color: var(--danger); }
  .auth-status.ok,
  .invite-status.ok { color: var(--accent-2); }

  .user-pill {
    padding: 8px 12px;
    border-radius: 999px;
    background: rgba(255,255,255,0.04);
    border: 1px solid var(--line);
    color: var(--muted);
    font-size: 12px;
    white-space: nowrap;
  }

  .invite-form {
    display: flex;
    flex-direction: column;
    gap: 12px;
  }

  code {
    padding: 2px 5px;
    border-radius: 6px;
    background: rgba(255,255,255,0.06);
    font-family: Consolas, "Cascadia Code", monospace;
  }

  @media (max-width: 1120px) {
    .header { flex-wrap: wrap; }
    .stats-bar {
      width: 100%;
      margin-left: 0;
      justify-content: flex-start;
    }
    .toolbar {
      grid-template-columns: minmax(220px, 1fr) repeat(2, minmax(140px, 1fr));
    }
    .detail-shell { grid-template-columns: minmax(0, 1fr) 380px; }
    .game-shell { grid-template-columns: 1fr; }
    .queue-panel { min-height: 0; }
  }

  @media (max-width: 900px) {
    .toolbar {
      grid-template-columns: 1fr 1fr;
    }
    .detail-overlay { padding: 10px; }
    .detail-shell {
      grid-template-columns: 1fr;
      height: auto;
      max-height: calc(100vh - 20px);
      overflow-y: auto;
    }
    .detail-panel {
      max-height: none;
      overflow: visible;
    }
    .detail-close {
      top: 14px;
      right: 14px;
    }
    .review-card,
    .modal-body {
      grid-template-columns: 1fr;
    }
    .review-side,
    .modal-preview {
      border-left: none;
      border-right: none;
      border-top: 1px solid rgba(255,255,255,0.05);
    }
  }

  @media (max-width: 720px) {
    .header,
    .toolbar,
    .batch-bar,
    .library-grid-wrap,
    .pagination,
    .chat-wrap {
      width: min(100%, calc(100% - 20px));
    }
    .header { padding: 16px 14px; }
    .toolbar { grid-template-columns: 1fr; }
    .library-grid { grid-template-columns: 1fr; }
    .card-title { font-size: 22px; }
    .title-input { font-size: 30px; }
    .meta-grid { grid-template-columns: 1fr; gap: 4px; }
    .chat-row { flex-direction: column; }
    .stage-frame { min-height: 300px; padding: 18px 54px; }
    .photo-nav.prev { left: 10px; }
    .photo-nav.next { right: 10px; }
    .game-shell,
    .modal-shell {
      width: min(100%, calc(100% - 20px));
    }
    .game-title,
    .modal-heading { font-size: 28px; }
    .review-actions,
    .field-grid { grid-template-columns: 1fr; }
  }
</style>
</head>
<body>
<div class="auth-screen" id="authScreen">
  <div class="auth-card">
    <div class="eyebrow">Private family library</div>
    <div class="auth-title">Bibliothek</div>
    <div class="auth-copy">Sign in with your invited email and I will send you a private magic link. Only approved family members can access the catalog.</div>
    <input type="email" class="search-box" id="authEmailInput" placeholder="your@email.com" autocomplete="email">
    <button class="gen-btn" id="authBtn" onclick="sendMagicLink()">Email me a magic link</button>
    <div class="auth-status" id="authStatus"></div>
  </div>
</div>

<div class="shell">
  <div class="header">
    <div class="brand">
      <div class="brand-mark">&#128218;</div>
      <div class="brand-text">
        <h1 class="brand-title">Bibliothek</h1>
        <div class="brand-note">Book-centered catalog for desktop and phone</div>
      </div>
    </div>
    <div class="nav-tabs">
      <button class="nav-tab active" onclick="showPanel('library', this)">Books</button>
      <button class="nav-tab" onclick="showPanel('game', this)">Game</button>
      <button class="nav-tab" onclick="showPanel('chat', this)">Ask AI</button>
    </div>
    <div class="header-tools">
      <div class="user-pill" id="userPill" hidden></div>
      <button class="ghost-btn" id="inviteBtn" onclick="openInviteModal()" hidden>Invite</button>
      <div class="stats-bar" id="statsBar"></div>
      <button class="ghost-btn logout-btn" onclick="logoutApp()">Sign out</button>
    </div>
  </div>

  <div id="libraryPanel" class="panel active">
    <div class="toolbar">
      <input type="text" class="search-box" id="searchInput" placeholder="Search title, author, publisher, OCR text..." oninput="debounceSearch()">
      <select class="filter-select" id="folderFilter" onchange="resetLoad()">
        <option value="">All photo folders</option>
        <option value="root">Root (title pages)</option>
        <option value="front">Front covers</option>
        <option value="side">Spines</option>
      </select>
      <select class="filter-select" id="langFilter" onchange="resetLoad()">
        <option value="">All languages</option>
      </select>
      <select class="filter-select" id="sortSelect" onchange="resetLoad()">
        <option value="title:asc">Title A-Z</option>
        <option value="author:asc">Author A-Z</option>
        <option value="year:asc">Year (oldest)</option>
        <option value="image_count:desc">Most photos</option>
        <option value="id:desc">Newest image ID</option>
      </select>
      <select class="filter-select" id="titleFilter" onchange="resetLoad()">
        <option value="">All titles</option>
        <option value="true">Identified only</option>
        <option value="false">Needs title</option>
      </select>
    </div>

    <div class="batch-bar">
      <div class="bar-copy">
        <strong id="descProgress">0/0 described</strong><br>
        Batch descriptions still run per photo, but the gallery now groups matching titles into books.
      </div>
      <button class="gen-btn" id="batchBtn" onclick="batchGenerate()">Generate 10 descriptions</button>
      <div class="batch-status" id="batchStatus"></div>
    </div>

    <div class="library-grid-wrap">
      <div class="library-grid" id="libraryGrid"></div>
    </div>

    <div class="pagination" id="pagination"></div>
  </div>

  <div id="gamePanel" class="panel">
    <div class="game-shell">
      <section class="game-board">
        <div class="eyebrow">Verification game</div>
        <h2 class="game-title">Swipe right if the OCR is right. Swipe left if it needs fixing.</h2>
        <div class="game-copy" id="gameSummary">Loading the review queue...</div>
        <div class="game-stage" id="gameStage"></div>
      </section>

      <aside class="queue-panel">
        <div class="queue-head">
          <div class="queue-title">Flagged for later</div>
          <div class="queue-count" id="flaggedCount">0 queued</div>
        </div>
        <div class="queue-list" id="flaggedQueue"></div>
      </aside>
    </div>
  </div>

  <div id="chatPanel" class="panel">
    <div class="chat-wrap">
      <div class="suggestions">
        <span class="sug-chip" onclick="askQ(this.innerText)">How many books are in Spanish?</span>
        <span class="sug-chip" onclick="askQ(this.innerText)">Which books are from before 1900?</span>
        <span class="sug-chip" onclick="askQ(this.innerText)">List books with multiple photos</span>
        <span class="sug-chip" onclick="askQ(this.innerText)">What authors appear most often?</span>
        <span class="sug-chip" onclick="askQ(this.innerText)">Which titles still need descriptions?</span>
      </div>
      <div class="chat-msgs" id="chatMsgs">
        <div class="chat-msg ai"><div class="chat-bubble">Ask me anything about the collection and I will answer from the catalog.</div></div>
      </div>
      <div class="chat-row">
        <input class="chat-input" id="chatInput" placeholder="Ask about your collection..." onkeydown="if(event.key==='Enter')sendChat()">
        <button class="chat-send" onclick="sendChat()">Send</button>
      </div>
    </div>
  </div>
</div>

<div class="detail-overlay" id="detailOverlay">
  <div class="detail-shell">
    <button class="detail-close" onclick="closeDetail()">&#10005;</button>
    <section class="detail-stage">
      <div class="stage-topbar">
        <div id="stageMeta">Loading...</div>
        <div class="stage-nav-row">
          <button class="stage-nav" onclick="navBook(-1)" title="Previous book">&#8592;</button>
          <button class="stage-nav" onclick="navBook(1)" title="Next book">&#8594;</button>
        </div>
      </div>
      <div class="stage-frame">
        <button class="photo-nav prev" onclick="navPhoto(-1)" title="Previous photo">&#8249;</button>
        <img class="stage-image" id="detailImg" src="" alt="Book photo">
        <button class="photo-nav next" onclick="navPhoto(1)" title="Next photo">&#8250;</button>
      </div>
      <div class="stage-caption" id="stageCaption"></div>
      <div class="thumb-row" id="thumbRow"></div>
    </section>
    <aside class="detail-panel" id="detailPanel"></aside>
  </div>
</div>

<div class="notice" id="notice"></div>

<div class="modal-overlay" id="correctionModal">
  <div class="modal-shell">
    <button class="modal-close" onclick="closeCorrectionModal()">&#10005;</button>
    <div class="modal-header">
      <div class="eyebrow">Correction queue</div>
      <div class="modal-heading" id="modalTitle">Fix OCR extract</div>
      <div class="modal-note" id="modalNote">You can save now or keep it in the queue for later.</div>
    </div>
    <div class="modal-body">
      <div class="modal-preview" id="modalPreview"></div>
      <div class="modal-form-wrap">
        <div class="field-grid" id="correctionFields"></div>
        <div class="detail-actions">
          <button class="ghost-btn" onclick="closeCorrectionModal()">Keep in queue</button>
          <button class="gen-btn" id="saveCorrectionBtn" onclick="saveCorrection()">Save correction</button>
        </div>
      </div>
    </div>
  </div>
</div>

<div class="modal-overlay" id="inviteModal">
  <div class="modal-shell" style="max-width:620px;">
    <button class="modal-close" onclick="closeInviteModal()">&#10005;</button>
    <div class="modal-header">
      <div class="eyebrow">Family access</div>
      <div class="modal-heading">Invite a member</div>
      <div class="invite-help">This sends a Supabase invite email. The person can then sign in with magic links and access the private catalog.</div>
    </div>
    <div class="modal-form-wrap">
      <div class="invite-form">
        <input type="email" class="search-box" id="inviteEmailInput" placeholder="family@example.com" autocomplete="email">
        <button class="gen-btn" id="inviteSubmitBtn" onclick="inviteMember()">Send invite</button>
        <div class="invite-status" id="inviteStatus"></div>
      </div>
    </div>
  </div>
</div>

<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
<script>
window.APP_CONFIG = __APP_CONFIG_JSON__;
</script>
<script>
const state = {
  currentPage: 1,
  currentGroups: [],
  currentGroupIdx: -1,
  currentGroup: null,
  currentPhotoIdx: 0,
  searchTimer: null,
  savingTitle: false,
  toastTimer: null,
  reviewDashboard: null,
  correctionItem: null,
  gameAnimating: false,
  authUser: null,
  appLoaded: false,
};

const appConfig = window.APP_CONFIG || {};
let supabaseClient = null;

const reviewFields = [
  { key: 'title', label: 'Title' },
  { key: 'subtitle', label: 'Subtitle' },
  { key: 'author', label: 'Author' },
  { key: 'translator', label: 'Translator' },
  { key: 'publisher', label: 'Publisher' },
  { key: 'year', label: 'Year' },
  { key: 'edition', label: 'Edition' },
  { key: 'language', label: 'Language' },
  { key: 'series', label: 'Series' },
  { key: 'volume', label: 'Volume' },
  { key: 'condition', label: 'Condition' },
  { key: 'special_features', label: 'Special features', wide: true, textarea: true },
  { key: 'other_text', label: 'Other text', wide: true, textarea: true },
  { key: 'raw_ocr_text', label: 'Raw OCR transcript', wide: true, textarea: true },
];

function authEnabled() {
  return appConfig.authMode === 'supabase';
}

function setAuthStatus(message, tone) {
  const status = document.getElementById('authStatus');
  if (!status) return;
  status.textContent = message || '';
  status.className = 'auth-status' + (tone ? ' ' + tone : '');
}

function setInviteStatus(message, tone) {
  const status = document.getElementById('inviteStatus');
  if (!status) return;
  status.textContent = message || '';
  status.className = 'invite-status' + (tone ? ' ' + tone : '');
}

function updateAuthChrome() {
  const authScreen = document.getElementById('authScreen');
  const userPill = document.getElementById('userPill');
  const inviteBtn = document.getElementById('inviteBtn');
  const logoutBtn = document.querySelector('.logout-btn');
  if (!authEnabled()) {
    authScreen.classList.remove('show');
    userPill.hidden = true;
    inviteBtn.hidden = true;
    logoutBtn.hidden = appConfig.authMode !== 'password';
    return;
  }

  const user = state.authUser;
  if (!user) {
    authScreen.classList.add('show');
    userPill.hidden = true;
    inviteBtn.hidden = true;
    logoutBtn.hidden = true;
    return;
  }

  authScreen.classList.remove('show');
  userPill.hidden = false;
  userPill.textContent = user.email || 'Signed in';
  inviteBtn.hidden = !user.is_owner;
  logoutBtn.hidden = false;
}

function clearAppData() {
  state.currentGroups = [];
  state.currentGroup = null;
  state.currentGroupIdx = -1;
  state.currentPhotoIdx = 0;
  state.reviewDashboard = null;
  document.getElementById('libraryGrid').innerHTML = '';
  document.getElementById('pagination').innerHTML = '';
  document.getElementById('statsBar').innerHTML = '';
  document.getElementById('gameStage').innerHTML = '';
  document.getElementById('flaggedQueue').innerHTML = '';
  document.getElementById('chatMsgs').innerHTML = '<div class="chat-msg ai"><div class="chat-bubble">Ask me anything about the collection and I will answer from the catalog.</div></div>';
}

async function authHeaders(extraHeaders) {
  const headers = new Headers(extraHeaders || {});
  if (authEnabled() && supabaseClient) {
    const sessionData = await supabaseClient.auth.getSession();
    const session = sessionData.data.session;
    if (session && session.access_token) {
      headers.set('Authorization', 'Bearer ' + session.access_token);
    }
  }
  return headers;
}

async function fetchJson(url, options) {
  const nextOptions = { ...(options || {}) };
  nextOptions.headers = await authHeaders(nextOptions.headers);
  const response = await fetch(url, nextOptions);
  const data = await response.json().catch(() => ({}));
  if (!response.ok) {
    if (response.status === 401) {
      if (authEnabled() && supabaseClient) {
        await supabaseClient.auth.signOut();
        state.authUser = null;
        state.appLoaded = false;
        clearAppData();
        updateAuthChrome();
        setAuthStatus('Your session expired. Request a new magic link.', 'error');
      } else {
        window.location.href = '/';
      }
      throw new Error('Authentication required');
    }
    throw new Error(data.detail || 'Request failed');
  }
  return data;
}

async function sendMagicLink() {
  if (!authEnabled()) return;
  const input = document.getElementById('authEmailInput');
  const button = document.getElementById('authBtn');
  const email = input.value.trim();
  if (!email) {
    setAuthStatus('Enter the invited email address first.', 'error');
    return;
  }

  button.disabled = true;
  button.textContent = 'Sending...';
  setAuthStatus('', '');
  try {
    const { error } = await supabaseClient.auth.signInWithOtp({
      email,
      options: {
        shouldCreateUser: false,
        emailRedirectTo: appConfig.authRedirectUrl || window.location.origin,
      },
    });
    if (error) throw error;
    setAuthStatus('Magic link sent. Open the email on this device to enter the catalog.', 'ok');
  } catch (error) {
    setAuthStatus(error.message || 'Could not send the magic link.', 'error');
  } finally {
    button.disabled = false;
    button.textContent = 'Email me a magic link';
  }
}

async function loadAuthUser() {
  if (!authEnabled()) return null;
  const me = await fetchJson('/api/auth/me');
  state.authUser = me;
  updateAuthChrome();
  return me;
}

async function ensureSupabaseAuth() {
  if (!authEnabled()) {
    updateAuthChrome();
    return true;
  }
  if (!window.supabase || !appConfig.supabaseUrl || !appConfig.supabaseAnonKey) {
    setAuthStatus('Supabase Auth is not configured correctly for the frontend.', 'error');
    updateAuthChrome();
    return false;
  }

  if (!supabaseClient) {
    supabaseClient = window.supabase.createClient(appConfig.supabaseUrl, appConfig.supabaseAnonKey);
    supabaseClient.auth.onAuthStateChange(async (event, session) => {
      if (event === 'SIGNED_OUT' || !session) {
        state.authUser = null;
        state.appLoaded = false;
        clearAppData();
        updateAuthChrome();
        return;
      }
      try {
        await loadAuthUser();
        if (!state.appLoaded) {
          await loadAppData();
        }
      } catch (error) {
        setAuthStatus(error.message, 'error');
      }
    });
  }

  const sessionData = await supabaseClient.auth.getSession();
  if (!sessionData.data.session) {
    updateAuthChrome();
    return false;
  }

  await loadAuthUser();
  return true;
}

function openInviteModal() {
  document.getElementById('inviteModal').classList.add('open');
  setInviteStatus('', '');
  syncBodyLock();
}

function closeInviteModal() {
  document.getElementById('inviteModal').classList.remove('open');
  syncBodyLock();
}

async function inviteMember() {
  const input = document.getElementById('inviteEmailInput');
  const button = document.getElementById('inviteSubmitBtn');
  const email = input.value.trim();
  if (!email) {
    setInviteStatus('Enter a valid email address.', 'error');
    return;
  }

  button.disabled = true;
  button.textContent = 'Sending...';
  setInviteStatus('', '');
  try {
    const result = await fetchJson('/api/invite-member', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email }),
    });
    setInviteStatus('Invite sent to ' + result.email + '.', 'ok');
    input.value = '';
  } catch (error) {
    setInviteStatus(error.message, 'error');
  } finally {
    button.disabled = false;
    button.textContent = 'Send invite';
  }
}

function showPanel(name, btn) {
  document.querySelectorAll('.panel').forEach(panel => panel.classList.remove('active'));
  document.querySelectorAll('.nav-tab').forEach(tab => tab.classList.remove('active'));
  document.getElementById(name + 'Panel').classList.add('active');
  btn.classList.add('active');
  if (name === 'game') {
    loadReviewDashboard().catch(error => showNotice(error.message, 'error'));
  }
}

function debounceSearch() {
  clearTimeout(state.searchTimer);
  state.searchTimer = setTimeout(() => {
    state.currentPage = 1;
    loadBooks();
  }, 280);
}

function resetLoad() {
  state.currentPage = 1;
  loadBooks();
}

function showNotice(message, tone) {
  const notice = document.getElementById('notice');
  notice.textContent = message;
  notice.className = 'notice show' + (tone === 'error' ? ' error' : '');
  clearTimeout(state.toastTimer);
  state.toastTimer = setTimeout(() => {
    notice.className = 'notice';
  }, 2200);
}

function syncBodyLock() {
  const detailOpen = document.getElementById('detailOverlay').classList.contains('open');
  const modalOpen = document.getElementById('correctionModal').classList.contains('open');
  const inviteOpen = document.getElementById('inviteModal').classList.contains('open');
  document.body.style.overflow = detailOpen || modalOpen || inviteOpen ? 'hidden' : '';
}

function esc(value) {
  if (value === null || value === undefined) return '';
  const div = document.createElement('div');
  div.textContent = String(value);
  return div.innerHTML;
}

function escAttr(value) {
  return esc(value).replace(/"/g, '&quot;');
}

function bookCardMeta(book) {
  const parts = [];
  if (book.author) parts.push(book.author);
  if (book.publisher) parts.push(book.publisher);
  if (book.year) parts.push(book.year);
  if (book.language) parts.push(book.language);
  return parts.length ? parts.join(' • ') : 'Metadata still needs cleanup';
}

function reviewLabel(status) {
  return {
    pending: 'Pending',
    verified: 'Verified',
    flagged: 'Flagged',
    corrected: 'Corrected',
  }[status] || 'Pending';
}

function clipText(value, limit) {
  const text = (value || '').trim();
  if (!text) return '';
  return text.length > limit ? text.slice(0, limit - 1) + '…' : text;
}

function editableValue(value, fieldKey) {
  if (!value) return '';
  if (fieldKey !== 'raw_ocr_text' && value === 'Unknown') return '';
  return value;
}

function photoDisplaySrc(photo) {
  return photo.display_url || photo.image_url || photo.thumb_url || ('/photos/' + photo.source_folder + '/' + photo.source_filename);
}

function photoThumbSrc(photo) {
  return photo.thumb_url || photo.display_url || photo.image_url || ('/photos/' + photo.source_folder + '/' + photo.source_filename);
}

function photoSrc(photo) {
  return photoThumbSrc(photo);
}

async function logoutApp() {
  if (authEnabled() && supabaseClient) {
    await supabaseClient.auth.signOut();
    state.authUser = null;
    state.appLoaded = false;
    clearAppData();
    updateAuthChrome();
    setAuthStatus('Signed out. Use your invite email to request another magic link.', 'ok');
    return;
  }
  try {
    await fetch('/logout', { method: 'POST' });
  } finally {
    window.location.href = '/';
  }
}

function buildQuery() {
  const params = new URLSearchParams({
    page: state.currentPage,
    per_page: 24,
  });
  const search = document.getElementById('searchInput').value.trim();
  const folder = document.getElementById('folderFilter').value;
  const language = document.getElementById('langFilter').value;
  const titleFilter = document.getElementById('titleFilter').value;
  const [sort, order] = document.getElementById('sortSelect').value.split(':');
  params.set('sort', sort);
  params.set('order', order);
  if (search) params.set('search', search);
  if (folder) params.set('folder', folder);
  if (language) params.set('language', language);
  if (titleFilter) params.set('has_title', titleFilter);
  return params;
}

async function loadBooks() {
  const data = await fetchJson('/api/books?' + buildQuery().toString());
  state.currentGroups = data.books;
  renderBooks(data);
}

function renderBooks(data) {
  const grid = document.getElementById('libraryGrid');
  if (!data.books.length) {
    grid.innerHTML = '<div class="empty-state">No book groups match the current filters.</div>';
    document.getElementById('pagination').innerHTML = '';
    return;
  }

  grid.innerHTML = data.books.map((book, index) => {
    const cover = photoThumbSrc(book.cover_image);
    const thumbs = book.sample_images.map(image => '<img class="mini-thumb" src="' + escAttr(photoThumbSrc(image)) + '" alt="">').join('');
    const strip = thumbs ? '<div class="card-strip">' + thumbs + '</div>' : '';
    const badges = [
      '<span class="badge">' + book.image_count + ' photo' + (book.image_count === 1 ? '' : 's') + '</span>',
      ...book.folders.map(folder => '<span class="badge">' + esc(folder) + '</span>'),
      (book.described_count ? '<span class="badge has-desc">' + book.described_count + '/' + book.image_count + ' described</span>' : ''),
      (book.needs_review_count ? '<span class="badge review-flagged">' + book.needs_review_count + ' to review</span>' : '<span class="badge review-done">reviewed</span>'),
      (book.review_counts && book.review_counts.flagged ? '<span class="badge review-flagged">' + book.review_counts.flagged + ' flagged</span>' : ''),
    ].filter(Boolean).join('');

    return `
      <article class="book-card" onclick="openBook(${index})">
        <div class="card-media">
          <img class="card-image" src="${escAttr(cover)}" loading="lazy" alt="">
          <div class="card-count">${book.image_count} photo${book.image_count === 1 ? '' : 's'}</div>
        </div>
        <div class="card-body">
          <div class="card-overline">Book group</div>
          <div class="card-title">${esc(book.title)}</div>
          <div class="card-meta">${esc(bookCardMeta(book))}</div>
          ${strip}
          <div class="tag-row">${badges}</div>
        </div>
      </article>
    `;
  }).join('');

  document.getElementById('pagination').innerHTML =
    '<button class="page-btn" onclick="goPage(' + (data.page - 1) + ')"' + (data.page <= 1 ? ' disabled' : '') + '>Prev</button>' +
    '<span class="page-info">Page ' + data.page + ' of ' + data.pages + ' (' + data.total + ' book groups)</span>' +
    '<button class="page-btn" onclick="goPage(' + (data.page + 1) + ')"' + (data.page >= data.pages ? ' disabled' : '') + '>Next</button>';
}

function goPage(page) {
  state.currentPage = page;
  loadBooks();
  window.scrollTo({ top: 0, behavior: 'smooth' });
}

async function openBook(index, preferredImageId) {
  state.currentGroupIdx = index;
  const summary = state.currentGroups[index];
  await loadBookDetail(summary.group_key, preferredImageId);
  document.getElementById('detailOverlay').classList.add('open');
  syncBodyLock();
}

async function loadBookDetail(groupKey, preferredImageId) {
  const group = await fetchJson('/api/books/' + encodeURIComponent(groupKey));
  state.currentGroup = group;
  const preferredIndex = group.images.findIndex(image => image.id === preferredImageId);
  state.currentPhotoIdx = preferredIndex >= 0 ? preferredIndex : (group.primary_image_index || 0);
  renderDetail();
}

function closeDetail() {
  document.getElementById('detailOverlay').classList.remove('open');
  syncBodyLock();
}

function navBook(direction) {
  if (!state.currentGroups.length) return;
  if (state.currentGroupIdx < 0) return;
  let next = state.currentGroupIdx + direction;
  if (next < 0) next = state.currentGroups.length - 1;
  if (next >= state.currentGroups.length) next = 0;
  openBook(next);
}

function navPhoto(direction) {
  if (!state.currentGroup || !state.currentGroup.images.length) return;
  let next = state.currentPhotoIdx + direction;
  if (next < 0) next = state.currentGroup.images.length - 1;
  if (next >= state.currentGroup.images.length) next = 0;
  state.currentPhotoIdx = next;
  renderDetail();
}

function selectPhoto(index) {
  state.currentPhotoIdx = index;
  renderDetail();
}

function currentImage() {
  if (!state.currentGroup || !state.currentGroup.images.length) return null;
  return state.currentGroup.images[state.currentPhotoIdx];
}

function metaRow(label, value) {
  if (!value) return '';
  return '<div class="meta-label">' + esc(label) + '</div><div class="meta-value">' + esc(value) + '</div>';
}

function renderDetail() {
  const group = state.currentGroup;
  const photo = currentImage();
  if (!group || !photo) return;

  document.getElementById('detailImg').src = photoDisplaySrc(photo);
  document.getElementById('stageMeta').textContent = group.title + ' • ' + (state.currentPhotoIdx + 1) + '/' + group.images.length + ' photos';
  document.getElementById('stageCaption').textContent = photo.photo_label + ' • Image #' + photo.id + ' • ' + photo.source_filename;
  document.getElementById('thumbRow').innerHTML = group.images.map((image, index) =>
    '<button class="thumb-btn' + (index === state.currentPhotoIdx ? ' active' : '') + '" onclick="selectPhoto(' + index + ')">' +
      '<img src="' + escAttr(photoThumbSrc(image)) + '" loading="lazy" alt="">' +
    '</button>'
  ).join('');

  const bookSummary = [group.author, group.publisher, group.year, group.language].filter(Boolean).join(' • ');
  const saveHint = group.image_count === 1
    ? 'Click the title, edit it, then press Enter or tap away. This saves directly to books.db for this photo.'
    : 'Click the title, edit it, then press Enter or tap away. This saves directly to books.db for all ' + group.image_count + ' grouped photos.';

  const photoTags = [photo.photo_label, photo.source_folder, photo.language].filter(Boolean).map(tag => '<span class="photo-chip">' + esc(tag) + '</span>').join('');
  const descriptionHtml = photo.description
    ? '<div class="desc-box">' + esc(photo.description) + '</div>'
    : '<div class="desc-box desc-empty">No selling description generated yet for this photo.</div>';

  document.getElementById('detailPanel').innerHTML = `
    <div class="eyebrow">Book-centered detail</div>
    <input
      id="titleInput"
      class="title-input"
      type="text"
      value="${escAttr(group.editable_title || '')}"
      placeholder="Enter book title"
      onkeydown="handleTitleKey(event)"
      onblur="saveTitle()"
    >
    <div class="title-hint" id="titleHint">${esc(saveHint)}</div>
    <div class="detail-actions">
      <button class="ghost-btn" onclick="navBook(-1)">Prev book</button>
      <button class="ghost-btn" onclick="navBook(1)">Next book</button>
    </div>
    <div class="byline">${esc(bookSummary || 'Use the title field above to clean up unidentified groups and merge matching photos.')}</div>

    <div class="section-title">Book Summary</div>
    <div class="meta-grid">
      ${metaRow('Author', group.author)}
      ${metaRow('Publisher', group.publisher)}
      ${metaRow('Year', group.year)}
      ${metaRow('Language', group.language)}
      ${metaRow('Photos', String(group.image_count))}
      ${metaRow('Described', group.described_count + '/' + group.image_count)}
    </div>

    <div class="section-title">Selected Photo</div>
    <div class="photo-chip-row">${photoTags}</div>
    <div class="meta-grid">
      ${metaRow('Image ID', String(photo.id))}
      ${metaRow('Review', reviewLabel(photo.review_status))}
      ${metaRow('Folder', photo.source_folder)}
      ${metaRow('File', photo.source_filename)}
      ${metaRow('Subtitle', photo.subtitle && photo.subtitle !== 'Unknown' ? photo.subtitle : '')}
      ${metaRow('Edition', photo.edition && photo.edition !== 'Unknown' ? photo.edition : '')}
      ${metaRow('Translator', photo.translator && photo.translator !== 'Unknown' ? photo.translator : '')}
      ${metaRow('Series', photo.series && photo.series !== 'Unknown' ? photo.series : '')}
      ${metaRow('Volume', photo.volume && photo.volume !== 'Unknown' ? photo.volume : '')}
      ${metaRow('Condition', photo.condition && photo.condition !== 'Unknown' ? photo.condition : '')}
      ${metaRow('Features', photo.special_features && photo.special_features !== 'Unknown' ? photo.special_features : '')}
      ${metaRow('Other', photo.other_text && photo.other_text !== 'Unknown' ? photo.other_text : '')}
    </div>

    <div class="section-title">Selling Description</div>
    ${descriptionHtml}
    <button class="gen-btn" id="genBtn" onclick="generateDesc(${photo.id})">${photo.description ? 'Regenerate description' : 'Generate description'}</button>

    <div class="section-title">OCR Transcript</div>
    <div class="ocr-box">${esc(photo.raw_ocr_text || 'No OCR text available for this photo.')}</div>
  `;
}

function handleTitleKey(event) {
  if (event.key === 'Enter') {
    event.preventDefault();
    event.target.blur();
  }
  if (event.key === 'Escape') {
    event.preventDefault();
    event.target.value = state.currentGroup ? (state.currentGroup.editable_title || '') : '';
    event.target.blur();
  }
}

function setTitleHint(message, tone) {
  const hint = document.getElementById('titleHint');
  if (!hint) return;
  hint.textContent = message;
  hint.className = 'title-hint' + (tone ? ' ' + tone : '');
}

async function saveTitle() {
  const input = document.getElementById('titleInput');
  if (!input || !state.currentGroup || state.savingTitle) return;

  const nextTitle = input.value.trim();
  const currentTitle = (state.currentGroup.editable_title || '').trim();
  if (nextTitle === currentTitle) return;
  if (!nextTitle || nextTitle.toLowerCase() === 'unknown') {
    input.value = currentTitle;
    setTitleHint('Please enter a real title so the grouping stays useful.', 'error');
    return;
  }

  state.savingTitle = true;
  setTitleHint('Saving title to the database...', 'ok');
  const focusImage = currentImage();
  try {
    const result = await fetchJson('/api/book-groups/' + encodeURIComponent(state.currentGroup.group_key) + '/title', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: nextTitle }),
    });
    await Promise.all([loadBooks(), loadStats()]);
    await loadBookDetail(result.group_key, focusImage ? focusImage.id : null);
    const nextIndex = state.currentGroups.findIndex(book => book.group_key === result.group_key);
    if (nextIndex >= 0) state.currentGroupIdx = nextIndex;
    setTitleHint('Saved to books.db. Matching titles will regroup automatically.', 'ok');
    showNotice('Saved title to ' + result.updated + ' photo' + (result.updated === 1 ? '' : 's') + '.', 'ok');
  } catch (error) {
    input.value = currentTitle;
    setTitleHint(error.message, 'error');
    showNotice(error.message, 'error');
  } finally {
    state.savingTitle = false;
  }
}

async function generateDesc(imageId) {
  const button = document.getElementById('genBtn');
  if (!button) return;
  button.disabled = true;
  button.textContent = 'Generating...';
  try {
    await fetchJson('/api/generate-description', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ image_id: imageId }),
    });
    await Promise.all([loadBookDetail(state.currentGroup.group_key, imageId), loadBooks(), loadStats()]);
    showNotice('Description generated for image #' + imageId + '.', 'ok');
  } catch (error) {
    button.textContent = error.message;
    showNotice(error.message, 'error');
  }
}

async function batchGenerate() {
  const button = document.getElementById('batchBtn');
  const status = document.getElementById('batchStatus');
  button.disabled = true;
  button.textContent = 'Generating...';
  status.textContent = '';
  try {
    const data = await fetchJson('/api/generate-descriptions-batch', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ count: 10 }),
    });
    status.textContent = 'Generated ' + data.generated + '. ' + data.remaining + ' photos still need descriptions.';
    await Promise.all([loadBooks(), loadStats()]);
    showNotice('Batch generation finished.', 'ok');
  } catch (error) {
    status.textContent = error.message;
    showNotice(error.message, 'error');
  } finally {
    button.disabled = false;
    button.textContent = 'Generate 10 descriptions';
  }
}

function reviewMetaRow(label, value) {
  if (!value) return '';
  return '<div class="review-label">' + esc(label) + '</div><div class="review-value">' + esc(value) + '</div>';
}

function renderFlaggedQueue(items) {
  const queue = document.getElementById('flaggedQueue');
  document.getElementById('flaggedCount').textContent = items.length + ' queued';
  if (!items.length) {
    queue.innerHTML = '<div class="review-empty">Flag the mismatches and they will stack up here for later editing.</div>';
    return;
  }

  queue.innerHTML = items.map(item => {
    const meta = [item.author, item.language, item.photo_label].filter(Boolean).join(' • ');
    return `
      <div class="queue-item">
        <img src="${escAttr(photoThumbSrc(item))}" loading="lazy" alt="">
        <div>
          <div class="queue-item-title">${esc(item.title)}</div>
          <div class="queue-item-meta">${esc(meta || 'Needs manual correction')}</div>
          <div class="queue-item-meta">Image #${item.id} • ${esc(item.source_filename)}</div>
          <div class="queue-item-actions">
            <button class="ghost-btn" onclick="openCorrectionModal(${item.id})">Edit now</button>
          </div>
        </div>
      </div>
    `;
  }).join('');
}

function renderGame() {
  const stage = document.getElementById('gameStage');
  const dashboard = state.reviewDashboard;
  if (!dashboard) {
    stage.innerHTML = '<div class="review-empty">Loading review queue...</div>';
    return;
  }

  const counts = dashboard.counts;
  document.getElementById('gameSummary').innerHTML =
    '<strong>' + counts.done + '</strong> done • ' +
    '<strong>' + counts.pending + '</strong> new cards left • ' +
    '<strong>' + counts.flagged + '</strong> parked for later';
  renderFlaggedQueue(dashboard.flagged || []);

  if (!dashboard.next_image) {
    if (counts.flagged) {
      stage.innerHTML = '<div class="review-empty"><div><div class="eyebrow">No fresh cards left</div><div class="game-title" style="font-size:28px;margin-top:10px">Nice. Only the flagged queue remains.</div><div class="game-copy">Open any queued mismatch on the right, correct it, and the game will be finished.</div></div></div>';
    } else {
      stage.innerHTML = '<div class="review-empty"><div><div class="eyebrow">Game complete</div><div class="game-title" style="font-size:28px;margin-top:10px">Every photo is now verified or corrected.</div><div class="game-copy">Once you are happy with the catalog, this tab can be removed from the UI.</div></div></div>';
    }
    return;
  }

  const image = dashboard.next_image;
  const summary = [image.author, image.publisher, image.year, image.language].filter(Boolean).join(' • ');
  const excerpt = clipText(image.raw_ocr_text || image.other_text || '', 260);
  const reviewGrid = [
    reviewMetaRow('Title', image.title && image.title !== 'Unknown' ? image.title : 'Missing title'),
    reviewMetaRow('Subtitle', image.subtitle && image.subtitle !== 'Unknown' ? image.subtitle : ''),
    reviewMetaRow('Author', image.author && image.author !== 'Unknown' ? image.author : ''),
    reviewMetaRow('Publisher', image.publisher),
    reviewMetaRow('Year', image.year),
    reviewMetaRow('Edition', image.edition && image.edition !== 'Unknown' ? image.edition : ''),
    reviewMetaRow('Language', image.language && image.language !== 'Unknown' ? image.language : ''),
    reviewMetaRow('Series', image.series && image.series !== 'Unknown' ? image.series : ''),
    reviewMetaRow('Condition', image.condition && image.condition !== 'Unknown' ? image.condition : ''),
    reviewMetaRow('Photo', image.photo_label),
    reviewMetaRow('File', image.source_filename),
  ].join('');

  stage.innerHTML = `
    <div class="review-card" id="reviewCard">
      <div class="review-media">
        <div class="review-badge">Image #${image.id}</div>
        <img src="${escAttr(photoDisplaySrc(image))}" alt="Review photo">
      </div>
      <div class="review-side">
        <div class="eyebrow">Quick verification</div>
        <div class="review-title">${esc(image.title && image.title !== 'Unknown' ? image.title : 'Untitled photo')}</div>
        <div class="review-subcopy">${esc(summary || 'Look at the extracted data, then approve or flag it for correction.')}</div>
        <div class="review-grid">${reviewGrid}</div>
        ${excerpt ? '<div class="section-title">OCR excerpt</div><div class="ocr-box">' + esc(excerpt) + '</div>' : ''}
        <div class="review-actions">
          <button class="review-btn reject" onclick="flagCurrent()">&#10005; Needs fixing<small>Queue it and open correction form</small></button>
          <button class="review-btn approve" onclick="verifyCurrent()">&#10003; Looks right<small>Mark this photo verified</small></button>
        </div>
      </div>
    </div>
  `;
}

async function loadReviewDashboard() {
  const dashboard = await fetchJson('/api/review/dashboard');
  state.reviewDashboard = dashboard;
  renderGame();
}

function animateReviewCard(direction) {
  const card = document.getElementById('reviewCard');
  if (!card) return Promise.resolve();
  card.classList.remove('swipe-left', 'swipe-right');
  void card.offsetWidth;
  card.classList.add(direction === 'left' ? 'swipe-left' : 'swipe-right');
  return new Promise(resolve => window.setTimeout(resolve, 290));
}

async function verifyCurrent() {
  if (!state.reviewDashboard || !state.reviewDashboard.next_image || state.gameAnimating) return;
  const imageId = state.reviewDashboard.next_image.id;
  state.gameAnimating = true;
  try {
    await animateReviewCard('right');
    await fetchJson('/api/review/' + imageId + '/verify', { method: 'POST' });
    await Promise.all([loadReviewDashboard(), loadStats(), loadBooks()]);
    showNotice('Image #' + imageId + ' verified.', 'ok');
  } catch (error) {
    await loadReviewDashboard();
    showNotice(error.message, 'error');
  } finally {
    state.gameAnimating = false;
  }
}

async function flagCurrent() {
  if (!state.reviewDashboard || !state.reviewDashboard.next_image || state.gameAnimating) return;
  const imageId = state.reviewDashboard.next_image.id;
  state.gameAnimating = true;
  try {
    await animateReviewCard('left');
    await fetchJson('/api/review/' + imageId + '/flag', { method: 'POST' });
    await Promise.all([loadReviewDashboard(), loadStats(), loadBooks()]);
    showNotice('Image #' + imageId + ' added to the correction queue.', 'ok');
    await openCorrectionModal(imageId);
  } catch (error) {
    await loadReviewDashboard();
    showNotice(error.message, 'error');
  } finally {
    state.gameAnimating = false;
  }
}

async function openCorrectionModal(imageId) {
  const image = await fetchJson('/api/images/' + imageId);
  state.correctionItem = image;
  document.getElementById('modalTitle').textContent = 'Correct image #' + image.id;
  document.getElementById('modalNote').textContent = 'Save now or close this modal to keep the image in the queue for later.';
  document.getElementById('modalPreview').innerHTML =
    '<img src="' + escAttr(photoDisplaySrc(image)) + '" alt="">' +
    '<div class="queue-item-title">' + esc(image.title && image.title !== 'Unknown' ? image.title : 'Untitled photo') + '</div>' +
    '<div class="queue-item-meta">' + esc([image.author, image.publisher, image.year, image.photo_label].filter(Boolean).join(' • ') || 'Correct the extracted fields on the right.') + '</div>';

  document.getElementById('correctionFields').innerHTML = reviewFields.map(field => {
    const tag = field.textarea ? 'textarea' : 'input';
    const className = 'form-field' + (field.wide ? ' wide' : '');
    if (tag === 'textarea') {
      return '<label class="' + className + '"><span>' + esc(field.label) + '</span><textarea data-field="' + escAttr(field.key) + '"></textarea></label>';
    }
    return '<label class="' + className + '"><span>' + esc(field.label) + '</span><input type="text" data-field="' + escAttr(field.key) + '"></label>';
  }).join('');

  for (const field of reviewFields) {
    const input = document.querySelector('[data-field="' + field.key + '"]');
    if (input) {
      input.value = editableValue(image[field.key], field.key);
    }
  }

  document.getElementById('correctionModal').classList.add('open');
  syncBodyLock();
}

function closeCorrectionModal() {
  document.getElementById('correctionModal').classList.remove('open');
  state.correctionItem = null;
  syncBodyLock();
}

async function saveCorrection() {
  if (!state.correctionItem) return;
  const button = document.getElementById('saveCorrectionBtn');
  const imageId = state.correctionItem.id;
  const payload = {};
  for (const field of reviewFields) {
    const input = document.querySelector('[data-field="' + field.key + '"]');
    payload[field.key] = input ? input.value.trim() : '';
  }

  button.disabled = true;
  button.textContent = 'Saving...';
  try {
    const result = await fetchJson('/api/review/' + imageId, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
    closeCorrectionModal();
    await Promise.all([loadReviewDashboard(), loadStats(), loadBooks()]);
    if (state.currentGroup && state.currentGroup.images.some(image => image.id === imageId)) {
      await loadBookDetail(result.image.group_key, imageId);
      const nextIndex = state.currentGroups.findIndex(book => book.group_key === result.image.group_key);
      if (nextIndex >= 0) state.currentGroupIdx = nextIndex;
    }
    showNotice('Corrections saved for image #' + imageId + '.', 'ok');
  } catch (error) {
    showNotice(error.message, 'error');
  } finally {
    button.disabled = false;
    button.textContent = 'Save correction';
  }
}

function askQ(question) {
  document.getElementById('chatInput').value = question;
  sendChat();
}

async function sendChat() {
  const input = document.getElementById('chatInput');
  const question = input.value.trim();
  if (!question) return;
  input.value = '';

  const messages = document.getElementById('chatMsgs');
  messages.innerHTML += '<div class="chat-msg user"><div class="chat-bubble">' + esc(question) + '</div></div>';
  messages.innerHTML += '<div class="chat-msg ai" id="pendingChat"><div class="chat-bubble" style="color:var(--soft)">Thinking...</div></div>';
  messages.scrollTop = messages.scrollHeight;

  try {
    const data = await fetchJson('/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ question }),
    });
    document.querySelector('#pendingChat .chat-bubble').innerHTML = fmtMd(data.answer);
  } catch (error) {
    document.querySelector('#pendingChat .chat-bubble').textContent = error.message;
  }
  document.getElementById('pendingChat').id = '';
  messages.scrollTop = messages.scrollHeight;
}

function fmtMd(text) {
  let safe = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  safe = safe.replace(/[*][*](.+?)[*][*]/g, '<strong>$1</strong>');
  safe = safe.replace(/[*](.+?)[*]/g, '<em>$1</em>');
  safe = safe.replace(/`(.+?)`/g, '<code>$1</code>');
  safe = safe.replace(/\n/g, '<br>');
  return safe;
}

async function loadStats() {
  const stats = await fetchJson('/api/stats');
  document.getElementById('statsBar').innerHTML = [
    '<span class="stat-pill"><strong>' + stats.book_groups + '</strong> book groups</span>',
    '<span class="stat-pill"><strong>' + stats.total + '</strong> photos</span>',
    '<span class="stat-pill"><strong>' + stats.with_title + '</strong> titled</span>',
    '<span class="stat-pill"><strong>' + stats.review.pending + '</strong> pending</span>',
    '<span class="stat-pill"><strong>' + stats.review.flagged + '</strong> flagged</span>',
    '<span class="stat-pill"><strong>' + stats.with_description + '</strong> described</span>',
  ].join('');
  document.getElementById('descProgress').textContent = stats.with_description + '/' + stats.total + ' described';
}

async function loadAppData() {
  if (state.appLoaded) return;
  const languages = await fetchJson('/api/languages');
  const languageSelect = document.getElementById('langFilter');
  if (!languageSelect.dataset.loaded) {
    for (const language of languages) {
      const option = document.createElement('option');
      option.value = language;
      option.textContent = language;
      languageSelect.appendChild(option);
    }
    languageSelect.dataset.loaded = 'true';
  }
  state.appLoaded = true;
  await Promise.all([loadStats(), loadBooks()]);
}

async function init() {
  document.getElementById('correctionModal').addEventListener('click', event => {
    if (event.target.id === 'correctionModal') closeCorrectionModal();
  });
  document.getElementById('inviteModal').addEventListener('click', event => {
    if (event.target.id === 'inviteModal') closeInviteModal();
  });

  const ready = await ensureSupabaseAuth();
  if (!authEnabled() || ready) {
    await loadAppData();
  }
}

document.addEventListener('keydown', event => {
  const modalOpen = document.getElementById('correctionModal').classList.contains('open');
  if (modalOpen && event.key === 'Escape') {
    closeCorrectionModal();
    return;
  }
  const inviteOpen = document.getElementById('inviteModal').classList.contains('open');
  if (inviteOpen && event.key === 'Escape') {
    closeInviteModal();
    return;
  }
  const overlayOpen = document.getElementById('detailOverlay').classList.contains('open');
  if (!overlayOpen) return;
  const activeTag = document.activeElement ? document.activeElement.tagName : '';
  const editingInput = activeTag === 'INPUT' || activeTag === 'TEXTAREA';
  if (event.key === 'Escape') {
    closeDetail();
    return;
  }
  if (editingInput) return;
  if (event.key === 'ArrowLeft') navPhoto(-1);
  if (event.key === 'ArrowRight') navPhoto(1);
  if (event.key === 'ArrowUp') navBook(-1);
  if (event.key === 'ArrowDown') navBook(1);
});

init();
</script>
</body>
</html>
"""
