"""Turns an HTML file — or a .zip of a whole site folder — into "html"
pages that look exactly like the original (POST /pages/import-html).

From a .zip, every .html file becomes a page; the images, fonts, videos and
other files they use are stored as public site assets
(UploadedFile.is_public) and every reference to them is pointed at its new
address. Local stylesheets are written into the page as <style> (so their
order and cascade are kept), links between the site's own pages become
"ff-page:<id>" (resolved to the page's address whenever it's served), and
lazy-loaded images (data-src) get a real src, since scripts never run.
JavaScript files are left out — see app/core/html_sanitize.py.
"""

from __future__ import annotations

import asyncio
import io
import mimetypes
import posixpath
import re
import uuid
import zipfile
from dataclasses import dataclass, field
from typing import Callable
from urllib.parse import unquote, urlsplit

from lxml import etree
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.html_sanitize import SanitizeReport, clean_css, parse_document, sanitize_document, serialize_document
from app.models.upload import UploadedFile
from app.routers.uploads import UPLOAD_ROOT, _safe_filename, _write_sync

MAX_UPLOAD_BYTES = 25 * 1024 * 1024
MAX_UNPACKED_BYTES = 100 * 1024 * 1024
MAX_ENTRIES = 500
MAX_HTML_FILES = 50
MAX_ASSET_BYTES = 15 * 1024 * 1024

_HTML_EXT = (".html", ".htm")
_SKIP_EXT = (".js", ".mjs", ".map", ".php", ".asp", ".aspx", ".jsp", ".exe", ".sh", ".bat", ".py")
_EXTRA_TYPES = {
    ".woff2": "font/woff2", ".woff": "font/woff", ".ttf": "font/ttf", ".otf": "font/otf", ".eot": "application/vnd.ms-fontobject",
    ".svg": "image/svg+xml", ".webp": "image/webp", ".avif": "image/avif", ".ico": "image/x-icon", ".css": "text/css",
    ".mp4": "video/mp4", ".webm": "video/webm", ".mp3": "audio/mpeg", ".pdf": "application/pdf",
}
_CSS_URL = re.compile(r"url\(\s*(['\"]?)([^'\")]+?)\1\s*\)", re.I)
_CSS_IMPORT = re.compile(r"@import\s+(?:url\(\s*)?(['\"])([^'\"]+)\1\s*\)?\s*([^;]*);", re.I)
_URL_ATTRS = ("src", "href", "poster", "data", "background")


class ImportError_(ValueError):
    """The upload can't be imported — the message says why."""


@dataclass
class Bundle:
    html: dict[str, str] = field(default_factory=dict)  # path -> markup
    files: dict[str, bytes] = field(default_factory=dict)  # path -> bytes (css included)
    skipped_scripts: int = 0
    main: str = ""


def _decode(data: bytes) -> str:
    for encoding in ("utf-8-sig", "utf-8"):
        try:
            return data.decode(encoding)
        except UnicodeDecodeError:
            continue
    return data.decode("latin-1")


def read_upload(filename: str, data: bytes) -> Bundle:
    if len(data) > MAX_UPLOAD_BYTES:
        raise ImportError_("That file is over 25 MB.")
    bundle = Bundle()
    name = (filename or "page.html").lower()
    if not name.endswith(".zip"):
        if len(data) > 5 * 1024 * 1024:
            raise ImportError_("That HTML file is over 5 MB.")
        bundle.html["index.html"] = _decode(data)
        bundle.main = "index.html"
        return bundle
    try:
        archive = zipfile.ZipFile(io.BytesIO(data))
    except zipfile.BadZipFile as exc:
        raise ImportError_("That isn't a readable .zip file.") from exc
    entries = [i for i in archive.infolist() if not i.is_dir()]
    if len(entries) > MAX_ENTRIES:
        raise ImportError_(f"The .zip holds more than {MAX_ENTRIES} files.")
    if sum(i.file_size for i in entries) > MAX_UNPACKED_BYTES:
        raise ImportError_("The .zip unpacks to more than 100 MB.")
    for info in entries:
        path = posixpath.normpath(info.filename.replace("\\", "/")).lstrip("/")
        parts = path.split("/")
        if path.startswith("..") or "__MACOSX" in parts or any(p.startswith(".") for p in parts):
            continue
        lower = path.lower()
        if lower.endswith(_SKIP_EXT):
            bundle.skipped_scripts += lower.endswith((".js", ".mjs"))
            continue
        if info.file_size > MAX_ASSET_BYTES:
            continue
        raw = archive.read(info)
        if lower.endswith(_HTML_EXT):
            if len(bundle.html) >= MAX_HTML_FILES:
                continue
            bundle.html[path] = _decode(raw)
        else:
            bundle.files[path] = raw
    if not bundle.html:
        raise ImportError_("The .zip has no .html file in it.")
    # The main page: an index.html nearest the top, else the shallowest page.
    ranked = sorted(bundle.html, key=lambda p: (p.count("/"), posixpath.basename(p).lower() not in ("index.html", "index.htm"), p))
    bundle.main = ranked[0]
    return bundle


def _resolve(base_dir: str, ref: str, known: Callable[[str], bool]) -> tuple[str, str] | None:
    """(bundle path, "#fragment") for a relative reference to a file in the
    bundle, else None (absolute, external, or not in the bundle)."""
    ref = (ref or "").strip()
    if not ref or ref.startswith(("#", "data:", "mailto:", "tel:", "javascript:", "//", "ff-page:")):
        return None
    parts = urlsplit(ref)
    if parts.scheme or parts.netloc:
        return None
    raw_path = unquote(parts.path)
    if not raw_path:
        return None
    joined = posixpath.normpath(posixpath.join(base_dir, raw_path) if not raw_path.startswith("/") else raw_path.lstrip("/"))
    if joined.startswith(".."):
        return None
    fragment = f"#{parts.fragment}" if parts.fragment else ""
    if known(joined):
        return joined, fragment
    # "about/" -> "about/index.html"
    for candidate in (posixpath.join(joined, "index.html"), joined + ".html"):
        if known(candidate):
            return candidate, fragment
    return None


@dataclass
class _Context:
    bundle: Bundle
    page_ids: dict[str, uuid.UUID]
    asset_url: Callable[[str], str]
    missing: set[str] = field(default_factory=set)

    def known(self, path: str) -> bool:
        return path in self.bundle.files or path in self.bundle.html

    def url_for(self, base_dir: str, ref: str) -> str | None:
        found = _resolve(base_dir, ref, self.known)
        if found is None:
            stripped = (ref or "").strip()
            if (
                stripped
                and not urlsplit(stripped).scheme
                and not stripped.startswith(("#", "//", "/", "data:", "ff-page:"))
                # Scripts are left out on purpose, not missing.
                and not urlsplit(stripped).path.lower().endswith(_SKIP_EXT)
            ):
                self.missing.add(stripped)
            return None
        path, fragment = found
        if path in self.bundle.html:
            return f"ff-page:{self.page_ids[path]}{fragment}"
        return self.asset_url(path) + fragment

    def css(self, css: str, base_dir: str, depth: int = 0) -> str:
        def imported(match: re.Match) -> str:
            ref, media = match.group(2), match.group(3).strip()
            found = _resolve(base_dir, ref, self.known)
            if found is None or depth >= 3 or not found[0].lower().endswith(".css"):
                url = self.url_for(base_dir, ref)
                return f'@import url("{url}"){(" " + media) if media else ""};' if url else match.group(0)
            inner = self.css(_decode(self.bundle.files[found[0]]), posixpath.dirname(found[0]), depth + 1)
            return f"@media {media} {{\n{inner}\n}}" if media else inner

        css = _CSS_IMPORT.sub(imported, css)

        def url(match: re.Match) -> str:
            new = self.url_for(base_dir, match.group(2))
            return f'url("{new}")' if new else match.group(0)

        return clean_css(_CSS_URL.sub(url, css))


def _rewrite_html(markup: str, path: str, ctx: _Context) -> tuple[str, str | None]:
    """The page's markup with every bundle reference rewritten, and its <title>."""
    base_dir = posixpath.dirname(path)
    doc = parse_document(markup)
    for el in list(doc.iter()):
        if not isinstance(el.tag, str):
            continue
        tag = el.tag.lower()
        # Images that wait for a script to load them get their real source now.
        for lazy in ("data-src", "data-lazy-src", "data-original"):
            if el.get(lazy) and tag in ("img", "source", "iframe", "video"):
                el.set("src", el.get(lazy))
        if el.get("data-srcset") and tag in ("img", "source"):
            el.set("srcset", el.get("data-srcset"))
        if el.get("data-bg"):
            el.set("style", f"{el.get('style') or ''};background-image:url('{el.get('data-bg')}')")
        if tag == "link" and "stylesheet" in (el.get("rel") or "").lower():
            found = _resolve(base_dir, el.get("href") or "", ctx.known)
            if found is not None and found[0].lower().endswith(".css"):
                style = etree.Element("style")
                media = el.get("media")
                if media:
                    style.set("media", media)
                style.text = ctx.css(_decode(ctx.bundle.files[found[0]]), posixpath.dirname(found[0]))
                el.addprevious(style)
                el.getparent().remove(el)
                continue
        for attr in _URL_ATTRS:
            value = el.get(attr)
            if value:
                new = ctx.url_for(base_dir, value)
                if new:
                    el.set(attr, new)
        for attr in ("srcset", "imagesrcset"):
            value = el.get(attr)
            if value:
                rewritten = []
                for part in value.split(","):
                    bits = part.strip().split(None, 1)
                    if not bits:
                        continue
                    new = ctx.url_for(base_dir, bits[0])
                    rewritten.append(" ".join([new or bits[0], *bits[1:]]))
                el.set(attr, ", ".join(rewritten))
        if el.get("style"):
            el.set("style", ctx.css(el.get("style") or "", base_dir))
        if tag == "style" and el.text:
            el.text = ctx.css(el.text, base_dir)
    title_el = doc.find(".//title")
    title = (title_el.text_content() if title_el is not None else "").strip() or None
    return serialize_document(doc), title


def content_type_for(path: str) -> str:
    ext = posixpath.splitext(path)[1].lower()
    return _EXTRA_TYPES.get(ext) or mimetypes.guess_type(path)[0] or "application/octet-stream"


def public_file_url(tenant_id: uuid.UUID, file_id: uuid.UUID, filename: str) -> str:
    return f"/site/_files/{tenant_id}/{file_id}/{_safe_filename(filename)}"


async def store_public_file(db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID | None, filename: str, data: bytes, content_type: str) -> UploadedFile:
    file_id = uuid.uuid4()
    storage_path = UPLOAD_ROOT / str(tenant_id) / f"{file_id}_{_safe_filename(filename)}"
    await asyncio.to_thread(_write_sync, storage_path, data)
    record = UploadedFile(
        id=file_id,
        tenant_id=tenant_id,
        filename=filename[:255] or "file",
        content_type=content_type[:127],
        size_bytes=len(data),
        storage_path=str(storage_path),
        is_public=True,
        created_by=user_id,
    )
    db.add(record)
    await db.flush()
    return record


@dataclass
class ImportedPage:
    path: str
    page_id: uuid.UUID
    title: str | None
    document: str


@dataclass
class ImportOutcome:
    pages: list[ImportedPage]
    assets: int
    notes: list[str]


async def import_bundle(
    db: AsyncSession,
    tenant_id: uuid.UUID,
    user_id: uuid.UUID | None,
    bundle: Bundle,
    page_ids: dict[str, uuid.UUID],
) -> ImportOutcome:
    """Each bundle page's sanitized document (main page first), with the
    files it uses stored. page_ids: the id each .html path will get."""
    # Pass 1 finds which files the pages use; only those are stored.
    used: set[str] = set()

    def note_use(path: str) -> str:
        used.add(path)
        return "about:blank"

    probe = _Context(bundle, page_ids, note_use)
    for path, markup in bundle.html.items():
        _rewrite_html(markup, path, probe)

    urls: dict[str, str] = {}
    for path in sorted(used):
        if path not in bundle.files:
            continue
        name = posixpath.basename(path) or "file"
        record = await store_public_file(db, tenant_id, user_id, name, bundle.files[path], content_type_for(path))
        urls[path] = public_file_url(tenant_id, record.id, name)

    ctx = _Context(bundle, page_ids, lambda p: urls.get(p, "about:blank"))
    report = SanitizeReport()
    pages: list[ImportedPage] = []
    order = [bundle.main] + sorted(p for p in bundle.html if p != bundle.main)
    for path in order:
        rewritten, title = _rewrite_html(bundle.html[path], path, ctx)
        pages.append(ImportedPage(path=path, page_id=page_ids[path], title=title, document=sanitize_document(rewritten, report)))

    notes = report.messages()
    if bundle.skipped_scripts:
        notes.append(f"Left out {bundle.skipped_scripts} JavaScript file{'s' if bundle.skipped_scripts != 1 else ''} — menus, sliders and other effects that need scripts won't move.")
    missing = sorted(probe.missing)
    if missing:
        sample = ", ".join(missing[:4]) + ("…" if len(missing) > 4 else "")
        what = "1 image or other file the page uses wasn't" if len(missing) == 1 else f"{len(missing)} images or other files the pages use weren't"
        notes.append(f"{what} included ({sample}). Upload a .zip of the whole site folder to bring them along.")
    return ImportOutcome(pages=pages, assets=len(urls), notes=notes)


def slug_from_path(path: str, root: str = "") -> str:
    """about/team.html -> about-team; index.html -> home. root: the site's
    own folder inside the .zip (the main page's), left out of the address."""
    if root and path.startswith(root.rstrip("/") + "/"):
        path = path[len(root.rstrip("/")) + 1:]
    stem = posixpath.splitext(path)[0]
    if posixpath.basename(stem).lower() == "index":
        stem = posixpath.dirname(stem) or "home"
    slug = re.sub(r"[^a-z0-9]+", "-", stem.lower()).strip("-")
    return slug[:100] or "page"


def slugify(value: str) -> str:
    slug = re.sub(r"[^a-z0-9]+", "-", (value or "").lower()).strip("-")
    return slug[:100] or "page"
