#!/usr/bin/env python3
"""Read-only review of a Web App Manifest.

This checks portable quality signals. It intentionally does not claim that a
manifest is installable in every browser because install criteria change.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from urllib.parse import urljoin, urlparse


def finding(level: str, code: str, message: str) -> dict[str, str]:
    return {"level": level, "code": code, "message": message}


def review(manifest_path: Path) -> list[dict[str, str]]:
    findings: list[dict[str, str]] = []
    try:
        data = json.loads(manifest_path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        return [finding("error", "file-missing", f"Manifest not found: {manifest_path}")]
    except json.JSONDecodeError as exc:
        return [finding("error", "invalid-json", f"Invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}")]

    if not isinstance(data, dict):
        return [finding("error", "root-type", "Manifest root must be a JSON object.")]

    if not data.get("name") and not data.get("short_name"):
        findings.append(finding("error", "name-missing", "Provide name or short_name."))
    if not data.get("start_url"):
        findings.append(finding("error", "start-url-missing", "Provide a start_url."))
    if not data.get("id"):
        findings.append(finding("warning", "id-missing", "Provide a stable id so manifest URL changes do not change app identity."))

    display = data.get("display")
    if not display:
        findings.append(finding("warning", "display-missing", "Declare the intended display mode."))
    elif not isinstance(display, str):
        findings.append(finding("error", "display-type", "display must be a string."))

    for color_key in ("theme_color", "background_color"):
        if not data.get(color_key):
            findings.append(finding("warning", f"{color_key}-missing", f"Consider declaring {color_key}."))

    start_url = data.get("start_url")
    scope = data.get("scope")
    if isinstance(start_url, str) and isinstance(scope, str):
        base = "https://manifest.invalid/app.webmanifest"
        start = urlparse(urljoin(base, start_url))
        resolved_scope = urlparse(urljoin(base, scope))
        if start.netloc != resolved_scope.netloc or not start.path.startswith(resolved_scope.path.rstrip("/") + "/") and start.path != resolved_scope.path.rstrip("/"):
            findings.append(finding("warning", "scope-start-url", "start_url appears to fall outside scope; verify resolved URLs."))

    icons = data.get("icons")
    if not isinstance(icons, list) or not icons:
        findings.append(finding("error", "icons-missing", "Provide at least one manifest icon."))
    else:
        sizes: set[str] = set()
        has_maskable = False
        manifest_dir = manifest_path.parent
        for index, icon in enumerate(icons):
            if not isinstance(icon, dict):
                findings.append(finding("error", "icon-type", f"icons[{index}] must be an object."))
                continue
            src = icon.get("src")
            if not isinstance(src, str) or not src:
                findings.append(finding("error", "icon-src", f"icons[{index}] needs src."))
            elif not urlparse(src).scheme and not src.startswith("/"):
                icon_path = manifest_dir / src.split("?", 1)[0].split("#", 1)[0]
                if not icon_path.exists():
                    findings.append(finding("warning", "icon-file-missing", f"Local icon does not exist: {icon_path}"))
            icon_sizes = icon.get("sizes")
            if isinstance(icon_sizes, str):
                sizes.update(icon_sizes.lower().split())
            purpose = icon.get("purpose", "any")
            if isinstance(purpose, str) and "maskable" in purpose.lower().split():
                has_maskable = True
        for recommended in ("192x192", "512x512"):
            if recommended not in sizes and "any" not in sizes:
                findings.append(finding("warning", "icon-size", f"No icon declares {recommended}; verify target-platform requirements."))
        if not has_maskable:
            findings.append(finding("warning", "maskable-icon", "Consider a dedicated maskable icon."))

    return findings


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("manifest", type=Path)
    parser.add_argument("--output", choices=("text", "json"), default="text")
    parser.add_argument("--strict", action="store_true", help="Return non-zero when warnings exist.")
    args = parser.parse_args()

    findings = review(args.manifest)
    if args.output == "json":
        print(json.dumps({"manifest": str(args.manifest), "findings": findings}, indent=2))
    elif findings:
        for item in findings:
            print(f"{item['level'].upper():7} {item['code']}: {item['message']}")
    else:
        print("No manifest preflight findings.")

    has_error = any(item["level"] == "error" for item in findings)
    has_warning = any(item["level"] == "warning" for item in findings)
    return 2 if has_error else 1 if args.strict and has_warning else 0


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