#!/usr/bin/env python3
"""Read-only checker for <img> and <picture> markup. Standard library only.
Usage: python3 img_check.py file.html [more.html]   (exit 1 if any error)
Output: file:line  level  code  message
Thresholds are defaults: MAX_CANDIDATES, MAX_HIGH."""
import re, sys
from html.parser import HTMLParser

MAX_CANDIDATES = 6   # Shopify Dev suggests about 4 to 6 widths; default, not a rule
MAX_HIGH = 2         # web.dev: more than one or two fetchpriority=high loses effect
HERO = re.compile(r"hero|banner|masthead|cover|featured", re.I)
JUNK_ALT = re.compile(r"^(image|img|photo|picture|graphic|banner)?[\s_-]*\d*$|\.(jpe?g|png|webp|avif|gif|svg)$|^(image|picture|photo|graphic) of\b", re.I)

class P(HTMLParser):
    def __init__(self, name):
        super().__init__(convert_charrefs=True)
        self.name, self.out, self.stack = name, [], []
        self.imgs, self.high = [], []
        self.pic = []          # open <picture> frames
        self.anchors = []      # open <a> frames
    def add(self, line, lvl, code, msg): self.out.append((line, lvl, code, msg))
    def handle_starttag(self, tag, attrs):
        a = {k: (v if v is not None else "") for k, v in attrs}
        line = self.getpos()[0]
        if tag == "picture": self.pic.append({"line": line, "img": False})
        elif tag == "a": self.anchors.append({"line": line, "text": False, "img_alt": None, "img_line": None})
        elif tag == "source" and self.pic:
            if "type" not in a and "media" not in a:
                self.add(line, "warn", "source-no-type-or-media", "source has neither type nor media, so it always matches")
            self.srcset_checks(line, a, source=True)
        elif tag == "img":
            if self.pic: self.pic[-1]["img"] = True
            self.img(line, a)
        if tag in ("a", "button"): pass
    def handle_endtag(self, tag):
        if tag == "picture" and self.pic:
            f = self.pic.pop()
            if not f["img"]: self.add(f["line"], "error", "picture-no-img", "picture has no img fallback, so nothing renders")
        elif tag == "a" and self.anchors:
            f = self.anchors.pop()
            if f["img_alt"] == "" and not f["text"]:
                self.add(f["img_line"], "error", "empty-link-image", "link contains only an image with alt=\"\", so the link has no name")
    def handle_data(self, data):
        if data.strip() and self.anchors: self.anchors[-1]["text"] = True
    def srcset_checks(self, line, a, source=False):
        ss, sz = a.get("srcset", ""), a.get("sizes")
        cands = [c.strip() for c in ss.split(",") if c.strip()] if ss else []
        desc = [c.split()[-1] for c in cands if len(c.split()) > 1]
        w = [d for d in desc if d.endswith("w")]; x = [d for d in desc if d.endswith("x")]
        if w and x: self.add(line, "error", "mixed-descriptors", "srcset mixes w and x descriptors")
        if len(cands) > MAX_CANDIDATES:
            self.add(line, "warn", "too-many-candidates", f"{len(cands)} srcset candidates (default limit {MAX_CANDIDATES})")
        if sz is not None and not w:
            self.add(line, "error", "sizes-without-w", "sizes has no effect without w descriptors in srcset")
        if w and sz is None:
            self.add(line, "warn", "w-without-sizes", "w descriptors without sizes: browser assumes 100vw")
        return w
    def img(self, line, a):
        if "alt" not in a: self.add(line, "error", "no-alt", "no alt attribute (missing is not the same as empty)")
        elif a["alt"].strip() and JUNK_ALT.search(a["alt"].strip()):
            self.add(line, "warn", "junk-alt", f"alt looks like a filename or filler: {a['alt'][:40]!r}")
        if self.anchors and "alt" in a:
            self.anchors[-1]["img_alt"] = a["alt"].strip(); self.anchors[-1]["img_line"] = line
            if a["alt"].strip(): self.anchors[-1]["text"] = True
        style = a.get("style", "")
        if not (a.get("width") and a.get("height")) and "aspect-ratio" not in style:
            self.add(line, "warn", "no-dimensions", "no width and height (or inline aspect-ratio): layout shift risk")
        load, fp = a.get("loading", "").lower(), a.get("fetchpriority", "").lower()
        if load not in ("", "lazy", "eager"): self.add(line, "warn", "bad-loading", f"loading={load!r} is not lazy or eager")
        if fp not in ("", "high", "low", "auto"): self.add(line, "warn", "bad-fetchpriority", f"fetchpriority={fp!r} is not high, low or auto")
        self.srcset_checks(line, a)
        if a.get("sizes", "").strip().lower().startswith("auto") and load != "lazy":
            self.add(line, "error", "sizes-auto-needs-lazy", "sizes=auto is valid only with loading=lazy; eager images need explicit sizes")
        if fp == "high": self.high.append(line)
        self.imgs.append({"line": line, "load": load, "fp": fp, "hero": bool(HERO.search(a.get("class", "") + " " + a.get("id", "")))})

    def finish(self):
        first = self.imgs[0] if self.imgs else None
        for i in self.imgs:
            likely = i["fp"] == "high" or i["hero"] or i is first
            if i["load"] == "lazy" and likely:
                why = "fetchpriority=high" if i["fp"] == "high" else "hero class" if i["hero"] else "first image in the document"
                self.add(i["line"], "error", "lazy-lcp", f"loading=lazy on a likely LCP image ({why}); confirm with a real LCP element")
        if len(self.high) > MAX_HIGH:
            self.add(self.high[MAX_HIGH], "warn", "many-high", f"{len(self.high)} images have fetchpriority=high (default limit {MAX_HIGH})")

def main(paths):
    errs = warns = 0
    for path in paths:
        p = P(path)
        try: p.feed(open(path, encoding="utf-8", errors="replace").read()); p.close()
        except OSError as e: print(f"{path}: cannot read ({e})"); return 2
        p.finish()
        for line, lvl, code, msg in sorted(p.out):
            print(f"{path}:{line}  {lvl:5}  {code}  {msg}")
            errs += lvl == "error"; warns += lvl == "warn"
        print(f"{path}: {errs} errors, {warns} warnings"); 
    return 1 if errs else 0

if __name__ == "__main__":
    if len(sys.argv) < 2: print(__doc__); sys.exit(2)
    sys.exit(main(sys.argv[1:]))
