"""Widget-spec linting — a faithful port of ``research_tools/mixw_lint.py``.

The builder (:mod:`mixlar.widgets`) is always spec-correct by construction, but
hand-written or third-party ``widget.json`` files aren't, and drift between this
schema and what the firmware (``custom_widget.ino``) actually parses is exactly
the bug class this catches: an unknown element type or field silently renders
wrong (or not at all) on a device you can't easily debug. This module returns
structured data instead of printing, so both the CLI and :meth:`Widget.lint`
can use it.
"""
from __future__ import annotations

import json
import os
import re
from typing import List, Optional, Tuple, Union

from .protocol import CANVAS_W, CANVAS_H, MAX_ELEMENTS
from .manifest import ValidationReport, validate_package

# Fields the mixw spec/firmware accept per WIDGETS.md + the parser. Unknown
# fields are WARNINGS (firmware ignores them); unknown element `t` values are
# ERRORS. Copied verbatim from mixw_lint.py — keep in sync with it.
KNOWN_FIELDS = {
    "t", "x", "y", "w", "h", "s", "c", "a", "r", "o",
    "bg", "bgc", "kc", "tc", "offc", "d",
    "bind", "fmt", "cbind", "obind", "show", "id", "macro", "page",
    "min", "max", "val", "pulse", "sym", "text", "src", "scroll",
    "a0", "a1", "rot", "ls", "lc", "hold",
    # mixw 2: chart + draw fields
    "series", "data", "kind", "auto", "gridc", "w2", "fill", "ops",
}
KNOWN_TOP = {"mixw", "name", "version", "bg", "elements", "pages", "menu",
             "layout"}
# mixw 2 element types the host renderer adds beyond firmware v1. On v1-only
# firmware these are skipped, so they WARN rather than ERROR under mixw:2.
MIXW2_TYPES = {"chart", "draw"}

# Used when the firmware source isn't available (third-party authors won't
# have a copy of the repo). Kept in sync with custom_widget.ino's strcmp list.
FALLBACK_TYPES = {
    "label", "bar", "arc", "img", "panel", "led", "line", "qr",
    "btn", "slider", "toggle",
}

_COLOR_FIELDS = ("c", "bg", "bgc", "kc", "tc", "offc", "lc")
_TYPE_RE = re.compile(r'strcmp\(\s*t\s*,\s*"([a-z]+)"\s*\)')


def _find_repo_root(start: Optional[str] = None) -> Optional[str]:
    """Walk up from ``start`` (or cwd) looking for ``Firmware/firmware_demo``."""
    path = os.path.abspath(start or os.getcwd())
    while True:
        candidate = os.path.join(path, "Firmware", "firmware_demo",
                                 "custom_widget.ino")
        if os.path.isfile(candidate):
            return path
        parent = os.path.dirname(path)
        if parent == path:
            return None
        path = parent


def firmware_types(repo_root: Optional[str] = None) -> Optional[set]:
    """Authoritative element types = every ``strcmp(t, "X")`` in the firmware.

    Reads ``Firmware/firmware_demo/custom_widget.ino`` under ``repo_root``
    (auto-detected by walking up from cwd when omitted). Returns ``None`` when
    the source can't be found/read — callers should fall back to
    :data:`FALLBACK_TYPES`.
    """
    root = repo_root or _find_repo_root()
    if not root:
        return None
    fw = os.path.join(root, "Firmware", "firmware_demo", "custom_widget.ino")
    try:
        with open(fw, encoding="utf-8", errors="ignore") as f:
            src = f.read()
    except OSError:
        return None
    return set(_TYPE_RE.findall(src))


def _iter_elements(doc: dict):
    """Yield (page_name, element) for single-page or multi-page widgets."""
    if isinstance(doc.get("pages"), list):
        for pg in doc["pages"]:
            name = pg.get("name", "?") if isinstance(pg, dict) else "?"
            for el in (pg.get("elements") or []):
                yield name, el
    for el in (doc.get("elements") or []):
        yield None, el


def lint_widget(source: Union[str, dict],
                types: Optional[set] = None) -> Tuple[List[str], List[str]]:
    """Lint a widget.json. ``source`` is a path or an already-loaded dict.

    Reproduces every check in ``mixw_lint.lint_file``: mixw version, unknown
    top-level keys, missing/unknown element type, unknown fields, non-hex
    colors, geometry overrun, missing ids on interactive elements, and the
    element budget. Returns ``(errors, warnings)``.
    """
    if isinstance(source, dict):
        doc = source
    else:
        with open(source, encoding="utf-8") as f:
            doc = json.load(f)

    if types is None:
        types = firmware_types() or FALLBACK_TYPES

    errs: List[str] = []
    warns: List[str] = []

    mv = doc.get("mixw")
    if mv not in (1, 2):
        errs.append(f'"mixw" must be 1 or 2 (got {mv!r})')
    v2 = (mv == 2)
    for k in doc:
        if k not in KNOWN_TOP:
            warns.append(f'unknown top-level key "{k}"')

    n = 0
    for page, el in _iter_elements(doc):
        n += 1
        if not isinstance(el, dict):
            errs.append("element is not an object")
            continue
        loc = f'[{page}] ' if page else ""
        t = el.get("t")
        if t is None:
            errs.append(f'{loc}element missing "t"')
        elif t in MIXW2_TYPES:
            if not v2:
                errs.append(f'{loc}"{t}" is a mixw 2 element but manifest is mixw:1')
            else:
                warns.append(f'{loc}"{t}" is mixw 2 (host/v2-firmware only; '
                             f'skipped on v1 firmware)')
        elif types is not None and t not in types:
            errs.append(f'{loc}unknown element type "{t}" (firmware won\'t render it)')
        for k, v in el.items():
            if k not in KNOWN_FIELDS:
                warns.append(f'{loc}element "{t}" has unknown field "{k}"')
        for cf in _COLOR_FIELDS:
            if cf in el and not re.fullmatch(r"[0-9A-Fa-f]{6}", str(el[cf])):
                errs.append(f'{loc}"{cf}"={el[cf]!r} is not 6-hex (no #)')
        try:
            x, y = int(el.get("x", 0)), int(el.get("y", 0))
            w, h = int(el.get("w", 0)), int(el.get("h", 0))
            if x < 0 or y < 0 or x + w > CANVAS_W or y + h > CANVAS_H:
                warns.append(f'{loc}"{t}" at ({x},{y},{w},{h}) overruns '
                             f'{CANVAS_W}x{CANVAS_H}')
        except (TypeError, ValueError):
            pass
        if t in ("btn", "toggle", "slider") and not el.get("id"):
            warns.append(f'{loc}interactive "{t}" has no "id" (no WEVENT will fire)')

    if n > MAX_ELEMENTS:
        errs.append(f"{n} elements exceeds the {MAX_ELEMENTS} budget")

    return errs, warns


def lint_package(pkg_dir: str,
                 app_version: Optional[str] = None) -> ValidationReport:
    """Manifest validation plus widget-body linting, folded into one report.

    Runs :func:`mixlar.manifest.validate_package` first, then lints every
    ``widgets/<id>/widget.json`` present on disk, prefixing messages with
    ``widget '<id>': `` so they're traceable back to their bundle.
    """
    rep = validate_package(pkg_dir, app_version)
    man = rep.manifest
    if not man:
        return rep

    types = firmware_types() or FALLBACK_TYPES
    for w in man.get("widgets", []):
        wid = w.get("id", "") if isinstance(w, dict) else ""
        wj = os.path.join(pkg_dir, "widgets", wid, "widget.json")
        if not os.path.isfile(wj):
            continue
        try:
            errs, warns = lint_widget(wj, types)
        except Exception as e:
            rep.error(f"widget '{wid}': widget.json unparseable: {e}")
            continue
        for e in errs:
            rep.error(f"widget '{wid}': {e}")
        for w_ in warns:
            rep.warn(f"widget '{wid}': {w_}")
    return rep


__all__ = [
    "KNOWN_FIELDS", "KNOWN_TOP", "CANVAS_W", "CANVAS_H", "MAX_ELEMENTS",
    "FALLBACK_TYPES", "firmware_types", "lint_widget", "lint_package",
]
