#!/usr/bin/env python3
"""Check the JSON-LD on a page against what Google actually requires, offline.

    python3 check.py path/to/page.html
    python3 check.py path/to/markup.json
    python3 check.py https://example.com/page/        (needs network)
    python3 check.py page.html --json                 (machine-readable)

Why this exists: the schema skill writes good markup and then hands you a checklist that says
"test it". Google's Rich Results Test has no API, so that step usually never happens. This does the
part a script can do — every field Google documents as required, plus the mistakes that cost rich
results in practice — and tells you the two things it cannot know, so you still look at those.

Exit code 0 when there are no errors, 1 when there are. Warnings never fail the run.

Nothing here calls out to a service. Python 3.9+, standard library only.
"""
from __future__ import annotations

import json
import re
import sys
import urllib.request
from datetime import date

UA = "Mozilla/5.0 (compatible; schema-markup-kit)"

# What Google documents as required for each type's rich result. A type missing from here is not
# checked for required fields — it is still checked for the mistakes further down.
REQUIRED = {
    "Product": ["name", "image", "offers"],
    "Offer": ["price", "priceCurrency"],
    "AggregateOffer": ["lowPrice", "priceCurrency"],
    "SoftwareApplication": ["name", "offers"],
    "WebApplication": ["name", "offers"],
    "MobileApplication": ["name", "offers"],
    "Article": ["headline", "image", "datePublished", "author"],
    "NewsArticle": ["headline", "image", "datePublished", "author"],
    "BlogPosting": ["headline", "image", "datePublished", "author"],
    "BreadcrumbList": ["itemListElement"],
    "ListItem": ["position", "name"],
    "Organization": ["name", "url"],
    "WebSite": ["name", "url"],
    "LocalBusiness": ["name", "address"],
    "Event": ["name", "startDate", "location"],
    "Recipe": ["name", "image"],
    "VideoObject": ["name", "description", "thumbnailUrl", "uploadDate"],
    "JobPosting": ["title", "description", "datePosted", "hiringOrganization", "jobLocation"],
    "Review": ["author", "reviewRating"],
    "AggregateRating": ["ratingValue", "ratingCount"],
    "FAQPage": ["mainEntity"],
    "Question": ["name", "acceptedAnswer"],
    "HowTo": ["name", "step"],
}

# Strongly recommended by Google: not an error, but the thing that quietly costs you the result.
RECOMMENDED = {
    "Product": ["brand", "sku", "description"],
    "Offer": ["availability", "url"],
    "Article": ["dateModified", "publisher"],
    "NewsArticle": ["dateModified", "publisher"],
    "BlogPosting": ["dateModified", "publisher"],
    "Organization": ["logo"],
    "WebApplication": ["applicationCategory", "operatingSystem"],
    "SoftwareApplication": ["applicationCategory", "operatingSystem"],
    "VideoObject": ["contentUrl", "duration"],
}

# Types Google no longer turns into rich results. The markup stays valid schema.org and answer
# engines still read it — but if you added it for stars or dropdowns in search, it is not coming.
RETIRED = {
    "FAQPage": "Google stopped showing FAQ rich results on 7 May 2026 and removed the docs in June. "
               "Valid markup, still read by answer engines, no search appearance.",
    "HowTo": "Google retired HowTo rich results in September 2023.",
}
# SearchAction inside WebSite: the sitelinks search box went in November 2024.
RETIRED_PROP = {"potentialAction": "The sitelinks search box was retired in November 2024; "
                                   "WebSite still supports the site name."}

ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?([.,]\d+)?(Z|[+-]\d{2}:?\d{2})?)?$")
AVAILABILITY = {"InStock", "OutOfStock", "PreOrder", "BackOrder", "Discontinued",
                "InStoreOnly", "LimitedAvailability", "OnlineOnly", "SoldOut", "PreSale"}
DATE_FIELDS = {"datePublished", "dateModified", "uploadDate", "startDate", "endDate", "datePosted",
               "priceValidUntil", "validFrom", "validThrough"}
URL_FIELDS = {"url", "item", "image", "logo", "thumbnailUrl", "contentUrl", "sameAs", "@id"}
BLOCK = re.compile(r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', re.S | re.I)


class Report:
    def __init__(self) -> None:
        self.errors: list[tuple[str, str]] = []
        self.warnings: list[tuple[str, str]] = []
        self.notes: list[tuple[str, str]] = []
        self.types: list[str] = []

    def error(self, where: str, msg: str) -> None:
        self.errors.append((where, msg))

    def warn(self, where: str, msg: str) -> None:
        self.warnings.append((where, msg))

    def note(self, where: str, msg: str) -> None:
        self.notes.append((where, msg))


def types_of(node: dict) -> list[str]:
    t = node.get("@type")
    return [t] if isinstance(t, str) else [x for x in (t or []) if isinstance(x, str)]


def walk(node, report: Report, path: str = "") -> None:
    """Every object in the tree gets checked, however deep the @graph nests it."""
    if isinstance(node, list):
        for i, x in enumerate(node):
            walk(x, report, f"{path}[{i}]")
        return
    if not isinstance(node, dict):
        return

    tys = types_of(node)
    here = f"{path} {'/'.join(tys)}".strip() or "(root)"
    for ty in tys:
        report.types.append(ty)

        for field in REQUIRED.get(ty, []):
            if node.get(field) in (None, "", [], {}):
                report.error(here, f"missing {field} — Google lists it as required for {ty}")
        for field in RECOMMENDED.get(ty, []):
            if node.get(field) in (None, "", [], {}):
                report.warn(here, f"no {field} — Google recommends it for {ty}")
        if ty in RETIRED:
            report.note(here, RETIRED[ty])
        if ty == "WebSite" and node.get("potentialAction"):
            report.note(here, RETIRED_PROP["potentialAction"])

    # A rating has to come from something a reader can see on the page. This is the field that puts
    # stars in search and the one most often invented; the script cannot read your page, so it asks.
    if any(k in node for k in ("aggregateRating", "review")) and "Product" in tys or "aggregateRating" in node:
        report.note(here, "carries a rating — Google requires the same rating to be visible on the "
                          "page, from real reviews. Check that by eye; a script cannot.")

    for key, val in node.items():
        if key in DATE_FIELDS and isinstance(val, str) and not ISO_DATE.match(val):
            report.error(here, f"{key} is not ISO 8601: {val!r}")
        if key in URL_FIELDS:
            for u in (val if isinstance(val, list) else [val]):
                if isinstance(u, str) and u and not u.startswith(("http://", "https://", "#", "/")):
                    report.error(here, f"{key} is not a full URL: {u!r}")
                elif isinstance(u, str) and u.startswith("/"):
                    report.warn(here, f"{key} is relative ({u!r}) — Google wants absolute URLs")
        if key == "availability" and isinstance(val, str):
            name = val.rsplit("/", 1)[-1]
            if name not in AVAILABILITY:
                report.error(here, f"availability {val!r} is not one of schema.org's values")
        if key == "price" and isinstance(val, (str, int, float)):
            s = str(val)
            if not re.fullmatch(r"\d+(\.\d+)?", s):
                report.error(here, f"price {val!r} must be a bare number — no currency symbol, no comma")
        if key == "priceValidUntil" and isinstance(val, str) and ISO_DATE.match(val):
            if val[:10] < date.today().isoformat():
                report.warn(here, f"priceValidUntil {val} is in the past — Google may drop the offer")

        walk(val, report, f"{path}.{key}" if path else key)


def blocks_from(text: str) -> list[str]:
    found = BLOCK.findall(text)
    return found if found else ([text] if text.lstrip().startswith(("{", "[")) else [])


def read(source: str) -> str:
    if source.startswith(("http://", "https://")):
        req = urllib.request.Request(source, headers={"user-agent": UA})
        with urllib.request.urlopen(req, timeout=30) as r:
            return r.read().decode("utf-8", "replace")
    with open(source, encoding="utf-8") as f:
        return f.read()


def check(source: str) -> Report:
    report = Report()
    text = read(source)
    raw = blocks_from(text)
    if not raw:
        report.error("(page)", "no <script type=\"application/ld+json\"> block found")
        return report
    for i, chunk in enumerate(raw):
        where = f"block {i + 1}" if len(raw) > 1 else "block"
        try:
            data = json.loads(chunk)
        except ValueError as exc:
            report.error(where, f"not valid JSON: {exc}")
            continue
        if isinstance(data, dict) and "@context" not in data and "@graph" not in data:
            report.warn(where, "no @context — most consumers will ignore this block")
        walk(data.get("@graph", data) if isinstance(data, dict) else data, report, where)
    return report


def main(argv: list[str]) -> int:
    args = [a for a in argv[1:] if not a.startswith("--")]
    as_json = "--json" in argv
    if not args:
        print(__doc__.strip())
        return 2
    worst = 0
    for source in args:
        report = check(source)
        if as_json:
            print(json.dumps({"source": source, "types": sorted(set(report.types)),
                              "errors": [{"where": w, "message": m} for w, m in report.errors],
                              "warnings": [{"where": w, "message": m} for w, m in report.warnings],
                              "notes": [{"where": w, "message": m} for w, m in report.notes]},
                             ensure_ascii=False, indent=1))
        else:
            print(f"\n{source}")
            print(f"  types: {', '.join(sorted(set(report.types))) or 'none'}")
            for where, msg in report.errors:
                print(f"  ERROR  {where}: {msg}")
            for where, msg in report.warnings:
                print(f"  warn   {where}: {msg}")
            for where, msg in report.notes:
                print(f"  note   {where}: {msg}")
            if not report.errors:
                print("  no errors")
            print("\n  Two things this cannot check, so check them yourself:")
            print("   1. Does the markup match what a visitor actually sees on the page?")
            print("   2. For a rating or review, are those reviews real and on the page?")
        worst = max(worst, 1 if report.errors else 0)
    return worst


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