import copy
import posixpath
import uuid

from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_db
from app.core.permissions import (
    deny_vendor,
    require_permission,
    require_portal_item,
    resolve_action_matrix,
    resolve_portal_grants,
)
from app.core.security import AuthContext, get_auth_context
from app.crud.audit import write_audit_log
from app.crud.base import CRUDBase
from app.core.html_sanitize import sanitize_document
from app.crud import html_site_import, public_data, public_site
from app.crud.change_log import blocks_diff, changed_keys
from app.models.access import PageVersion
from app.models.identity import User
from app.models.navigation import Page
from app.schemas.navigation import PageBlockDef, PageCreate, PageDuplicate, PagePublish, PageRead, PageUpdate, PageVersionRead
from app.schemas.public_site import HtmlImportedPage, HtmlImportResult, PageAssetRead, PageSiteUpdate

router = APIRouter(prefix="/pages", tags=["pages"])
crud = CRUDBase[Page, PageCreate, PageUpdate](Page)


def _contains_html_block(blocks: list[PageBlockDef]) -> bool:
    for block in blocks:
        if block.type == "html":
            return True
        if block.children and _contains_html_block(block.children):
            return True
        for group in block.groups or []:
            if _contains_html_block(group.children):
                return True
    return False


def _stored_blocks_contain_html(blocks: list) -> bool:
    """_contains_html_block over a page's stored JSONB blocks (plain dicts),
    for copying a page without re-validating blocks saved under older
    rules."""
    for block in blocks:
        if not isinstance(block, dict):
            continue
        if block.get("type") == "html":
            return True
        if _stored_blocks_contain_html(block.get("children") or []):
            return True
        for group in block.get("groups") or []:
            if isinstance(group, dict) and _stored_blocks_contain_html(group.get("children") or []):
                return True
    return False


async def _check_html_block_permission(db: AsyncSession, ctx: AuthContext, blocks: list[PageBlockDef] | bool) -> None:
    """The "html" block (raw author-supplied HTML/JS, sandboxed via
    <iframe srcdoc> — see PageBlockDef's doc for why) is gated behind the
    "pages":"publish" action rather than a new RBAC action of its own:
    RBAC_ACTIONS (frontend/src/lib/types.ts) is one fixed action vocabulary
    shared as a uniform column set across every module's Roles & Privileges
    grid, so a pages-only action would need a wider UI change than this one
    block type warrants. "publish" is already this app's most-trusted
    per-module action, so reusing it here is a real permission boundary
    (most editor roles won't have it) without inventing new RBAC plumbing."""
    has_html = blocks if isinstance(blocks, bool) else _contains_html_block(blocks)
    if not has_html:
        return
    matrix, is_system_admin = await resolve_action_matrix(db, ctx.tenant_id, ctx.user_id)
    if is_system_admin:
        return
    if "publish" not in matrix.get("pages", []):
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Your role doesn't have permission to add custom HTML — a Custom HTML block or an HTML page (requires 'pages':'publish').",
        )


async def _with_clean_html(db: AsyncSession, ctx: AuthContext, payload: PageCreate | PageUpdate) -> PageCreate | PageUpdate:
    """An "html" page's document, with anything that could run removed
    (app/core/html_sanitize.py). Writing one needs the same permission as a
    Custom HTML block."""
    if not (payload.html_document or "").strip():
        return payload
    await _check_html_block_permission(db, ctx, True)
    return payload.model_copy(update={"html_document": sanitize_document(payload.html_document or "")})


async def _check_public(db: AsyncSession, tenant_id: uuid.UUID, blocks: list, content_mode: str) -> None:
    open_sources = await public_data.public_sources(db, tenant_id)
    problems = public_site.public_problems(blocks, content_mode, open_sources)
    if problems:
        raise HTTPException(
            status_code=422,
            detail={"code": "not_public_safe", "message": "This page can't be on the public website: " + " ".join(problems), "problems": problems},
        )


async def _unique_slug(db: AsyncSession, tenant_id: uuid.UUID, wanted: str, page_id: uuid.UUID | None = None) -> str:
    """wanted, or wanted-2, wanted-3… — the first no other page has."""
    query = select(Page.slug).where(Page.tenant_id == tenant_id, Page.slug.like(f"{wanted}%"))
    if page_id is not None:
        query = query.where(Page.id != page_id)
    taken = {slug for (slug,) in (await db.execute(query)).all()}
    if wanted not in taken:
        return wanted
    n = 2
    while f"{wanted}-{n}" in taken:
        n += 1
    return f"{wanted}-{n}"


# ---- Working copy vs published versions -----------------------------------------------------------
#
# A page's own columns are the designer's working copy. Publishing snapshots
# it into page_versions and points pages.published_version at the snapshot;
# that snapshot is what visitors get (GET /pages/{id}/live, and GET
# /pages/{id} for a Public Portal caller) while the working copy keeps
# changing. A page never published shows its working copy, as before.

_CONTENT = ("name", "blocks", "parameters", "events", "variables", "canvas_width", "fit_mode", "content_mode", "html_document", "seo")


def _content(obj: Page | PageVersion) -> dict:
    return {k: getattr(obj, k) for k in _CONTENT}


async def _versions_by_page(db: AsyncSession, pages: list[Page]) -> dict[tuple[uuid.UUID, int], PageVersion]:
    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 {key: found[key] for key in wanted if key in found}


def _read(page: Page, published: PageVersion | None) -> PageRead:
    read = PageRead.model_validate(page)
    changed = published is not None and _content(page) != _content(published)
    return read.model_copy(update={"has_unpublished_changes": changed})


async def _read_one(db: AsyncSession, page: Page) -> PageRead:
    versions = await _versions_by_page(db, [page])
    return _read(page, versions.get((page.id, page.published_version)))


async def _live(db: AsyncSession, page: Page) -> PageRead:
    published = (await _versions_by_page(db, [page])).get((page.id, page.published_version))
    if published is None:
        return _read(page, None)
    return PageRead.model_validate(page).model_copy(
        update={**_content(published), "status": "published", "has_unpublished_changes": False, "updated_at": published.published_at}
    )


async def _audit_page(db: AsyncSession, ctx: AuthContext, action: str, page_id: uuid.UUID, details: dict | None = None) -> None:
    await write_audit_log(db, ctx.tenant_id, ctx.user_id, action, "page", page_id, details or {})


@router.get("", response_model=list[PageRead])
async def list_pages(
    skip: int = 0,
    limit: int = 100,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("pages", "view")),
) -> list[PageRead]:
    pages = await crud.list(db, ctx.tenant_id, skip=skip, limit=limit)
    if ctx.vendor_id is not None:
        grants = await resolve_portal_grants(db, ctx.tenant_id, ctx.user_id)
        allowed = grants.get("pages", set())
        pages = [p for p in pages if str(p.id).lower() in allowed]
    versions = await _versions_by_page(db, pages)
    return [_read(p, versions.get((p.id, p.published_version))) for p in pages]


@router.get("/{page_id}", response_model=PageRead)
async def get_page(
    page_id: uuid.UUID,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("pages", "view")),
    __: AuthContext = Depends(require_portal_item("pages", "page_id")),
) -> PageRead:
    """The working copy (what the Page Designer edits). A Public Portal
    caller gets the published version instead — unpublished work isn't
    theirs to see."""
    page = await crud.get(db, ctx.tenant_id, page_id)
    if ctx.vendor_id is not None:
        return await _live(db, page)
    return await _read_one(db, page)


@router.get("/{page_id}/live", response_model=PageRead)
async def get_live_page(
    page_id: uuid.UUID,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("pages", "view")),
    __: AuthContext = Depends(require_portal_item("pages", "page_id")),
) -> PageRead:
    """What visitors see: the published version, or the working copy of a
    page never published."""
    return await _live(db, await crud.get(db, ctx.tenant_id, page_id))


@router.post("", response_model=PageRead, status_code=201)
async def create_page(
    payload: PageCreate,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("pages", "create")),
    __: AuthContext = Depends(deny_vendor()),
) -> PageRead:
    await _check_html_block_permission(db, ctx, payload.blocks)
    payload = await _with_clean_html(db, ctx, payload)
    page = await crud.create(db, ctx.tenant_id, payload, created_by=ctx.user_id)
    await _audit_page(db, ctx, "page.create", page.id, {"name": page.name})
    return _read(page, None)


@router.patch("/{page_id}", response_model=PageRead)
async def update_page(
    page_id: uuid.UUID,
    payload: PageUpdate,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("pages", "edit")),
    __: AuthContext = Depends(deny_vendor()),
) -> PageRead:
    """Edits the working copy only — visitors keep seeing the published
    version until the page is published again."""
    if payload.blocks is not None:
        await _check_html_block_permission(db, ctx, payload.blocks)
    before = _content(await crud.get(db, ctx.tenant_id, page_id))
    before = copy.deepcopy(before)
    if "html_document" in payload.model_fields_set and payload.html_document == before["html_document"]:
        # Unchanged — not re-checked (or re-sanitized) on every save.
        payload.model_fields_set.discard("html_document")
    elif payload.html_document is not None:
        payload = await _with_clean_html(db, ctx, payload)
    page = await crud.update(db, ctx.tenant_id, page_id, payload)
    after = _content(page)
    await _audit_page(
        db, ctx, "page.update", page.id,
        {
            "name": page.name,
            "changed": changed_keys(before, after, ["name", "parameters", "events", "canvas_width", "fit_mode", "content_mode", "html_document", "seo"]),
            **blocks_diff(before["blocks"], after["blocks"]),
        },
    )
    return await _read_one(db, page)


@router.post("/{page_id}/duplicate", response_model=PageRead, status_code=201)
async def duplicate_page(
    page_id: uuid.UUID,
    payload: PageDuplicate,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("pages", "create")),
    __: AuthContext = Depends(deny_vendor()),
) -> PageRead:
    """A new draft page with the source's blocks, parameters, events and
    canvas width (its working copy). Block ids are kept as-is — they're only
    unique within a page, and the page's own actions reference them
    (popup_block_id). Menu entries and portal grants point at the source
    page and aren't copied."""
    source = await crud.get(db, ctx.tenant_id, page_id)
    await _check_html_block_permission(db, ctx, _stored_blocks_contain_html(source.blocks or []) or bool(source.html_document))
    name = (payload.name or "").strip() or f"{source.name} (copy)"
    page = Page(
        tenant_id=ctx.tenant_id,
        name=name[:200],
        blocks=copy.deepcopy(source.blocks or []),
        parameters=copy.deepcopy(source.parameters or []),
        events=copy.deepcopy(source.events or []),
        variables=copy.deepcopy(source.variables or []),
        canvas_width=source.canvas_width,
        fit_mode=source.fit_mode,
        content_mode=source.content_mode,
        html_document=source.html_document,
        seo=copy.deepcopy(source.seo or {}),
        status="draft",
        created_by=ctx.user_id,
    )
    db.add(page)
    await db.flush()
    await db.refresh(page)
    await _audit_page(db, ctx, "page.duplicate", page.id, {"name": page.name, "from": str(page_id)})
    return _read(page, None)


@router.post("/{page_id}/publish", response_model=PageRead)
async def publish_page(
    page_id: uuid.UUID,
    payload: PagePublish | None = None,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("pages", "publish")),
    __: AuthContext = Depends(deny_vendor()),
) -> PageRead:
    """Snapshots the working copy as the next version and makes it live."""
    page = await crud.get(db, ctx.tenant_id, page_id)
    if page.is_public:
        await _check_public(db, ctx.tenant_id, page.blocks or [], page.content_mode)
    latest = (await db.execute(select(func.max(PageVersion.version)).where(PageVersion.page_id == page.id))).scalar_one()
    version = PageVersion(
        tenant_id=ctx.tenant_id,
        page_id=page.id,
        version=(latest or 0) + 1,
        **copy.deepcopy(_content(page)),
        note=(payload.note or None) if payload else None,
        published_by=ctx.user_id,
    )
    db.add(version)
    page.status = "published"
    page.published_version = version.version
    await db.flush()
    await db.refresh(page)
    await _audit_page(db, ctx, "page.publish", page.id, {"name": page.name, "version": version.version, "note": version.note})
    return _read(page, version)


@router.get("/{page_id}/versions", response_model=list[PageVersionRead])
async def list_page_versions(
    page_id: uuid.UUID,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("pages", "view")),
    __: AuthContext = Depends(deny_vendor()),
) -> list[PageVersionRead]:
    await crud.get(db, ctx.tenant_id, page_id)
    rows = await db.execute(
        select(PageVersion, User.name)
        .outerjoin(User, User.id == PageVersion.published_by)
        .where(PageVersion.tenant_id == ctx.tenant_id, PageVersion.page_id == page_id)
        .order_by(PageVersion.version.desc())
    )
    return [PageVersionRead.model_validate(v).model_copy(update={"published_by_name": name}) for v, name in rows.all()]


async def _set_working_copy(db: AsyncSession, ctx: AuthContext, page: Page, source: PageVersion) -> None:
    await _check_html_block_permission(db, ctx, _stored_blocks_contain_html(source.blocks or []) or bool(source.html_document))
    for key, value in copy.deepcopy(_content(source)).items():
        setattr(page, key, value)
    await db.flush()
    await db.refresh(page)


@router.post("/{page_id}/versions/{version}/restore", response_model=PageRead)
async def restore_page_version(
    page_id: uuid.UUID,
    version: int,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("pages", "edit")),
    __: AuthContext = Depends(deny_vendor()),
) -> PageRead:
    """Copies an earlier version back into the working copy. Visitors see no
    change until it's published."""
    page = await crud.get(db, ctx.tenant_id, page_id)
    source = (
        await db.execute(select(PageVersion).where(PageVersion.tenant_id == ctx.tenant_id, PageVersion.page_id == page_id, PageVersion.version == version))
    ).scalar_one_or_none()
    if source is None:
        raise HTTPException(status_code=404, detail="Page version not found")
    await _set_working_copy(db, ctx, page, source)
    await _audit_page(db, ctx, "page.restore", page.id, {"name": page.name, "restored_from": version})
    return await _read_one(db, page)


@router.post("/{page_id}/discard", response_model=PageRead)
async def discard_page_changes(
    page_id: uuid.UUID,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("pages", "edit")),
    __: AuthContext = Depends(deny_vendor()),
) -> PageRead:
    """Throws away unpublished changes: the working copy goes back to the
    published version."""
    page = await crud.get(db, ctx.tenant_id, page_id)
    published = (await _versions_by_page(db, [page])).get((page.id, page.published_version))
    if published is None:
        raise HTTPException(status_code=409, detail="This page has never been published, so there's nothing to go back to.")
    await _set_working_copy(db, ctx, page, published)
    await _audit_page(db, ctx, "page.discard_changes", page.id, {"name": page.name, "version": published.version})
    return _read(page, published)


@router.delete("/{page_id}", status_code=204)
async def delete_page(
    page_id: uuid.UUID,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("pages", "delete")),
    __: AuthContext = Depends(deny_vendor()),
) -> None:
    page = await crud.get(db, ctx.tenant_id, page_id)
    name = page.name
    await crud.delete(db, ctx.tenant_id, page_id)
    await _audit_page(db, ctx, "page.delete", page_id, {"name": name})


# ---- The public website ------------------------------------------------------------------------------------
#
# A page is on the public website (app/routers/public_site.py) when it is
# public, has a slug and has been published. Putting it there is a
# publish-level action; so is uploading the images it shows, which anyone
# can then open.

_ASSET_MAX_BYTES = 15 * 1024 * 1024
_ASSET_TYPES = ("image/", "font/", "video/", "audio/", "application/pdf", "text/css")


@router.patch("/{page_id}/site", response_model=PageRead)
async def update_page_site(
    page_id: uuid.UUID,
    payload: PageSiteUpdate,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("pages", "publish")),
    __: AuthContext = Depends(deny_vendor()),
) -> PageRead:
    """Puts the page on the public website (or takes it off), at /site/<site>/<slug>.
    Takes effect at once; visitors only ever see the published version."""
    page = await crud.get(db, ctx.tenant_id, page_id)
    if payload.is_public:
        published = (await _versions_by_page(db, [page])).get((page.id, page.published_version))
        source = published or page
        await _check_public(db, ctx.tenant_id, source.blocks or [], source.content_mode)
    slug = payload.slug or page.slug or (html_site_import.slugify(page.name) if payload.is_public else None)
    if slug and slug != page.slug:
        clash = (await db.execute(select(Page.name).where(Page.tenant_id == ctx.tenant_id, Page.slug == slug, Page.id != page.id))).scalar_one_or_none()
        if clash is not None and payload.slug:
            raise HTTPException(status_code=409, detail=f"“{clash}” already uses the address /{slug}.")
        if clash is not None:
            slug = await _unique_slug(db, ctx.tenant_id, slug, page.id)
    before = {"is_public": page.is_public, "slug": page.slug}
    page.is_public = payload.is_public
    page.slug = slug
    await db.flush()
    await db.refresh(page)
    await _audit_page(db, ctx, "page.site", page.id, {"name": page.name, "before": before, "is_public": page.is_public, "slug": page.slug})
    return await _read_one(db, page)


@router.post("/assets", response_model=PageAssetRead, status_code=201)
async def upload_page_asset(
    file: UploadFile = File(...),
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("pages", "edit")),
    __: AuthContext = Depends(deny_vendor()),
) -> PageAssetRead:
    """An image (or font, video, PDF) for a page — public: anyone with its
    address can open it, so it's for website content, not private files."""
    data = await file.read()
    if len(data) > _ASSET_MAX_BYTES:
        raise HTTPException(status_code=413, detail="That file is over 15 MB.")
    filename = file.filename or "file"
    content_type = (file.content_type or "").lower()
    if not content_type.startswith(_ASSET_TYPES):
        content_type = html_site_import.content_type_for(filename)
    if not content_type.startswith(_ASSET_TYPES):
        raise HTTPException(status_code=415, detail="Upload an image, font, video, audio or PDF file.")
    record = await html_site_import.store_public_file(db, ctx.tenant_id, ctx.user_id, filename, data, content_type)
    return PageAssetRead(
        id=record.id, filename=record.filename, content_type=record.content_type, size_bytes=record.size_bytes,
        url=html_site_import.public_file_url(ctx.tenant_id, record.id, record.filename),
    )


@router.post("/import-html", response_model=HtmlImportResult, status_code=201)
async def import_html_pages(
    file: UploadFile | None = File(default=None),
    html: str | None = Form(default=None),
    page_id: uuid.UUID | None = Form(default=None),
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("pages", "create")),
    __: AuthContext = Depends(deny_vendor()),
) -> HtmlImportResult:
    """An HTML file, pasted HTML, or a .zip of a site folder, as "html" pages
    that look exactly like it (app/crud/html_site_import.py). With page_id,
    the main page replaces that page's working copy; every other .html file
    in a .zip becomes a new draft page."""
    await _check_html_block_permission(db, ctx, True)
    if file is not None:
        data = await file.read()
        filename = file.filename or "page.html"
    elif (html or "").strip():
        data = (html or "").encode("utf-8")
        filename = "page.html"
    else:
        raise HTTPException(status_code=422, detail="Choose an .html or .zip file, or paste the HTML.")
    try:
        bundle = html_site_import.read_upload(filename, data)
    except html_site_import.ImportError_ as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc

    target: Page | None = None
    if page_id is not None:
        target = await crud.get(db, ctx.tenant_id, page_id)
    page_ids = {path: (target.id if target is not None and path == bundle.main else uuid.uuid4()) for path in bundle.html}
    outcome = await html_site_import.import_bundle(db, ctx.tenant_id, ctx.user_id, bundle, page_ids)
    root = posixpath.dirname(bundle.main)

    created: list[HtmlImportedPage] = []
    for item in outcome.pages:
        name = (item.title or html_site_import.slug_from_path(item.path, root).replace("-", " ").capitalize())[:200]
        if target is not None and item.page_id == target.id:
            before_mode = target.content_mode
            target.content_mode = "html"
            target.html_document = item.document
            await db.flush()
            await _audit_page(db, ctx, "page.import_html", target.id, {"name": target.name, "from": filename, "was": before_mode})
            created.append(HtmlImportedPage(id=target.id, name=target.name, slug=target.slug))
            continue
        slug = await _unique_slug(db, ctx.tenant_id, html_site_import.slug_from_path(item.path, root))
        page = Page(
            id=item.page_id,
            tenant_id=ctx.tenant_id,
            name=name,
            blocks=[],
            content_mode="html",
            html_document=item.document,
            seo={},
            slug=slug,
            status="draft",
            created_by=ctx.user_id,
        )
        db.add(page)
        await db.flush()
        await _audit_page(db, ctx, "page.import_html", page.id, {"name": page.name, "from": filename})
        created.append(HtmlImportedPage(id=page.id, name=page.name, slug=page.slug))
    return HtmlImportResult(pages=created, assets=outcome.assets, notes=outcome.notes)

