#!/usr/bin/env python3
"""Whole-theme audit for leona-dawn-theme.

Covers the structural checks plus every Shopify schema constraint the
push validator taught us this session — the ones a JSON parse cannot see.
"""
import json, re, sys
from pathlib import Path
from collections import defaultdict

D = Path(sys.argv[1] if len(sys.argv) > 1 else "/Users/abrarzahin/aurelia-theme")
SCHEMA = re.compile(r"{%-?\s*schema\s*-?%}(.*?){%-?\s*endschema\s*-?%}", re.S)
RENDER = re.compile(r"{%-?\s*(?:render|include)\s+'([^']+)'")
ASSET  = re.compile(r"'([a-zA-Z0-9._-]+\.(?:css|js|png|jpg|jpeg|svg|gif|woff2?))'\s*\|\s*asset_url")
TKEY   = re.compile(r'"t:([a-zA-Z0-9_.]+)"')

R = defaultdict(list)
liquid   = sorted(list(D.glob("sections/*.liquid")) + list(D.glob("snippets/*.liquid")) + list(D.glob("layout/*.liquid")))
snippets = {p.stem for p in D.glob("snippets/*.liquid")}
assets   = {p.name for p in D.glob("assets/*")}
sections = {p.stem for p in D.glob("sections/*.liquid")}

def load_json(p):
    return json.loads(re.sub(r"^\s*/\*.*?\*/", "", p.read_text(errors="ignore"), flags=re.S))

def schema_of(stype):
    f = D / "sections" / f"{stype}.liquid"
    if not f.exists(): return None
    m = SCHEMA.search(f.read_text(errors="ignore"))
    if not m: return None
    try: return json.loads(m.group(1))
    except Exception: return None

# ── structural ──────────────────────────────────────────────
for p in D.glob("sections/*.liquid"):
    t = p.read_text(errors="ignore")
    m = SCHEMA.search(t)
    if m:
        try: json.loads(m.group(1))
        except Exception as e: R["schema_invalid_json"].append(f"{p.name}: {str(e)[:60]}")
    # "#{{" is an anchor href plus a Liquid output — not Ruby interpolation
    if re.search(r"#\{(?!\{)", t):
        R["ruby_interpolation"].append(p.name)

for p in liquid:
    t = p.read_text(errors="ignore")
    for n in set(RENDER.findall(t)):
        if n not in snippets: R["missing_snippet"].append(f"{p.parent.name}/{p.name} -> '{n}'")
    for n in set(ASSET.findall(t)):
        if n not in assets: R["missing_asset"].append(f"{p.parent.name}/{p.name} -> '{n}'")

for p in D.glob("templates/*.json"):
    try: d = load_json(p)
    except Exception as e:
        R["template_invalid_json"].append(f"{p.name}: {str(e)[:50]}"); continue
    order = d.get("order") or []
    if len(order) > 25:
        R["template_too_many_sections"].append(f"{p.name}: {len(order)}")
    for sid, s in (d.get("sections") or {}).items():
        if not isinstance(s, dict): continue
        st = s.get("type")
        if st and st not in sections:
            R["template_missing_section"].append(f"{p.name} -> '{st}'"); continue
        sch = schema_of(st or "")
        if not sch: continue
        declared = {b.get("type") for b in (sch.get("blocks") or [])}
        used = {b.get("type") for b in (s.get("blocks") or {}).values() if isinstance(b, dict)}
        bad = sorted(x for x in used if x and x not in declared)
        if bad: R["template_bad_block_type"].append(f"{p.name} [{st}] {bad}")

# ── schema constraints ──────────────────────────────────────
def check_range(st, where):
    if st.get("type") != "range": return
    try: mn, mx, stp = st["min"], st["max"], st["step"]
    except KeyError:
        R["range_missing_key"].append(f"{where}[{st.get('id')}]"); return
    if mx >= 10000: R["range_max_too_big"].append(f"{where}[{st['id']}] max={mx}")
    if stp <= 0: R["range_bad_step"].append(f"{where}[{st['id']}] step={stp}"); return
    if (mx - mn) / stp + 1 > 101: R["range_too_many_steps"].append(f"{where}[{st['id']}] {int((mx-mn)/stp+1)}")
    s = str(stp)
    if "." in s and len(s.split(".")[1]) > 1: R["range_step_decimals"].append(f"{where}[{st['id']}] step={stp}")
    dv = st.get("default")
    if isinstance(dv, (int, float)):
        if not (mn <= dv <= mx): R["range_default_oor"].append(f"{where}[{st['id']}] {dv}")
        else:
            off = (dv - mn) / stp
            if abs(off - round(off)) > 1e-9: R["range_default_off_step"].append(f"{where}[{st['id']}] {dv}")

for p in D.glob("sections/*.liquid"):
    sch = schema_of(p.stem)
    if not sch: continue
    nm = sch.get("name") or ""
    if not nm.startswith("t:") and len(nm) > 25:
        R["section_name_too_long"].append(f"{p.name}: '{nm}' ({len(nm)})")
    pools = [(sch.get("settings") or [], p.stem)]
    for b in (sch.get("blocks") or []):
        pools.append((b.get("settings") or [], f"{p.stem}/{b.get('type')}"))
    for pool, where in pools:
        ids = [x.get("id") for x in pool if x.get("id")]
        dup = {i for i in ids if ids.count(i) > 1}
        if dup: R["duplicate_setting_id"].append(f"{where}: {sorted(dup)}")
        for st in pool:
            u = st.get("unit")
            if u and len(u) > 3: R["unit_too_long"].append(f"{where}[{st.get('id')}] '{u}'")
            check_range(st, where)
            if "default" in st and isinstance(st["default"], str) and st["default"].strip() == "":
                R["blank_default"].append(f"{where}[{st.get('id')}]")
            if st.get("type") == "richtext":
                dv = st.get("default")
                if isinstance(dv, str) and dv.strip() and not dv.startswith("t:") and not dv.strip().startswith(("<p","<ul","<ol","<h")):
                    R["richtext_bad_default"].append(f"{where}[{st.get('id')}]")

# ── template values vs schema ranges ────────────────────────
for p in D.glob("templates/*.json"):
    try: d = load_json(p)
    except Exception: continue
    for s in (d.get("sections") or {}).values():
        if not isinstance(s, dict): continue
        sch = schema_of(s.get("type") or "")
        if not sch: continue
        secR = {x["id"]: x for x in (sch.get("settings") or []) if x.get("type") == "range"}
        blkR = {b.get("type"): {x["id"]: x for x in (b.get("settings") or []) if x.get("type") == "range"}
                for b in (sch.get("blocks") or [])}
        def vet(vals, rr, where):
            for k, v in (vals or {}).items():
                if k in rr and isinstance(v, (int, float)):
                    st = rr[k]; mn, mx, stp = st["min"], st["max"], st["step"]
                    if not (mn <= v <= mx): R["tpl_value_oor"].append(f"{p.name} {where}.{k}={v}")
                    else:
                        off = (v - mn) / stp
                        if abs(off - round(off)) > 1e-9: R["tpl_value_off_step"].append(f"{p.name} {where}.{k}={v}")
        vet(s.get("settings"), secR, s.get("type"))
        for b in (s.get("blocks") or {}).values():
            if isinstance(b, dict): vet(b.get("settings"), blkR.get(b.get("type"), {}), f"{s.get('type')}/{b.get('type')}")

# ── translations ────────────────────────────────────────────
loc = D / "locales" / "en.default.schema.json"
if loc.exists():
    def flat(d, pre=""):
        o = {}
        for k, v in (d or {}).items():
            kk = f"{pre}.{k}" if pre else k
            if isinstance(v, dict): o.update(flat(v, kk))
            else: o[kk] = v
        return o
    try:
        have = flat(load_json(loc))
        for p in D.glob("sections/*.liquid"):
            for k in set(TKEY.findall(p.read_text(errors="ignore"))):
                if k not in have: R["missing_translation_key"].append(f"{p.name} -> t:{k}")
    except Exception as e:
        R["locale_parse_error"].append(str(e)[:60])

# ── preset values vs settings_schema ────────────────────────
# settings_data presets are validated by Shopify on push but were not
# covered here, which is how an out-of-range heading_scale shipped.
sch_file = D / "config" / "settings_schema.json"
sd_file  = D / "config" / "settings_data.json"
if sch_file.exists() and sd_file.exists():
    try:
        _schema = load_json(sch_file)
        _ranges = {}
        for _g in _schema:
            for _st in (_g.get("settings") or []):
                if _st.get("type") == "range" and _st.get("id"):
                    _ranges[_st["id"]] = _st
        _data = load_json(sd_file)
        _presets = _data.get("presets") or {}
        _ids = None
        for _pname, _p in _presets.items():
            if not isinstance(_p, dict):
                continue
            # every preset must declare the same colour-scheme ids
            _cs = sorted((_p.get("color_schemes") or {}).keys())
            if _ids is None:
                _ids = _cs
            elif _cs != _ids:
                R["preset_scheme_ids_differ"].append(f"{_pname}")
            for _k, _v in _p.items():
                _st = _ranges.get(_k)
                if not _st or not isinstance(_v, (int, float)):
                    continue
                _mn, _mx, _step = _st["min"], _st["max"], _st["step"]
                if not (_mn <= _v <= _mx):
                    R["preset_value_oor"].append(f"{_pname}.{_k}={_v} (allowed {_mn}-{_mx})")
                else:
                    _off = (_v - _mn) / _step
                    if abs(_off - round(_off)) > 1e-9:
                        R["preset_value_off_step"].append(f"{_pname}.{_k}={_v} (step {_step})")
    except Exception as e:
        R["settings_parse_error"].append(str(e)[:70])

total = 0
for k in sorted(R):
    items = sorted(set(R[k]))
    total += len(items)
    print(f"\n### {k}  ({len(items)})")
    for line in items[:14]: print(f"   {line}")
    if len(items) > 14: print(f"   ... +{len(items)-14} more")
print(f"\n{'='*50}\nTOTAL ISSUES: {total}")
