"""Deterministic conversion of a static HTML site into Nuxt pages that render identically.

The problem this solves: a prototype from an AI tool (index.html, about.html, assets/css, assets/js ...) has
to become a real Nuxt app, and asking a model to retype eleven pages into components loses fidelity (or, as
seen in practice, the model serves the files as they are). This module does the mechanical part exactly.

A *faithful shell* per page:

- ``legacy/pages/<name>.html`` keeps the page's body markup verbatim, minus the ``<script>``/``<style>``
  elements, which are hoisted;
- ``pages/<name>.vue`` renders that markup (``?raw`` import + ``v-html``), turns the ``<head>`` into
  ``useHead`` and loads the body scripts, in their original order, once Vue has mounted (see below);
- links are not rewritten: a page is served at its original ``.html`` URL (``pages/about.html.vue`` ->
  ``/about.html``), because legacy scripts read the file name (``location.pathname.split('/').pop()``) to
  highlight the current link or hand off to ``index.html#apply``; the clean URL ``/about`` redirects to it.
  ``index.html`` is served at ``/`` (and ``/index.html``).

Why v-html and not a Vue template: compiling arbitrary hand-written HTML as a Vue template is fragile.
Vue turns relative asset URLs into imports (a build fails when an image is not uploaded yet), drops
``<script>``/``<style>``, treats ``{{ }}`` and ``v-``/``:``/``@`` attributes as directives, ignores inline
``on*`` handlers when rendering on the server, and hydration mismatches when a browser repairs the markup
(``<table>`` without ``<tbody>``). A string is parsed by the browser exactly like the original, so there is
nothing to mismatch. The result was verified against the original in a real browser (same layout to the pixel,
same script behavior); after that, the agent can turn any page into components one at a time, safely.

Scripts run after mount, never before: a legacy script changes the DOM (and often ``document.title``), and if it
runs before hydration Vue meets a DOM it did not render, and the head manager later puts the static title
back. Verified in a browser: the title a script sets stays, and the page behaves exactly like the original.

Standard library only. Binary assets (images, video) are not handled here: only text files are provided.
"""

from __future__ import annotations

import html as html_lib
import json
import re
from dataclasses import dataclass, field
from typing import Any

HEAD_TAGS = frozenset({"title", "meta", "link", "base", "script", "style", "noscript", "template"})
_RAW_TEXT = frozenset({"script", "style", "textarea", "title"})

_TAG_RE = re.compile(
    r"<(/?)([A-Za-z][A-Za-z0-9:_-]*)"
    r"((?:\s+[^\s\"'>/=]+(?:\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s\"'=<>`]+))?)*)"
    r"\s*(/?)>"
)
_ATTR_RE = re.compile(r"""([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?""")

_SAFE_NAME = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
_TEXT_ASSET_SUFFIXES = (".css", ".js", ".mjs", ".json", ".svg", ".txt", ".xml", ".webmanifest", ".map")
_ASSET_PREFIXES = ("assets/", "static/", "css/", "js/", "fonts/", "img/", "images/", "media/")


@dataclass
class Token:
    kind: str  # start | end | comment | doctype | text
    start: int
    end: int
    name: str = ""
    attrs: list[tuple[str, str | None]] = field(default_factory=list)
    inner: str = ""  # raw text of script/style/textarea/title
    inner_end: int = 0  # offset just after the closing tag of a raw-text element


def parse_attrs(raw: str) -> list[tuple[str, str | None]]:
    out: list[tuple[str, str | None]] = []
    for m in _ATTR_RE.finditer(raw or ""):
        value = m.group(2) if m.group(2) is not None else m.group(3) if m.group(3) is not None else m.group(4)
        out.append((m.group(1), html_lib.unescape(value) if value is not None else None))
    return out


def tokenize(text: str) -> list[Token]:
    """Split HTML into structural tokens with exact offsets (the markup is later sliced, never re-serialized)."""
    tokens: list[Token] = []
    i, n = 0, len(text)
    while i < n:
        lt = text.find("<", i)
        if lt == -1:
            tokens.append(Token("text", i, n))
            break
        if lt > i:
            tokens.append(Token("text", i, lt))
            i = lt
        if text.startswith("<!--", i):
            close = text.find("-->", i + 4)
            end = n if close == -1 else close + 3
            tokens.append(Token("comment", i, end))
            i = end
            continue
        if text.startswith("<!", i) or text.startswith("<?", i):
            close = text.find(">", i)
            end = n if close == -1 else close + 1
            tokens.append(Token("doctype", i, end))
            i = end
            continue
        m = _TAG_RE.match(text, i)
        if not m:  # a stray "<" is just text
            tokens.append(Token("text", i, i + 1))
            i += 1
            continue
        closing, name = m.group(1) == "/", m.group(2).lower()
        token = Token("end" if closing else "start", i, m.end(), name, [] if closing else parse_attrs(m.group(3)))
        if not closing and name in _RAW_TEXT and not m.group(4):
            close = re.compile(r"</" + re.escape(name) + r"\s*>", re.I).search(text, m.end())
            inner_end = close.start() if close else n
            token.inner = text[m.end():inner_end]
            token.inner_end = close.end() if close else n
            tokens.append(token)
            i = token.inner_end
            continue
        tokens.append(token)
        i = m.end()
    return tokens


@dataclass
class Page:
    """One converted page, before it is rendered to files."""

    name: str
    body_html: str
    html_attrs: dict[str, Any]
    body_attrs: dict[str, Any]
    head: dict[str, Any]
    body_scripts: list[dict[str, Any]]
    warnings: list[str] = field(default_factory=list)


def _attr_dict(attrs: list[tuple[str, str | None]]) -> dict[str, Any]:
    """unhead takes attributes as an object: a bare attribute (``defer``, ``crossorigin``) is ``true``/``""``."""
    out: dict[str, Any] = {}
    for key, value in attrs:
        if key.lower() == "class" and value is not None:
            out["class"] = " ".join(value.split())
        elif value is None:
            out[key] = True if key.lower() in {"defer", "async", "nomodule", "hidden"} else ""
        else:
            out[key] = value
    return out


def _script_entry(token: Token, position: str) -> dict[str, Any]:
    entry = _attr_dict(token.attrs)
    if "src" not in entry and token.inner.strip():
        entry["innerHTML"] = token.inner
    entry["tagPosition"] = position
    return entry


def convert_page(text: str, name: str) -> Page:
    """Split one HTML document into head data, body markup and scripts, without re-serializing the markup."""
    tokens = tokenize(text)
    warnings: list[str] = []
    html_attrs: dict[str, Any] = {}
    body_attrs: dict[str, Any] = {}
    head: dict[str, Any] = {"meta": [], "link": [], "script": [], "style": [], "noscript": []}
    body_scripts: list[dict[str, Any]] = []
    removals: list[tuple[int, int]] = []
    state = "pre"  # pre | head | body
    body_start: int | None = None
    body_end = len(text)
    i = 0
    while i < len(tokens):
        t = tokens[i]
        i += 1
        if state != "body":
            if t.kind in ("doctype", "comment"):
                continue
            if t.kind == "text":
                if t.start < t.end and text[t.start:t.end].strip():
                    state, body_start = "body", t.start
                continue
            if t.kind == "end":
                continue  # </head>, </html>
            if t.name == "html":
                html_attrs = _attr_dict(t.attrs)
                continue
            if t.name == "head":
                state = "head"
                continue
            if t.name == "body":
                body_attrs = _attr_dict(t.attrs)
                state, body_start = "body", t.end
                continue
            if t.name in HEAD_TAGS:
                if t.name == "title":
                    head["title"] = html_lib.unescape(t.inner).strip()
                elif t.name == "meta":
                    head["meta"].append(_attr_dict(t.attrs))
                elif t.name == "link":
                    head["link"].append(_attr_dict(t.attrs))
                elif t.name == "base":
                    head["base"] = _attr_dict(t.attrs)
                    warnings.append("<base> is passed on, but relative URLs are no longer resolved against it by Vue.")
                elif t.name == "script":
                    head["script"].append(_script_entry(t, "head"))
                elif t.name == "style":
                    head["style"].append({"innerHTML": t.inner})
                elif t.name == "noscript":
                    warnings.append("<noscript> in the head was dropped.")
                continue
            state, body_start = "body", t.start  # anything else starts the body (its tag was omitted)
            i -= 1
            continue
        # ---- in the body
        if t.kind == "end" and t.name in ("body", "html"):
            body_end = min(body_end, t.start)
            break
        if t.kind == "start" and t.name == "script":
            removals.append((t.start, t.inner_end or t.end))
            body_scripts.append(_script_entry(t, "bodyClose"))
        elif t.kind == "start" and t.name == "style":
            removals.append((t.start, t.inner_end or t.end))
            head["style"].append({"innerHTML": t.inner})
    if body_start is None:
        body_start, body_end = len(text), len(text)
        warnings.append("No body content was found.")
    pieces, cursor = [], body_start
    for start, end in sorted(removals):
        if start >= body_start:
            pieces.append(text[cursor:start])
            cursor = end
    pieces.append(text[cursor:body_end])
    body_html = "".join(pieces).strip("\r\n")
    head = {k: v for k, v in head.items() if v}
    return Page(name, body_html, html_attrs, body_attrs, head, body_scripts, warnings)


def route_for(name: str) -> str:
    return "/" if name == "index" else f"/{name}"


def _js(value: Any, indent: int = 2) -> str:
    """JSON is valid JavaScript; keep ``</`` from ever closing the SFC ``<script>`` block."""
    return json.dumps(value, indent=indent, ensure_ascii=False).replace("</", "<\\/")


LEGACY_SCRIPTS_COMPOSABLE_PATH = "composables/useLegacyScripts.ts"
LEGACY_SCRIPTS_COMPOSABLE = """// Loads a converted page's original <script> tags, in order, after Vue has mounted the page.
// Legacy scripts change the DOM and document.title: run before hydration they meet a DOM Vue did not render, and
// the head manager puts the static title back. The converted pages are reached by normal links (full page loads),
// like the prototype, so a script is loaded once per page load; already loaded sources are skipped.
// A resource that failed BEFORE the scripts attached their listeners would never report it (in the original the
// scripts run while the page is still being parsed), so those errors are replayed once, afterwards.
function replayMissedResourceErrors() {
  document.querySelectorAll('video, audio, img').forEach((el) => {
    if (el.hasAttribute('onerror')) return // its own handler already ran, on time
    const failed = el instanceof HTMLMediaElement
      ? !!el.error
      : el instanceof HTMLImageElement && el.complete && el.naturalWidth === 0 && !!el.currentSrc
    if (!failed) return
    el.querySelectorAll('source').forEach(source => source.dispatchEvent(new Event('error')))
    el.dispatchEvent(new Event('error'))
  })
}

export function useLegacyScripts(scripts: Array<Record<string, unknown>>) {
  onMounted(async () => {
    const loaded = ((window as any).__p2hsLegacyScripts ||= new Set<string>()) as Set<string>
    for (const spec of scripts) {
      const src = typeof spec.src === 'string' ? spec.src : ''
      if (src && loaded.has(src)) continue
      await new Promise<void>((resolve) => {
        const el = document.createElement('script')
        for (const [key, value] of Object.entries(spec)) {
          if (key === 'tagPosition' || key === 'innerHTML' || value === false || value == null) continue
          el.setAttribute(key, value === true ? '' : String(value))
        }
        if (spec.innerHTML) {
          el.textContent = String(spec.innerHTML)
          document.body.appendChild(el)
          return resolve()
        }
        el.onload = () => resolve()
        el.onerror = () => resolve()
        if (src) loaded.add(src)
        document.body.appendChild(el)
      })
    }
    replayMissedResourceErrors()
  })
}
"""


def render_vue_page(page: Page) -> str:
    head: dict[str, Any] = dict(page.head)
    if page.html_attrs:
        head["htmlAttrs"] = page.html_attrs
    if page.body_attrs:
        head["bodyAttrs"] = page.body_attrs
    is_index = page.name == "index"
    meta = "{ layout: false, alias: [\"/index.html\"] }" if is_index else "{ layout: false }"
    lines = [
        "<script setup lang=\"ts\">",
        f"// Converted from {page.name}.html by P2HS. The markup is the original, untouched: see the file below.",
        "// Turn parts of it into components one at a time; the page must keep rendering the same.",
        f"import html from '~~/legacy/pages/{page.name}.html?raw'",
        "",
        "// layout: false -- the prototype carries its own header and footer; a project layout would wrap them twice.",
        f"definePageMeta({meta})",
        "",
        f"useHead({_js(head)})",
    ]
    if page.body_scripts:
        lines += [
            "",
            "// The page's original <script> tags, in order, after mount (see composables/useLegacyScripts.ts).",
            f"useLegacyScripts({_js(page.body_scripts)})",
        ]
    lines += [
        "</script>",
        "",
        "<template>",
        "  <div style=\"display: contents\" v-html=\"html\" />",
        "</template>",
        "",
    ]
    return "\n".join(lines)

def render_redirect_page(name: str) -> str:
    """``/about`` -> ``/about.html``: one canonical URL, and the page's scripts always see a file name."""
    return "\n".join([
        "<script setup lang=\"ts\">",
        f"definePageMeta({{ layout: false, redirect: '/{name}.html' }})",
        "</script>",
        "",
        "<template><div /></template>",
        "",
    ])

@dataclass
class SiteConversion:
    files: dict[str, str] = field(default_factory=dict)
    pages: list[dict[str, Any]] = field(default_factory=list)
    skipped: list[dict[str, str]] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)

    @property
    def converted(self) -> bool:
        return bool(self.pages)


def _is_text_asset(path: str) -> bool:
    lowered = path.lower()
    return lowered.endswith(_TEXT_ASSET_SUFFIXES) and (
        lowered.startswith(_ASSET_PREFIXES) or "/" not in lowered
    )


def convert_static_site(
    files: dict[str, str], *, existing_routes: frozenset[str] | set[str] = frozenset()
) -> SiteConversion:
    """Convert the root-level ``*.html`` pages of a static site and carry over its text assets.

    Pages in sub-folders, unsafe names and routes that already exist in the project are reported, not converted:
    relative URLs in a nested page would resolve against a different folder, and an existing route belongs to
    the project."""
    out = SiteConversion()
    taken = set(existing_routes)
    for path in sorted(files):
        if not path.lower().endswith((".html", ".htm")):
            continue
        stem = path.rsplit(".", 1)[0]
        if "/" in path:
            out.skipped.append({"path": path, "reason": "nested page: only root-level pages are converted"})
            continue
        if not _SAFE_NAME.fullmatch(stem.lower()) or stem != stem.lower():
            out.skipped.append({"path": path, "reason": "name is not a safe route name"})
            continue
        route = route_for(stem)
        html_route = "/" if stem == "index" else f"/{stem}.html"
        if route in taken or html_route in taken:
            out.skipped.append({"path": path, "reason": f"the project already has a {route} route"})
            continue
        page = convert_page(files[path], stem)
        taken.update({route, html_route})
        out.files[f"legacy/pages/{stem}.html"] = page.body_html + "\n"
        if stem == "index":
            out.files["pages/index.vue"] = render_vue_page(page)
        else:
            out.files[f"pages/{stem}.html.vue"] = render_vue_page(page)
            out.files[f"pages/{stem}.vue"] = render_redirect_page(stem)
        out.pages.append(
            {"source": path, "route": html_route, "redirect_from": None if stem == "index" else route,
             "alias": "/index.html" if stem == "index" else None, "scripts": len(page.body_scripts),
             "warnings": page.warnings}
        )
        out.warnings.extend(f"{path}: {w}" for w in page.warnings)
    if any(p["scripts"] for p in out.pages):
        out.files[LEGACY_SCRIPTS_COMPOSABLE_PATH] = LEGACY_SCRIPTS_COMPOSABLE
    for path in sorted(files):
        if _is_text_asset(path) and path not in out.files:
            out.files[f"public/{path}"] = files[path]
    return out
