"""Duty Free Professor - application entrypoint.

One Python process serves both the API and the built SPA, per the stack standard.
The page routes (`mount_site`) stamp each page's head and, for product variants, its body
server-side, answer HEAD, carry ETag/Last-Modified for conditional GETs, and
return real 404s for unknown product variants and routes. Every HTML and JSON response
is `Cache-Control: no-store`, so a CDN in front can never serve an admin's
change late; immutable assets keep their year.
"""

import hashlib
import json
import logging
from datetime import UTC, datetime
from email.utils import format_datetime, parsedate_to_datetime
from pathlib import Path

from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session

from app.config import settings
from app.db import get_db
from app.routers import catalog, discussion, health, items, plan, quote, sources, trip, todos
from app.routers import articles, attachments, collectors, auth, image_ask, notifications, ops, subscribers
from app.routers import airports
from app.routers import review
from app.routers import publish as publish_router
from app.services import access, accounts, catalog_queries, feeds, indexnow, publish, robots_policy, seo, sessions, urls
from app.version import APP_VERSION

logging.basicConfig(
    level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s"
)
log = logging.getLogger(__name__)

STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
EXTRA_STATIC_DIR = Path("/srv/extra-static")
STARTED_AT = datetime.now(UTC).replace(microsecond=0)

app = FastAPI(
    title="Duty Free Professor",
    version=APP_VERSION,
    description="Duty-free price comparison across airport retailers.",
    # The interactive docs are off in production whatever the site mode; check.sh and the
    # Docker build read app.openapi() in-process, which needs no route.
    docs_url=None if settings.is_production else "/docs",
    redoc_url=None if settings.is_production else "/redoc",
    openapi_url=None if settings.is_production else "/openapi.json",
)
# Compress text responses here as well as at the edge: a server-rendered product
# page is ~18 KB raw and ~4 KB gzipped, and not every host in front of this
# process encodes. Caddy passes an already-encoded body through untouched.
app.add_middleware(GZipMiddleware, minimum_size=1024)
# Every request, reads and pages included, passes the access policy before routing: a
# route in no class of services/access.py is refused, anonymous callers are turned away
# while the site is members-only, and a permission is checked where one is named.
access.install(app)
# The session loader runs outside the access middleware (added after it, so it is outer):
# every policy decision and every route sees request.state.session, and a View As the kit
# changed on the row is flushed after the response (services/sessions.py).
sessions.install(app)


@app.middleware("http")
async def cache_headers(request: Request, call_next):
    """Immutable assets cache for a year; everything a human or crawler reads
    (HTML, JSON) is no-store.

    Bundled assets are content-hashed and fonts never change in place, so a
    year is safe; medal artwork is versioned by query string (Medal.tsx).
    Pages and API answers change whenever an owner edits something, and a
    production CDN that cached them would keep showing the old value.
    """
    response = await call_next(request)
    path = request.url.path
    if path.startswith(("/assets/", "/fonts/")):
        response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
    elif path.startswith(("/medals/", "/flags/")):
        response.headers["Cache-Control"] = "public, max-age=2592000"
    elif "Cache-Control" not in response.headers:
        ctype = response.headers.get("content-type", "")
        if ctype.startswith(("text/html", "application/json")):
            response.headers["Cache-Control"] = "no-store"
    return response


app.include_router(health.router)
app.include_router(catalog.router)
app.include_router(airports.router)  # /api/airports/{iata}/hours (Stream G)
app.include_router(sources.router)
app.include_router(collectors.router)
app.include_router(image_ask.router)
app.include_router(trip.router)
app.include_router(discussion.router)
app.include_router(discussion.pages_router)  # the moved client pages, 301 (T8)
app.include_router(quote.router)
app.include_router(plan.router)
app.include_router(items.router)
app.include_router(notifications.router)
app.include_router(attachments.router)
if settings.feature_client_todos:
    app.include_router(todos.router)
app.include_router(articles.router)
app.include_router(subscribers.router)
app.include_router(auth.router)
# The account system: the kit is wired once here (the owner from ACCOUNT_OWNER, the mapped
# store, the six permissions); DFP's /api/bw/me is registered before the kit router so the
# kit's own /me is shadowed, never edited (services/accounts.py).
accounts.init()
app.include_router(auth.me_router)
app.include_router(accounts.kit_router())
app.include_router(ops.router)
app.include_router(review.router)  # /api/review: the sheet, approval and undo (Stream K4)
app.include_router(publish_router.router)  # /api/review/index: the index candidates a person approves (Stream K6)

# Project explainer pages, served alongside the app.
if EXTRA_STATIC_DIR.is_dir():
    app.mount("/docs-static", StaticFiles(directory=EXTRA_STATIC_DIR), name="docs-static")

    # Serve every explainer at its own top-level URL. Listing them one route at
    # a time meant a new page silently fell through to the SPA catch-all below
    # and rendered the app instead -- it looks like it works, because something
    # returns 200.
    @app.get("/{page}.html", include_in_schema=False)
    def explainer(page: str) -> FileResponse:
        # index.html belongs to the app, never to an explainer directory that
        # happens to contain one.
        if page == "index" and STATIC_DIR.is_dir():
            return FileResponse(STATIC_DIR / "index.html")
        candidate = (EXTRA_STATIC_DIR / f"{page}.html").resolve()
        if candidate.parent != EXTRA_STATIC_DIR.resolve() or not candidate.is_file():
            raise HTTPException(status_code=404, detail="No such page")
        return FileResponse(candidate)


def site_base(request: Request) -> str:
    return seo.site_base(
        request.headers.get("host"), settings.public_base_url, settings.is_production
    )


def conditional(
    request: Request,
    body: str,
    media_type: str,
    last_modified: datetime | None,
    status_code: int = 200,
) -> Response:
    """A response with a weak ETag and Last-Modified, or a 304 when the client
    already holds this version. Crawlers revalidate constantly; a 304 costs a
    hash instead of a page. The ETag is weak because the edge may re-encode
    the body, which is the case a strong tag would misdescribe."""
    payload = body.encode()
    etag = 'W/"' + hashlib.sha1(payload).hexdigest()[:20] + '"'
    modified = (last_modified or STARTED_AT).astimezone(UTC).replace(microsecond=0)
    headers = {"ETag": etag, "Last-Modified": format_datetime(modified, usegmt=True)}
    if status_code == 200:
        if etag in [t.strip() for t in request.headers.get("if-none-match", "").split(",")]:
            return Response(status_code=304, headers=headers)
        since = request.headers.get("if-modified-since")
        if since and "if-none-match" not in request.headers:
            try:
                if modified <= parsedate_to_datetime(since):
                    return Response(status_code=304, headers=headers)
            except (TypeError, ValueError):
                pass
    return Response(payload, status_code=status_code, media_type=media_type, headers=headers)


def mount_site(app: FastAPI, static_dir: Path) -> None:
    """The page routes, over one built SPA shell.

    Separate from import so tests can mount it over a temporary shell with no
    database, and so a process without a built frontend (the OpenAPI dump in
    the Docker build) simply has no pages.
    """
    app.mount("/assets", StaticFiles(directory=static_dir / "assets"), name="assets")
    # The shell's own root files (logo, icons, manifest) are public by exact path.
    access.register_root_files(static_dir)
    # Real static mounts (not the SPA catch-all) so HEAD and ranges work for
    # crawlers and caches; the cache middleware stamps their lifetimes.
    for sub in ("fonts", "medals", "flags", "media"):
        if (static_dir / sub).is_dir():
            app.mount(f"/{sub}", StaticFiles(directory=static_dir / sub), name=sub)

    # The built shell, read once. Every page's head is stamped into a copy of
    # this server-side, so crawlers that never run JavaScript still see the
    # title, description, canonical and schema.org markup. Feature switches
    # ride in the same shell so the SPA knows them before first render.
    flags_tag = (
        "<script>window.__DFP_FLAGS__ = "
        + json.dumps(settings.spa_flags).replace("<", "\\u003c")
        + "; window.__DFP_SOCIAL__ = "
        + json.dumps(settings.social_profiles).replace("<", "\\u003c")
        + "</script></head>"
    )
    shell = (static_dir / "index.html").read_text().replace("</head>", flags_tag, 1)
    # Which medal artwork exists, so the server-rendered body shows the image
    # exactly where the SPA would (and the text badge where it would not).
    medal_assets = frozenset(
        p.name for p in (static_dir / "medals").glob("*.png")
    ) if (static_dir / "medals").is_dir() else frozenset()

    def page(head: seo.Head | None, request: Request, status_code: int = 200) -> Response:
        html = shell if head is None else head.apply(shell, site_base(request))
        last = head.last_modified if head else None
        return conditional(request, html, "text/html", last, status_code)

    def not_found(request: Request) -> Response:
        # The shell with a noindex head and a real 404 status: the SPA draws
        # its not-found page, and no crawler files the address as a page.
        return page(seo.NOT_FOUND_HEAD, request, status_code=404)

    def hidden_redirect(db: Session, kind: str, ref: str) -> Response | None:
        """A page a person hid answers 302 to the nearest right page, never 404 (plan W18;
        `publish.hidden_forward`): hidden means wrong, and may be put right, so nothing permanent."""
        target = publish.hidden_target(db, kind, ref)
        return RedirectResponse(target, status_code=302) if target else None

    # The access policy answers a page a client may not know exists (/plan) with this
    # same shell, so a refusal and a missing page are one response.
    app.state.not_found = not_found

    @app.api_route("/robots.txt", methods=["GET", "HEAD"], include_in_schema=False)
    def robots(request: Request) -> Response:
        """Per-bot policy and Content Signals (app/services/robots_policy.py) once the
        site is public; while members-only, one Disallow for everyone and no Sitemap line
        (a 302 or 401 here would read as "no restrictions")."""
        if not settings.site_open:
            return conditional(request, "User-agent: *\nDisallow: /\n", "text/plain", None)
        return conditional(request, robots_policy.robots_txt(site_base(request)), "text/plain", None)

    @app.api_route("/feed.xml", methods=["GET", "HEAD"], include_in_schema=False)
    def feed(request: Request, db: Session = Depends(get_db)) -> Response:
        items = feeds.feed_items(db)
        newest = max((i.published for i in items), default=None)
        return conditional(
            request, feeds.rss_xml(items, site_base(request), newest), "application/rss+xml", newest
        )

    @app.api_route("/llms.txt", methods=["GET", "HEAD"], include_in_schema=False)
    def llms(request: Request, db: Session = Depends(get_db)) -> Response:
        facts = catalog_queries.dataset_facts(db)
        return conditional(
            request, feeds.llms_txt(facts, site_base(request)), "text/markdown", facts.last_observed_at
        )

    if settings.has_indexnow:
        # The key file the IndexNow engines fetch to confirm we own the host.
        @app.api_route(f"/{settings.indexnow_key}.txt", methods=["GET", "HEAD"], include_in_schema=False)
        def indexnow_key(request: Request) -> Response:
            return conditional(request, settings.indexnow_key, "text/plain", None)

    @app.api_route("/data", methods=["GET", "HEAD"], include_in_schema=False)
    def data_page(request: Request, db: Session = Depends(get_db)) -> Response:
        return page(seo.dataset_head(db, settings.spa_flags), request)

    @app.api_route("/sitemap.xml", methods=["GET", "HEAD"], include_in_schema=False)
    def sitemap(request: Request, db: Session = Depends(get_db)) -> Response:
        xml, newest = seo.sitemap_entries(
            db, site_base(request), include_my_airports=settings.feature_my_airports, line_pages=settings.line_pages
        )
        return conditional(request, xml, "application/xml", newest)

    @app.api_route("/products/{slug}", methods=["GET", "HEAD"], include_in_schema=False)
    def product_page(
        slug: str,
        request: Request,
        variant: str | None = None,
        airports: str | None = None,
        db: Session = Depends(get_db),
    ) -> Response:
        if settings.line_pages:
            return line_page(slug, request, variant, airports, db)
        variant_id = seo.parse_product_slug(slug)
        if variant_id is None:
            return not_found(request)
        # A merged product answers under its survivor: the head below is the
        # survivor's, so its canonical differs from the requested slug and the
        # 301 follows.
        head = seo.product_head(db, catalog_queries.resolve_variant_id(db, variant_id), medal_assets)
        if head is None:
            return not_found(request)
        # One canonical URL per bottle: the numeric shorthand, any stale slug
        # and any merged-away id all 301 to the current one.
        if f"/products/{slug}" != head.canonical_path:
            return RedirectResponse(head.canonical_path, status_code=301)
        return page(head, request)

    def line_page(slug: str, request: Request, variant: str | None, airports: str | None, db: Session) -> Response:
        """The product line page (Stream K5; plan W1). A live line answers for every view of it
        (the canonical is the bare address); an alias line or a retired slug 301s to the line
        with the query kept; an old variant address (`/products/<name>-<id>`, or the bare id)
        301s to its line with the variant chosen, a merged-away id first followed to its
        survivor. The parameters are read leniently: a mangled one is ignored, never a 422."""
        variant_id = int(variant) if variant and variant.isdigit() else None
        codes = urls.read_airports(airports)
        hidden = hidden_redirect(db, "product_line", slug)
        if hidden is not None:
            return hidden
        line, forward = catalog_queries.line_by_slug(db, slug)
        if line is not None:
            head = seo.line_head(db, line.slug, medal_assets, settings.spa_flags, variant_id=variant_id, airports=codes)
            return page(head, request) if head is not None else not_found(request)
        if forward:
            return RedirectResponse(urls.line_path(forward, variant_id, codes), status_code=301)
        old = seo.parse_product_slug(slug)
        landing = catalog_queries.line_of_variant(db, old) if old is not None else None
        if landing is None:
            return not_found(request)
        survivor, line_slug = landing
        return RedirectResponse(urls.line_path(line_slug, survivor, codes), status_code=301)

    @app.api_route("/airports/{slug}", methods=["GET", "HEAD"], include_in_schema=False)
    def airport_page(
        slug: str,
        request: Request,
        category: str | None = None,
        sort: str = "featured",
        page_no: str | None = Query(default=None, alias="page"),
        tab: str | None = None,
        multi_only: str | None = None,
        awarded_only: str | None = None,
        exclusives_only: str | None = None,
        db: Session = Depends(get_db),
    ) -> Response:
        iata = seo.parse_airport_slug(slug)
        if iata is None:
            return not_found(request)
        hidden = hidden_redirect(db, "place", iata)
        if hidden is not None:
            return hidden
        # Filters and paging are read leniently: a crawler that mangles ?page=
        # gets page one, never a 422 with a JSON body where a page should be.
        number = int(page_no) if page_no and page_no.isdigit() and int(page_no) > 0 else 1
        head = seo.airport_head(
            db, iata, medal_assets, settings.spa_flags,
            category=(category or None), sort=sort, page=number, tab=tab,
            multi_only=urls.read_flag(multi_only), awarded_only=urls.read_flag(awarded_only),
            exclusives_only=urls.read_flag(exclusives_only),
        )
        if head is None:
            return not_found(request)
        # One canonical path per airport: the bare code and any stale city
        # words 301 to the current one, keeping the filters.
        if f"/airports/{slug}" != head.canonical_path:
            target = head.canonical_path + (f"?{request.url.query}" if request.url.query else "")
            return RedirectResponse(target, status_code=301)
        return page(head, request)

    @app.api_route("/airports/{slug}/{category_slug}", methods=["GET", "HEAD"], include_in_schema=False)
    def airport_category_page(
        slug: str,
        category_slug: str,
        request: Request,
        sort: str = "featured",
        page_no: str | None = Query(default=None, alias="page"),
        multi_only: str | None = None,
        awarded_only: str | None = None,
        exclusives_only: str | None = None,
        db: Session = Depends(get_db),
    ) -> Response:
        """Category at airport (/airports/<airport>/<category>): a page only where the pair
        is over the coverage bar (a real 404 under it, nothing typed); the airport part
        canonicalises like the airport page, the category word exactly, both keeping the query."""
        iata = seo.parse_airport_slug(slug)
        if iata is None or seo.category_from_slug(category_slug) is None:
            return not_found(request)
        hidden = hidden_redirect(db, "place", iata)
        if hidden is not None:
            return hidden
        number = int(page_no) if page_no and page_no.isdigit() and int(page_no) > 0 else 1
        head = seo.airport_category_head(
            db, iata, category_slug, medal_assets, settings.spa_flags,
            sort=sort, page=number, multi_only=urls.read_flag(multi_only),
            awarded_only=urls.read_flag(awarded_only), exclusives_only=urls.read_flag(exclusives_only),
        )
        if head is None:
            return not_found(request)
        if f"/airports/{slug}/{category_slug}" != head.canonical_path:
            target = head.canonical_path + (f"?{request.url.query}" if request.url.query else "")
            return RedirectResponse(target, status_code=301)
        return page(head, request)

    @app.api_route("/articles/{slug}", methods=["GET", "HEAD"], include_in_schema=False)
    def article_page(slug: str, request: Request, db: Session = Depends(get_db)) -> Response:
        """One published article, body and all; a draft or an unknown slug is a
        real 404, not the shell (nothing about unpublished work is inferable)."""
        head = seo.article_head(db, slug, settings.spa_flags) if len(slug) <= 160 else None
        if head is None:
            return not_found(request)
        return page(head, request)

    @app.api_route("/brands/{slug}", methods=["GET", "HEAD"], include_in_schema=False)
    def brand_page(
        slug: str,
        request: Request,
        category: str | None = None,
        sort: str = "featured",
        page_no: str | None = Query(default=None, alias="page"),
        db: Session = Depends(get_db),
    ) -> Response:
        hidden = hidden_redirect(db, "brand", slug.lower())
        if hidden is not None:
            return hidden
        number = int(page_no) if page_no and page_no.isdigit() and int(page_no) > 0 else 1
        head = seo.brand_head(
            db, slug.lower(), medal_assets, settings.spa_flags,
            category=(category or None), sort=sort, page=number,
        )
        if head is None:
            return not_found(request)
        # An alias row's slug, or a differently cased one, 301s to the brand's page.
        if f"/brands/{slug}" != head.canonical_path:
            target = head.canonical_path + (f"?{request.url.query}" if request.url.query else "")
            return RedirectResponse(target, status_code=301)
        return page(head, request)

    @app.api_route("/{full_path:path}", methods=["GET", "HEAD"], include_in_schema=False)
    def spa(full_path: str, request: Request) -> Response:
        """Serve the SPA shell for the app's own routes (client-side routing).

        A path with a file extension is a request for a FILE: if it does not
        exist, answering with the app shell is a lie -- crawlers read a 200
        /sitemap.xml full of HTML as a broken site, not a missing file. And a
        path the SPA has no route for is a 404, not a 200 with the shell.
        """
        # Confined to the shell root. Uvicorn passes ".." (and "%2e%2e", which it
        # decodes) through unnormalised, and the bare join this replaced served
        # any file on the container filesystem to anyone past the gate (found in
        # the accounts plan's review, 2026-09-10). Same pattern as the explainer
        # route above.
        root = static_dir.resolve()
        candidate = (static_dir / full_path).resolve()
        if full_path and root in candidate.parents and candidate.is_file():
            return FileResponse(candidate)
        last = full_path.rsplit("/", 1)[-1]
        if "." in last:
            raise HTTPException(status_code=404, detail="No such file")
        path = f"/{full_path}".rstrip("/") or "/"
        if not seo.is_known_route(path):
            return not_found(request)
        return page(seo.STATIC_HEADS.get(path), request)


if STATIC_DIR.is_dir():
    mount_site(app, STATIC_DIR)
    if settings.is_production and not settings.public_base_url:
        log.warning(
            "PUBLIC_BASE_URL is not set: canonical, sitemap and JSON-LD URLs are "
            "relative until it is (set it in .app.env, e.g. https://<host>)"
        )
