#!/usr/bin/env python3
##
# @file build_assets.py
# @brief Deterministic web-asset generator: src/web/input/* -> src/network_drivers/application/<type>.{h,cpp}.
# @details Each file under src/web/input is an editable source document (HTML, CSS,
#          JSON, XML, SVG, JS). Its base name is the C identifier it backs (e.g.
#          DETWS_PROV_FORM.html -> `extern const char DETWS_PROV_FORM[];`) and its
#          extension selects the per-type output header (html.h, css.h, ...). This
#          script emits the documents as C string literals into the application
#          layer so the firmware ships them in flash with no filesystem or heap.
#
#          The output is deterministic and committed (Arduino-IDE users do not run
#          Python); run `--check` in CI to fail when the generated files drift from
#          the input. Per-file directives (consumed, never served):
#            <!--#brief TEXT-->   doxygen @brief for the declaration
#            <!--#theme NAME-->   inject src/web/themes/NAME.css before </head>
#            <!--#minify-->       collapse markup whitespace (script/style preserved)
#          (CSS/JS/JSON use /*#brief ...*/ etc.)
#
# @author Douglas Quigg (dstroy0, dquigg123@gmail.com)
# @date June 2026
#

import argparse
import hashlib
import os
import re
import sys

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
INPUT_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "input"))
THEMES_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "themes"))
OUT_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "network_drivers", "application"))

# Extension -> output type (header/source base name).
EXT_TYPE = {".html": "html", ".css": "css", ".json": "json", ".xml": "xml", ".svg": "svg", ".js": "js", ".txt": "text"}

BANNER = (
    "// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>\n"
    "// SPDX-License-Identifier: AGPL-3.0-or-later\n"
    "//\n"
    "// GENERATED by src/web/wizard/build_assets.py - DO NOT EDIT BY HAND.\n"
    "// Edit the source documents under src/web/input/ and re-run the generator."
)

# ---------------------------------------------------------------------------
# Directive handling (HTML uses <!--#cmd arg-->, others use /*#cmd arg*/)
# ---------------------------------------------------------------------------

KNOWN_DIRECTIVES = {"brief", "theme", "minify"}


def directive_pattern(asset_type):
    # A trailing newline is consumed too, so a directive on its own line leaves no
    # blank line behind (the served bytes stay exactly the authored content).
    if asset_type in ("css", "js"):
        return re.compile(r"/\*#(\w+)(?:[ \t]+(.*?))?\*/\n?", re.DOTALL)
    return re.compile(r"<!--#(\w+)(?:[ \t]+(.*?))?-->\n?", re.DOTALL)


def extract_directives(text, asset_type, warn):
    """Pull directives out of the document; return (clean_text, {cmd: arg})."""
    directives = {}
    pat = directive_pattern(asset_type)
    for m in pat.finditer(text):
        cmd = m.group(1).lower()
        arg = (m.group(2) or "").strip()
        if cmd not in KNOWN_DIRECTIVES:
            warn(f"unknown directive #{cmd}")
        directives[cmd] = arg
    clean = pat.sub("", text)
    return clean, directives


# ---------------------------------------------------------------------------
# Optional transforms (opt-in via directives)
# ---------------------------------------------------------------------------


def minify_css(css):
    css = re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL)  # comments
    css = re.sub(r"\s+", " ", css)
    css = re.sub(r"\s*([{}:;,>])\s*", r"\1", css)
    return css.strip()


def inject_theme(html, name, warn):
    path = os.path.join(THEMES_DIR, name + ".css")
    if not os.path.isfile(path):
        warn(f"theme '{name}' not found at {path}")
        return html
    with open(path, "r", encoding="utf-8") as f:
        css = minify_css(f.read())
    block = "<style>" + css + "</style>"
    if "</head>" in html:
        return html.replace("</head>", block + "</head>", 1)
    return block + html


def minify_js(js):
    """Collapse the layout whitespace of inline JS *safely*: strip each line's leading indentation
    and trailing whitespace, and drop blank lines. Newlines and every token are preserved, so
    semantics - including automatic semicolon insertion - are unchanged. Deliberately conservative
    because these documents are user-editable; it removes pretty-print bloat without risking a
    user's JS (no comment stripping, no token joining across lines)."""
    return "\n".join(s for s in (ln.strip() for ln in js.split("\n")) if s)


def minify_html(html):
    """Collapse whitespace in the markup, the CSS in <style>, and (safely) the JS in <script>, so
    the source document can stay human-readable while the served body is compact."""
    parts = re.split(r"(<script\b[^>]*>.*?</script>|<style\b[^>]*>.*?</style>)", html, flags=re.DOTALL | re.IGNORECASE)
    out = []
    for i, part in enumerate(parts):
        if i % 2 == 1:  # a <script> or <style> block
            m = re.match(r"(<script\b[^>]*>)(.*)(</script>)", part, flags=re.DOTALL | re.IGNORECASE)
            if m:
                out.append(m.group(1) + minify_js(m.group(2)) + m.group(3))
                continue
            m = re.match(r"(<style\b[^>]*>)(.*)(</style>)", part, flags=re.DOTALL | re.IGNORECASE)
            if m:
                out.append(m.group(1) + minify_css(m.group(2)) + m.group(3))
                continue
            out.append(part)
            continue
        part = re.sub(r"<!--(?!\s*#).*?-->", "", part, flags=re.DOTALL)  # drop non-directive comments
        part = re.sub(r">\s+<", "><", part)
        part = re.sub(r"\s{2,}", " ", part)
        out.append(part)
    return "".join(out).strip()


# Collapse a pretty-printed JSON document to the wire form: drop every whitespace
# character that sits OUTSIDE a string literal (newlines, indentation, the space
# after a colon, ...). Whitespace is never significant outside a JSON string, and
# whitespace inside a string is preserved verbatim - so the source file can stay
# readable while the served body carries no layout whitespace.
def minify_json(text):
    out = []
    in_str = esc = False
    for ch in text:
        if in_str:
            out.append(ch)
            if esc:
                esc = False
            elif ch == "\\":
                esc = True
            elif ch == '"':
                in_str = False
        elif ch == '"':
            in_str = True
            out.append(ch)
        elif ch in " \t\r\n":
            continue  # layout whitespace outside a string - drop it
        else:
            out.append(ch)
    return "".join(out)


def apply_transforms(text, directives, asset_type, warn):
    if "theme" in directives and asset_type == "html":
        text = inject_theme(text, directives["theme"], warn)
    if asset_type == "json":
        text = minify_json(text)  # source may be pretty; the wire body is compact
    if "minify" in directives:
        if asset_type == "html":
            text = minify_html(text)
        elif asset_type == "css":
            text = minify_css(text)
    return text


# ---------------------------------------------------------------------------
# C string-literal encode / decode
# ---------------------------------------------------------------------------

_SIMPLE = {"\\": "\\\\", '"': '\\"', "\n": "\\n", "\r": "\\r", "\t": "\\t"}


def escape_segments(text, width=110):
    """Escape @p text into one or more C string-literal segments (concatenated by
    the compiler), wrapping near @p width and never splitting a character/escape."""
    segs, cur = [], ""
    for ch in text:
        if ch in _SIMPLE:
            tok = _SIMPLE[ch]
        elif ord(ch) < 0x20:
            tok = '\\x%02x""' % ord(ch)  # close+reopen so a following hex digit cannot extend the escape
        else:
            tok = ch  # printable ASCII and raw UTF-8 pass through
        if len(cur) + len(tok) > width and cur:
            segs.append(cur)
            cur = ""
        cur += tok
    if cur or not segs:
        segs.append(cur)
    return segs


_C_ESCAPES = {
    "n": "\n",
    "r": "\r",
    "t": "\t",
    '"': '"',
    "\\": "\\",
    "0": "\0",
    "a": "\a",
    "b": "\b",
    "f": "\f",
    "v": "\v",
}


def iter_char_arrays(src):
    """Yield (name, body) for each `const char NAME[] = <literals>;`, finding the
    terminating ';' with string-literal awareness (a ';' inside a literal, e.g. a
    CSS rule, does not end the statement)."""
    for m in re.finditer(r"const char (\w+)\[\]\s*=", src):
        i, n = m.end(), len(src)
        in_str = False
        start = i
        while i < n:
            c = src[i]
            if in_str:
                if c == "\\":
                    i += 2
                    continue
                if c == '"':
                    in_str = False
            else:
                if c == '"':
                    in_str = True
                elif c == ";":
                    yield m.group(1), src[start:i]
                    break
            i += 1


def decode_c_literals(blob):
    """Decode concatenated C string literals into raw bytes."""
    out = bytearray()
    i, n = 0, len(blob)
    while i < n:
        if blob[i] != '"':
            i += 1
            continue
        i += 1
        while i < n and blob[i] != '"':
            c = blob[i]
            if c == "\\":
                nxt = blob[i + 1]
                if nxt == "x":
                    j = i + 2
                    while j < n and blob[j] in "0123456789abcdefABCDEF":
                        j += 1
                    out.append(int(blob[i + 2 : j], 16) & 0xFF)
                    i = j
                    continue
                out.extend(_C_ESCAPES.get(nxt, nxt).encode("latin-1"))
                i += 2
            else:
                out.extend(c.encode("utf-8"))
                i += 1
        i += 1  # skip closing quote
    return bytes(out)


# ---------------------------------------------------------------------------
# Lint (advisory)
# ---------------------------------------------------------------------------


def lint(symbol, text, asset_type, warn):
    if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", symbol):
        warn(f"'{symbol}' is not a valid C identifier")
    if asset_type in ("html", "svg", "xml"):
        for tag in ("script", "style"):
            o = len(re.findall(r"<%s\b" % tag, text, re.IGNORECASE))
            c = len(re.findall(r"</%s>" % tag, text, re.IGNORECASE))
            if o != c:
                warn(f"unbalanced <{tag}> ({o} open / {c} close)")
    if asset_type == "json" and "{{" not in text:  # a {{name}} template is not literal JSON
        try:
            import json

            json.loads(text)
        except Exception as e:
            warn(f"invalid JSON: {e}")


# ---------------------------------------------------------------------------
# Asset model
# ---------------------------------------------------------------------------


class Asset:
    def __init__(self, symbol, asset_type, served, brief):
        self.symbol = symbol
        self.type = asset_type
        self.served = served  # the exact bytes the firmware serves (a str)
        self.brief = brief


def load_assets():
    assets = []
    if not os.path.isdir(INPUT_DIR):
        return assets
    for name in sorted(os.listdir(INPUT_DIR)):
        path = os.path.join(INPUT_DIR, name)
        if not os.path.isfile(path):
            continue
        symbol, ext = os.path.splitext(name)
        ext = ext.lower()
        if ext not in EXT_TYPE:
            continue
        asset_type = EXT_TYPE[ext]
        with open(path, "r", encoding="utf-8") as f:
            text = f.read()

        warnings = []
        warn = lambda m, _n=name: warnings.append(f"[{_n}] {m}")
        text, directives = extract_directives(text, asset_type, warn)
        served = apply_transforms(text, directives, asset_type, warn)
        lint(symbol, served, asset_type, warn)
        for w in warnings:
            print("warning: " + w, file=sys.stderr)
        assets.append(Asset(symbol, asset_type, served, directives.get("brief", "")))
    return assets


# ---------------------------------------------------------------------------
# Code generation
# ---------------------------------------------------------------------------


# All assets land in one translation unit (web_assets.{h,cpp}). The source file's
# extension only selects lint/transforms - it does NOT name an output file, so a
# document type can never collide with a module of the same name (e.g. the JSON
# codec at presentation/json/json.h).
ASSET_BASENAME = "web_assets"


def render_header(assets):
    guard = "DETERMINISTICESPASYNCWEBSERVER_WEB_ASSETS_H"
    lines = [
        BANNER,
        "",
        "/**",
        " * @file web_assets.h",
        " * @brief Layer 7 (Application) - embedded web assets generated from src/web/input/.",
        " *",
        " * One declaration per source document under src/web/input/ (its base name is the",
        " * C symbol). On ESP32 these const arrays live in flash (DROM), read directly - no",
        " * filesystem or heap. Edit src/web/input/ and re-run src/web/wizard/build_assets.py.",
        " */",
        "",
        "#ifndef " + guard,
        "#define " + guard,
        "",
    ]
    last_type = None
    for a in assets:
        if a.type != last_type:
            lines.append("// ---- %s ----" % a.type)
            last_type = a.type
        if a.brief:
            lines.append("/** @brief %s */" % a.brief)
        lines.append("extern const char %s[];" % a.symbol)
        lines.append("")
    lines.append("#endif // " + guard)
    return "\n".join(lines).rstrip("\n") + "\n"


def render_source(assets):
    lines = [BANNER, "", '#include "network_drivers/application/web_assets.h"', ""]
    last_type = None
    for a in assets:
        if a.type != last_type:
            lines.append("// ---- %s ----" % a.type)
            last_type = a.type
        segs = escape_segments(a.served)
        decl = "const char %s[] = " % a.symbol
        oneline = decl + '"' + segs[0] + '";'
        if len(segs) == 1 and len(oneline) <= 120:
            lines.append(oneline)  # clang-format keeps a short literal on the declaration line
        else:
            lines.append(decl.rstrip())
            lines.append('    "' + '"\n    "'.join(segs) + '";')
        lines.append("")
    return "\n".join(lines).rstrip("\n") + "\n"


def generate(write=True):
    assets = load_assets()
    assets.sort(key=lambda a: (a.type, a.symbol))  # stable, grouped output

    files = {
        os.path.join(OUT_DIR, ASSET_BASENAME + ".h"): render_header(assets),
        os.path.join(OUT_DIR, ASSET_BASENAME + ".cpp"): render_source(assets),
    }

    stale = []
    for path, content in files.items():
        existing = None
        if os.path.isfile(path):
            with open(path, "r", encoding="utf-8", newline="") as f:
                existing = f.read()
        if existing != content:
            stale.append(os.path.relpath(path))
            if write:
                with open(path, "w", encoding="utf-8", newline="\n") as f:
                    f.write(content)
    return files, stale


# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------


def cmd_generate(_):
    _, stale = generate(write=True)
    if stale:
        print("regenerated: " + ", ".join(stale))
    else:
        print("up to date (no changes)")
    return 0


def cmd_check(_):
    _, stale = generate(write=False)
    if stale:
        print("STALE (run build_assets.py): " + ", ".join(stale), file=sys.stderr)
        return 1
    print("generated assets are in sync with src/web/input/")
    return 0


def cmd_hash(args):
    """Print NAME <sha256-of-served-bytes> for each symbol in a .cpp (faithfulness check)."""
    with open(args.file, "r", encoding="utf-8") as f:
        src = f.read()
    for name, body in iter_char_arrays(src):
        digest = hashlib.sha256(decode_c_literals(body)).hexdigest()
        print(f"{name} {digest}")
    return 0


def cmd_migrate(args):
    """Extract const char arrays from a .cpp into editable src/web/input/<NAME>.<ext> files."""
    with open(args.file, "r", encoding="utf-8") as f:
        src = f.read()
    os.makedirs(INPUT_DIR, exist_ok=True)
    for name, body in iter_char_arrays(src):
        data = decode_c_literals(body)
        out = os.path.join(INPUT_DIR, name + "." + args.ext)
        with open(out, "wb") as f:
            f.write(data)
        print(f"wrote {os.path.relpath(out)} ({len(data)} bytes)")
    return 0


def main():
    p = argparse.ArgumentParser(description="Generate embedded web assets from src/web/input/.")
    sub = p.add_subparsers(dest="cmd")
    sub.add_parser("generate", help="regenerate the application/*.{h,cpp} files (default)")
    sub.add_parser("check", help="fail if the generated files are stale (CI gate)")
    hp = sub.add_parser("hash", help="print sha256 of each served asset in a .cpp")
    hp.add_argument("file")
    mp = sub.add_parser("migrate", help="extract const char arrays from a .cpp into src/web/input/")
    mp.add_argument("file")
    mp.add_argument("--ext", default="html")
    args = p.parse_args()

    cmd = args.cmd or "generate"
    return {"generate": cmd_generate, "check": cmd_check, "hash": cmd_hash, "migrate": cmd_migrate}[cmd](args)


if __name__ == "__main__":
    sys.exit(main())
