"""The public website: finding a workspace's site, checking what a public
page may hold, and writing the HTML search engines and visitors get.

A page is on the site when it is public (Page.is_public), has a slug and has
been published — the site only ever shows the published version.

- "html" pages (Page.content_mode) are served whole: the author's own
  document (already sanitized on save), with the SEO tags merged into its
  <head>. Nothing in it runs (Content-Security-Policy: script-src 'none').
- "blocks" pages are drawn by the web app itself (frontend PublicSitePage),
  so they look exactly as in the designer. The server sends the web app's
  own index.html with this page's title, description, social card and
  structured data in <head>, and a plain HTML rendering of the page's
  content inside #root — what crawlers and link previews read, replaced by
  the real page once the app starts.
"""

from __future__ import annotations

import html
import json
import re
import time
import uuid
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from xml.sax.saxutils import escape as xml_escape

import httpx
from lxml import etree
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.config import settings
from app.core.html_sanitize import parse_document, sanitize_fragment, serialize_document, text_content
from app.crud import licensing
from app.models.access import PageVersion
from app.models.identity import Tenant
from app.models.navigation import Page
from app.schemas.public_site import PUBLIC_ACTION_TYPES, PUBLIC_BLOCK_TYPES, PageSeo, SiteSettings

# ---- finding the site --------------------------------------------------------------------------------------


@dataclass
class Site:
    tenant: Tenant
    slug: str
    settings: SiteSettings

    @property
    def name(self) -> str:
        return self.settings.site_name or self.tenant.name

    @property
    def base_path(self) -> str:
        return f"/site/{self.slug}"


async def set_tenant(db: AsyncSession, tenant_id: uuid.UUID) -> None:
    await db.execute(text("SELECT set_config('app.current_tenant_id', :v, true)"), {"v": str(tenant_id)})


def _usable(tenant: Tenant | None) -> bool:
    if tenant is None or tenant.status != "active" or not tenant.site_slug:
        return False
    if licensing.tenant_message(tenant):
        return False
    return bool((tenant.site_settings or {}).get("enabled"))


def _site(tenant: Tenant) -> Site:
    return Site(tenant=tenant, slug=tenant.site_slug or "", settings=SiteSettings.model_validate(tenant.site_settings or {}))


async def site_by_slug(db: AsyncSession, slug: str) -> Site | None:
    tenant = (await db.execute(select(Tenant).where(Tenant.site_slug == slug.lower()))).scalar_one_or_none()
    if not _usable(tenant):
        return None
    await set_tenant(db, tenant.id)
    return _site(tenant)


async def site_for_host(db: AsyncSession, host: str | None) -> Site | None:
    """The site whose landing page "/" shows on this host: one listing the
    host among its domains, else the platform's default site."""
    hostname = (host or "").strip().lower()
    if hostname:
        candidates = [hostname, hostname.split(":", 1)[0]]
        for candidate in dict.fromkeys(candidates):
            tenant = (
                await db.execute(select(Tenant).where(Tenant.site_settings["domains"].contains([candidate])).limit(1))
            ).scalar_one_or_none()
            if _usable(tenant):
                await set_tenant(db, tenant.id)
                return _site(tenant)
    default = (settings.public_site_default or "").strip().lower()
    if default:
        return await site_by_slug(db, default)
    return None


# ---- pages on the site -------------------------------------------------------------------------------------


@dataclass
class PublicPage:
    page: Page
    version: PageVersion


async def _published(db: AsyncSession, pages: list[Page]) -> list[PublicPage]:
    wanted = [(p.id, p.published_version) for p in pages if p.published_version is not None]
    if not wanted:
        return []
    rows = await db.execute(select(PageVersion).where(PageVersion.page_id.in_([pid for pid, _ in wanted])))
    found = {(v.page_id, v.version): v for v in rows.scalars()}
    return [PublicPage(p, found[(p.id, p.published_version)]) for p in pages if (p.id, p.published_version) in found]


async def site_pages(db: AsyncSession, site: Site) -> list[PublicPage]:
    pages = (
        await db.execute(select(Page).where(Page.tenant_id == site.tenant.id, Page.is_public.is_(True), Page.slug.is_not(None)).order_by(Page.name))
    ).scalars().all()
    return await _published(db, list(pages))


async def site_page(db: AsyncSession, site: Site, page_slug: str | None) -> PublicPage | None:
    """The published public page at this slug — or, with no slug, the landing page."""
    query = select(Page).where(Page.tenant_id == site.tenant.id, Page.is_public.is_(True), Page.slug.is_not(None))
    if page_slug:
        query = query.where(Page.slug == page_slug.lower())
    elif site.settings.home_page_id:
        query = query.where(Page.id == site.settings.home_page_id)
    else:
        return None
    page = (await db.execute(query)).scalar_one_or_none()
    if page is None:
        return None
    found = await _published(db, [page])
    return found[0] if found else None


def page_path(site: Site, page_id: uuid.UUID | str, slug: str | None) -> str:
    if site.settings.home_page_id and str(site.settings.home_page_id) == str(page_id):
        return f"{site.base_path}/"
    return f"{site.base_path}/{slug}" if slug else f"{site.base_path}/"


async def link_map(db: AsyncSession, site: Site) -> dict[str, str]:
    """page id -> address, for every public page with a slug (published or not
    yet — a link to one not published yet just shows "not found")."""
    rows = await db.execute(select(Page.id, Page.slug).where(Page.tenant_id == site.tenant.id, Page.is_public.is_(True), Page.slug.is_not(None)))
    return {str(pid): page_path(site, pid, slug) for pid, slug in rows.all()}


# ---- what a public page may hold ---------------------------------------------------------------------------

_BLOCK_LABELS = {
    "data_list": "Data list", "data_grid": "Data grid", "detail_view": "Detail view", "form": "Form", "form_field": "Form field",
    "kpi_card": "KPI card", "bar_chart": "Bar chart", "pie_chart": "Pie chart", "line_chart": "Line chart", "menu": "Menu",
}


def _walk(blocks: list[Any]) -> list[dict]:
    out: list[dict] = []
    for block in blocks or []:
        if not isinstance(block, dict):
            continue
        out.append(block)
        out.extend(_walk(block.get("children") or []))
        for group in block.get("groups") or []:
            if isinstance(group, dict):
                out.extend(_walk(group.get("children") or []))
    return out


def public_problems(blocks: list[Any], content_mode: str = "blocks", open_sources: set[str] | frozenset[str] = frozenset()) -> list[str]:
    """Why these blocks can't go on the public site — empty when they can.
    `open_sources`: the datasources/forms open to the public ("master:<id>"),
    whose data blocks may be on a public page, read-only (app/crud/public_data.py)."""
    from app.crud.public_data import DATA_BLOCK_TYPES, data_block_problems

    if content_mode == "html":
        return []
    problems: list[str] = []
    for block in _walk(blocks):
        kind = block.get("type") or ""
        if kind in DATA_BLOCK_TYPES:
            problems += data_block_problems(block, set(open_sources))
            continue
        if kind not in PUBLIC_BLOCK_TYPES:
            label = _BLOCK_LABELS.get(kind, kind.replace("_", " ").capitalize())
            problems.append(f"{label} blocks show workspace data, so they need a signed-in person.")
            continue
        for key in ("action", "back_action"):
            action = block.get(key)
            if isinstance(action, dict) and action.get("type") and action["type"] not in PUBLIC_ACTION_TYPES:
                what = (block.get("label") or block.get("text") or kind).strip()
                problems.append(f"“{what}” runs “{action['type'].replace('_', ' ')}”, which needs a signed-in person — public pages can only navigate, open links and popups, and print.")
            if isinstance(action, dict) and action.get("type") == "navigate" and action.get("target_type") not in (None, "page"):
                what = (block.get("label") or block.get("text") or kind).strip()
                problems.append(f"“{what}” opens a {action.get('target_type')}, which needs a signed-in person — link to a public page or a web address instead.")
    # One message per kind of problem is enough.
    return list(dict.fromkeys(problems))


# ---- SEO tags -----------------------------------------------------------------------------------------------


def _attr(value: str) -> str:
    return html.escape(value or "", quote=True)


def _ld(data: Any) -> str:
    # "</script>" can't end the block early: every "<" is escaped.
    return json.dumps(data, ensure_ascii=False).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")


def absolute(origin: str, url: str | None) -> str | None:
    if not url:
        return None
    if url.startswith(("http://", "https://")):
        return url
    return origin.rstrip("/") + (url if url.startswith("/") else "/" + url)


@dataclass
class SeoTags:
    title: str
    description: str
    canonical: str
    lang: str
    head: str  # every tag below <title>, as HTML


def build_seo(
    site: Site,
    *,
    page_name: str,
    seo: dict,
    url: str,
    origin: str,
    fallback_text: str,
    is_home: bool,
    updated_at: datetime | None = None,
) -> SeoTags:
    meta = PageSeo.model_validate(seo or {})
    cfg = site.settings
    page_title = (meta.title or page_name or "").strip()
    if is_home and not meta.title:
        title = site.name
    else:
        title = cfg.title_template.replace("{page}", page_title).replace("{site}", site.name).strip(" |-–—")
    description = (meta.description or "").strip() or _summary(fallback_text) or (cfg.default_description or "")
    canonical = absolute(origin, meta.canonical_url) or url
    index = meta.robots_index and cfg.allow_indexing
    robots = f"{'index' if index else 'noindex'},{'follow' if meta.robots_follow else 'nofollow'}"
    image = absolute(origin, meta.og_image or cfg.default_og_image)
    lang = meta.language or cfg.language or "en"

    tags = [
        f'<meta name="description" content="{_attr(description)}">' if description else "",
        f'<meta name="keywords" content="{_attr(meta.keywords)}">' if meta.keywords else "",
        f'<meta name="robots" content="{robots}">',
        f'<link rel="canonical" href="{_attr(canonical)}">',
        f'<meta property="og:site_name" content="{_attr(site.name)}">',
        f'<meta property="og:type" content="{meta.og_type}">',
        f'<meta property="og:title" content="{_attr(meta.og_title or page_title or title)}">',
        f'<meta property="og:description" content="{_attr(meta.og_description or description)}">' if (meta.og_description or description) else "",
        f'<meta property="og:url" content="{_attr(canonical)}">',
        f'<meta property="og:locale" content="{_attr(lang.replace("-", "_"))}">',
        f'<meta property="og:image" content="{_attr(image)}">' if image else "",
        f'<meta name="twitter:card" content="{meta.twitter_card if image else "summary"}">',
        f'<meta name="twitter:title" content="{_attr(meta.og_title or page_title or title)}">',
        f'<meta name="twitter:description" content="{_attr(meta.og_description or description)}">' if (meta.og_description or description) else "",
        f'<meta name="twitter:image" content="{_attr(image)}">' if image else "",
        f'<link rel="icon" href="{_attr(cfg.favicon_url)}">' if cfg.favicon_url else "",
        f'<meta name="google-site-verification" content="{_attr(cfg.google_site_verification)}">' if is_home and cfg.google_site_verification else "",
        f'<meta name="msvalidate.01" content="{_attr(cfg.bing_site_verification)}">' if is_home and cfg.bing_site_verification else "",
        f'<meta property="article:modified_time" content="{updated_at.isoformat()}">' if updated_at and meta.og_type == "article" else "",
    ]
    structured: list[Any] = []
    if is_home and cfg.organization_json_ld:
        site_url = absolute(origin, site.base_path + "/") or url
        structured.append({"@context": "https://schema.org", "@type": "WebSite", "name": site.name, "url": site_url})
        org: dict[str, Any] = {"@context": "https://schema.org", "@type": "Organization", "name": site.name, "url": site_url}
        logo = absolute(origin, cfg.organization_logo)
        if logo:
            org["logo"] = logo
        structured.append(org)
    if meta.json_ld:
        try:
            parsed = json.loads(meta.json_ld)
            structured.extend(parsed if isinstance(parsed, list) else [parsed])
        except ValueError:
            pass
    for item in structured:
        tags.append(f'<script type="application/ld+json">{_ld(item)}</script>')
    return SeoTags(title=title, description=description, canonical=canonical, lang=lang, head="\n".join(t for t in tags if t))


def _summary(value: str, limit: int = 160) -> str:
    value = re.sub(r"\s+", " ", value or "").strip()
    if len(value) <= limit:
        return value
    cut = value[: limit - 1].rsplit(" ", 1)[0]
    return cut.rstrip(",.;:-") + "…"


# ---- links between site pages -----------------------------------------------------------------------------

_FF_PAGE = re.compile(r"ff-page:([0-9a-fA-F-]{36})")


def resolve_page_links(markup: str, links: dict[str, str]) -> str:
    """ff-page:<page id> (how an imported site's pages link to each other) ->
    that page's current address."""
    return _FF_PAGE.sub(lambda m: links.get(m.group(1).lower(), "#"), markup)


# ---- html pages ---------------------------------------------------------------------------------------------


def render_html_page(document: str, seo: SeoTags, links: dict[str, str]) -> str:
    doc = parse_document(resolve_page_links(document or "", links))
    doc.set("lang", doc.get("lang") or seo.lang)
    head = doc.find("head")
    # Ours win over the document's own title, description, robots, canonical and social tags.
    for el in list(head):
        tag = el.tag if isinstance(el.tag, str) else ""
        name = (el.get("name") or el.get("property") or "").lower()
        rel = (el.get("rel") or "").lower()
        if tag == "title" or (tag == "meta" and (name in ("description", "keywords", "robots") or name.startswith(("og:", "twitter:")))) or (tag == "link" and rel == "canonical"):
            head.remove(el)
    if head.find("meta[@charset]") is None:
        head.insert(0, etree.Element("meta", charset="utf-8"))
    if not any((m.get("name") or "").lower() == "viewport" for m in head.iter("meta")):
        head.insert(1, etree.Element("meta", name="viewport", content="width=device-width, initial-scale=1"))
    title = etree.SubElement(head, "title")
    title.text = seo.title
    serialized = serialize_document(doc)
    return serialized.replace("</title>", "</title>\n" + seo.head, 1)


# ---- blocks pages: what crawlers read -----------------------------------------------------------------------


def _sorted(blocks: list[Any]) -> list[dict]:
    items = [b for b in blocks or [] if isinstance(b, dict) and b.get("type") != "popup"]
    return sorted(items, key=lambda b: ((b.get("layout") or {}).get("y", 0), (b.get("layout") or {}).get("x", 0)))


def _href(action: Any, links: dict[str, str]) -> str | None:
    if not isinstance(action, dict):
        return None
    if action.get("type") == "navigate" and action.get("target_type") == "page" and action.get("target_id"):
        return links.get(str(action["target_id"]).lower())
    if action.get("type") == "open_external_url" and action.get("url") and "{{" not in action["url"]:
        url = str(action["url"])
        return url if url.startswith(("http://", "https://", "/", "mailto:", "tel:")) else None
    return None


def render_blocks_text(blocks: list[Any], links: dict[str, str]) -> str:
    """Plain, semantic HTML of a blocks page's content."""
    out: list[str] = []
    for block in _sorted(blocks):
        kind = block.get("type")
        if kind not in PUBLIC_BLOCK_TYPES:
            continue
        label = (block.get("label") or "").strip()
        body = (block.get("text") or "").strip()
        if kind == "page_header":
            out.append(f"<h1>{html.escape(label or body)}</h1>")
            if block.get("subtitle"):
                out.append(f"<p>{html.escape(block['subtitle'])}</p>")
        elif kind == "text" and body:
            tag = {"heading": "h2", "subheading": "h3"}.get(block.get("text_style") or "", "p")
            out.append(f"<{tag}>{html.escape(body)}</{tag}>")
        elif kind in ("rich_text", "html") and block.get("html"):
            out.append(f"<div>{resolve_page_links(sanitize_fragment(block['html']), links)}</div>")
        elif kind == "alert" and (body or label):
            out.append(f"<p>{html.escape(body or label)}</p>")
        elif kind == "image" and block.get("image_url"):
            alt = "" if block.get("decorative") else (block.get("alt_text") or "")
            out.append(f'<img src="{_attr(block["image_url"])}" alt="{_attr(alt)}">')
        elif kind in ("button", "link") and label:
            href = _href(block.get("action"), links)
            out.append(f'<p><a href="{_attr(href)}">{html.escape(label)}</a></p>' if href else f"<p>{html.escape(label)}</p>")
        elif kind == "video" and block.get("video_url"):
            out.append(f'<p><a href="{_attr(block["video_url"])}">{html.escape(label or "Watch the video")}</a></p>')
        elif kind == "table":
            head = "".join(f"<th>{html.escape(str(c))}</th>" for c in block.get("table_columns") or [])
            rows = "".join("<tr>" + "".join(f"<td>{html.escape(str(c))}</td>" for c in row) + "</tr>" for row in block.get("table_rows") or [])
            if head or rows:
                out.append(f"<table><thead><tr>{head}</tr></thead><tbody>{rows}</tbody></table>")
        elif kind == "stepper" and block.get("steps"):
            items = "".join(f"<li>{html.escape(str(s.get('label') or ''))}</li>" for s in block["steps"] if isinstance(s, dict))
            out.append(f"<ol>{items}</ol>")
        if block.get("children"):
            inner = render_blocks_text(block["children"], links)
            if inner:
                out.append(f"<section>{inner}</section>")
        for group in block.get("groups") or []:
            if isinstance(group, dict):
                inner = render_blocks_text(group.get("children") or [], links)
                out.append(f"<section><h2>{html.escape(str(group.get('label') or ''))}</h2>{inner}</section>")
    return "\n".join(out)


def blocks_plain_text(blocks: list[Any]) -> str:
    return text_content(render_blocks_text(blocks, {}))


# ---- the web app's index.html -------------------------------------------------------------------------------

_index_cache: dict[str, tuple[float, str | None]] = {}
_INDEX_TTL = 60.0


def _index_url() -> str:
    if settings.public_site_app_index_url.strip():
        return settings.public_site_app_index_url.strip()
    if settings.app_base_url.strip():
        return settings.app_base_url.strip().rstrip("/") + "/index.html"
    return "http://localhost:5173/index.html"


async def app_index_html() -> str | None:
    """The web app's own index.html (scripts and styles that start it), or
    None when it can't be reached — the page is then served without it."""
    url = _index_url()
    cached = _index_cache.get(url)
    if cached and time.monotonic() - cached[0] < (_INDEX_TTL if cached[1] else 10.0):
        return cached[1]
    body: str | None = None
    try:
        async with httpx.AsyncClient(timeout=3.0, follow_redirects=True) as client:
            res = await client.get(url, headers={"Accept": "text/html"})
        if res.status_code == 200 and 'id="root"' in res.text:
            body = res.text
    except httpx.HTTPError:
        body = None
    _index_cache[url] = (time.monotonic(), body)
    return body


_FALLBACK_STYLE = (
    "<style>body{font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;margin:0;color:#0f172a;line-height:1.6}"
    "#root{max-width:1100px;margin:0 auto;padding:32px 20px}img{max-width:100%;height:auto}"
    "table{border-collapse:collapse}td,th{border:1px solid #e2e8f0;padding:6px 10px}a{color:#2563eb}</style>"
)


def render_app_shell(index_html: str | None, seo: SeoTags, *, site_slug: str, page_slug: str, content: str) -> str:
    root = f'<div id="root" data-ff-site="{_attr(site_slug)}" data-ff-page="{_attr(page_slug)}">{content}</div>'
    head_tags = f"<title>{html.escape(seo.title)}</title>\n{seo.head}"
    if index_html:
        shell = re.sub(r"<title>.*?</title>", "", index_html, count=1, flags=re.S | re.I)
        shell = re.sub(r'<meta\s+name="description"[^>]*>', "", shell, flags=re.I)
        shell = re.sub(r"<html([^>]*)\slang=\"[^\"]*\"", r"<html\1", shell, count=1, flags=re.I)
        # Replacements as functions: the tags hold backslashes (<) that re would read as escapes.
        shell = re.sub(r"<html", lambda _m: f'<html lang="{_attr(seo.lang)}"', shell, count=1, flags=re.I)
        shell = re.sub(r"</head>", lambda _m: head_tags + "\n</head>", shell, count=1, flags=re.I)
        shell, n = re.subn(r'<div id="root">\s*</div>', lambda _m: root, shell, count=1)
        if n == 1:
            return shell
    return (
        f'<!DOCTYPE html>\n<html lang="{_attr(seo.lang)}"><head><meta charset="utf-8">'
        f'<meta name="viewport" content="width=device-width, initial-scale=1">{head_tags}{_FALLBACK_STYLE}</head>'
        f"<body>{root}</body></html>"
    )


# ---- sitemap and robots.txt ---------------------------------------------------------------------------------


def sitemap_xml(site: Site, pages: list[PublicPage], origin: str) -> str:
    rows = []
    for item in pages:
        meta = PageSeo.model_validate(item.version.seo or {})
        if not meta.include_in_sitemap or not meta.robots_index or meta.canonical_url:
            continue
        loc = absolute(origin, page_path(site, item.page.id, item.page.slug))
        parts = [f"<loc>{xml_escape(loc or '')}</loc>", f"<lastmod>{item.version.published_at.date().isoformat()}</lastmod>"]
        if meta.change_frequency:
            parts.append(f"<changefreq>{meta.change_frequency}</changefreq>")
        priority = meta.sitemap_priority if meta.sitemap_priority is not None else (1.0 if loc and loc.endswith(site.base_path + "/") else None)
        if priority is not None:
            parts.append(f"<priority>{priority:.1f}</priority>")
        rows.append("<url>" + "".join(parts) + "</url>")
    # One address per page (the landing page may also be reachable by its slug).
    rows = list(dict.fromkeys(rows))
    return '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' + "\n".join(rows) + "\n</urlset>\n"


def robots_txt(sites: list[Site], origin: str) -> str:
    lines = ["User-agent: *", "Disallow: /api/", "Disallow: /portal", "Disallow: /admin", "Disallow: /home"]
    for site in sites:
        if not site.settings.allow_indexing:
            lines.append(f"Disallow: {site.base_path}/")
    lines.append("")
    for site in sites:
        if site.settings.allow_indexing:
            lines.append(f"Sitemap: {origin.rstrip('/')}{site.base_path}/sitemap.xml")
    return "\n".join(lines).rstrip() + "\n"
