#!/usr/bin/env python3
"""
verify_modded_jsons.py — sanity-check the mirror's modded version JSONs
before publishing.

Catches the classes of bug we keep hitting in production reports:

  * **OptiFine missing tweakClass** — the LaunchWrapper-based variants
    (1.8.x through 1.20.6 standalone OptiFine) need
    ``--tweakClass optifine.OptiFineTweaker`` in ``arguments.game``.
    Without it LaunchWrapper defaults to ``net.minecraft.launchwrapper
    .VanillaTweaker`` which doesn't exist in ``launchwrapper-of-2.3.jar``,
    and the game bombs with ``ClassNotFoundException`` before any window
    is shown.

  * **Forge 1.20.6+ missing module path** — Forge switched to the
    SecureModules system in 1.20.6. The ``arguments.jvm`` block must
    include ``-p`` / ``--module-path`` pointing at the FML loader jars,
    or ``ModLauncher`` exits with ``Missing LaunchHandler fmlclient``.

  * **Forge legacy missing FML tweaker** — 1.7.10 / 1.12.2-style Forge
    needs ``--tweakClass cpw.mods.fml.common.launcher.FMLTweaker`` (or
    ``net.minecraftforge.fml.common.launcher.FMLTweaker``). Same failure
    mode as OptiFine when missing.

  * **Fabric mainClass mismatch** — Fabric profiles must launch
    ``net.fabricmc.loader.impl.launch.knot.KnotClient``. If a regenerated
    JSON points at vanilla's ``net.minecraft.client.main.Main``, no mod
    is loaded and the player sees vanilla.

  * **inheritsFrom dangling** — the parent version folder has to exist on
    the same mirror. If it doesn't, the launcher's metadata fetch falls
    over with a 404 mid-download.

  * **Unrewritten Mojang URLs** — anything pointing at
    ``piston-meta.mojang.com``, ``libraries.minecraft.net``, or
    ``files.minecraftforge.net`` is unreachable from the Iranian intranet.

  * **Non-mirror library URLs** — ``url`` fields outside
    ``{mirror_base}`` (or ``maven.fabricmc.net`` etc.) need rewriting.

The intent: run this on every mirror update so the server-side bugs
that show up in app crash reports get caught before the rsync.

Usage::

    python verify_modded_jsons.py --mirror /path/to/mirror
    # exit 0 = clean, exit 2 = problems found

    python verify_modded_jsons.py --mirror ./mirror --only "OptiFine 1.20.6"
    # check a single profile

    python verify_modded_jsons.py --mirror ./mirror --json
    # machine-readable output (one finding per line)

The script never modifies files — it only reports. Apply the suggested
fix in your version-JSON generator (mirror.py / make_forge_optifine.py)
and re-run.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import List


# Mirror hosts that don't trigger a warning when seen in `url` fields.
ALLOWED_URL_PREFIXES = (
    "https://dl.neocraft.ir",
    "http://dl.neocraft.ir",
    "https://212.80.8.75",
    "http://212.80.8.75",
)

# Hosts the Iranian intranet *cannot* reach. If a library still points at
# these in a published version JSON, the launcher will fail mid-download.
BLOCKED_URL_PREFIXES = (
    "https://piston-meta.mojang.com",
    "https://piston-data.mojang.com",
    "https://libraries.minecraft.net",
    "https://launcher.mojang.com",
    "https://launchermeta.mojang.com",
    "https://resources.download.minecraft.net",
    "https://files.minecraftforge.net",
    "https://maven.minecraftforge.net",
    "https://maven.fabricmc.net",
    "https://meta.fabricmc.net",
    "https://repo1.maven.org",
)


class Finding:
    __slots__ = ("level", "version", "message", "fix")

    def __init__(self, level: str, version: str, message: str, fix: str = ""):
        self.level = level  # "ERROR" / "WARN" / "INFO"
        self.version = version
        self.message = message
        self.fix = fix


def detect_loader(version_id: str, json_data: dict) -> str:
    """Best-effort classification used to scope the per-loader checks."""
    name = (json_data.get("id") or version_id or "").lower()
    if "forgeoptifine" in name.replace(" ", "") or "optifineforge" in name.replace(" ", ""):
        return "ForgeOptiFine"
    if "optifine" in name:
        return "OptiFine"
    if "forge" in name:
        return "Forge"
    if "fabric" in name:
        return "Fabric"
    if "neoforge" in name:
        return "NeoForge"
    return "Vanilla"


def parse_mc_minor(version_id: str) -> int:
    """Pull the MC minor version out of an ID like "Forge 1.20.6"; -1 if none."""
    import re
    m = re.search(r"1\.(\d+)(?:\.\d+)?", version_id)
    if not m:
        return -1
    try:
        return int(m.group(1))
    except ValueError:
        return -1


def collect_game_args(json_data: dict) -> List[str]:
    """Return the full list of game args, joining the structured + legacy forms.

    Modern format (1.13+) uses ``arguments.game`` (a list, possibly with
    objects for conditional args). Legacy uses ``minecraftArguments`` (a
    space-joined string). We don't try to evaluate conditionals — we just
    pull the raw strings since we're looking for the presence/absence of
    specific flag values.
    """
    out: List[str] = []
    args = json_data.get("arguments")
    if isinstance(args, dict):
        for entry in args.get("game", []) or []:
            if isinstance(entry, str):
                out.append(entry)
            elif isinstance(entry, dict):
                vals = entry.get("value")
                if isinstance(vals, str):
                    out.append(vals)
                elif isinstance(vals, list):
                    out.extend(v for v in vals if isinstance(v, str))
    legacy = json_data.get("minecraftArguments")
    if isinstance(legacy, str):
        out.extend(legacy.split())
    return out


def collect_jvm_args(json_data: dict) -> List[str]:
    """Mirror of collect_game_args for ``arguments.jvm``."""
    out: List[str] = []
    args = json_data.get("arguments")
    if isinstance(args, dict):
        for entry in args.get("jvm", []) or []:
            if isinstance(entry, str):
                out.append(entry)
            elif isinstance(entry, dict):
                vals = entry.get("value")
                if isinstance(vals, str):
                    out.append(vals)
                elif isinstance(vals, list):
                    out.extend(v for v in vals if isinstance(v, str))
    return out


def find_url_strings(node) -> List[str]:
    """Recursively pull every ``url`` field out of the JSON tree."""
    found: List[str] = []
    if isinstance(node, dict):
        url = node.get("url")
        if isinstance(url, str) and url:
            found.append(url)
        for v in node.values():
            found.extend(find_url_strings(v))
    elif isinstance(node, list):
        for v in node:
            found.extend(find_url_strings(v))
    return found


def check_optifine(version_id: str, data: dict, findings: List[Finding]) -> None:
    args = collect_game_args(data)
    has_tweak = any("optifine.OptiFineTweaker" in a for a in args)
    if not has_tweak:
        findings.append(Finding(
            "ERROR", version_id,
            "OptiFine version missing --tweakClass optifine.OptiFineTweaker",
            "Add to arguments.game (or minecraftArguments): "
            "\"--tweakClass\", \"optifine.OptiFineTweaker\"",
        ))
    main = data.get("mainClass")
    if main and main != "net.minecraft.launchwrapper.Launch":
        findings.append(Finding(
            "WARN", version_id,
            f"OptiFine standalone usually wants mainClass=Launch (got {main!r})",
            "If this is OptiFine bundled with Forge or NeoForge, ignore. "
            "Otherwise set mainClass to net.minecraft.launchwrapper.Launch.",
        ))


def check_forge(version_id: str, data: dict, findings: List[Finding]) -> None:
    minor = parse_mc_minor(version_id)
    args = collect_game_args(data)
    jvm = collect_jvm_args(data)

    if minor >= 20 or minor < 0:
        # 1.20.6+ uses SecureModules; needs --module-path or -p in jvm args
        # (the value typically references ${library_directory}/...).
        has_modpath = any(a in ("-p", "--module-path") for a in jvm) \
            or any(a.startswith("--module-path=") or a.startswith("-p=") for a in jvm)
        if not has_modpath:
            findings.append(Finding(
                "ERROR", version_id,
                "Forge 1.20.6+ version missing --module-path / -p in arguments.jvm "
                "— ModLauncher will fail with 'Missing LaunchHandler fmlclient'",
                "Re-run a fresh Forge installer for this MC version locally and "
                "copy its arguments.jvm block (or merge it via "
                "make_forge_optifine.py) into the mirror JSON.",
            ))
    else:
        # 1.7.10 .. 1.19.x: needs the FML tweak class
        legacy_tweaks = (
            "cpw.mods.fml.common.launcher.FMLTweaker",
            "net.minecraftforge.fml.common.launcher.FMLTweaker",
            "cpw.mods.modlauncher.Launcher",
        )
        has_tweak = any(any(t in a for t in legacy_tweaks) for a in args) \
            or data.get("mainClass") in legacy_tweaks
        if not has_tweak:
            findings.append(Finding(
                "ERROR", version_id,
                "Forge legacy version missing FML tweak class",
                "Add --tweakClass cpw.mods.fml.common.launcher.FMLTweaker "
                "(1.7.10/1.8.9) or net.minecraftforge.fml.common.launcher"
                ".FMLTweaker (1.9–1.19.x) to arguments.game.",
            ))


def check_fabric(version_id: str, data: dict, findings: List[Finding]) -> None:
    main = data.get("mainClass")
    if main != "net.fabricmc.loader.impl.launch.knot.KnotClient":
        findings.append(Finding(
            "ERROR", version_id,
            f"Fabric version has wrong mainClass {main!r}",
            "Set mainClass to net.fabricmc.loader.impl.launch.knot.KnotClient.",
        ))
    # Also expect game args to be empty / inherit-only — the inheritedFrom
    # vanilla JSON's args are merged in. A non-empty Fabric game args block
    # usually means an old TLauncher-style profile got copied verbatim.
    args = collect_game_args(data)
    if args:
        findings.append(Finding(
            "WARN", version_id,
            f"Fabric arguments.game is non-empty ({len(args)} entries) — "
            "usually a sign of a legacy TLauncher-era JSON that overrides "
            "the vanilla args instead of inheriting them",
            "Replace with the meta.fabricmc.net profile JSON for this MC "
            "version (mirror.py --fabric-versions does this).",
        ))


def check_inherits(
    version_id: str, data: dict, mirror_versions: set, findings: List[Finding]
) -> None:
    parent = data.get("inheritsFrom")
    if parent and parent not in mirror_versions:
        findings.append(Finding(
            "ERROR", version_id,
            f"inheritsFrom={parent!r} but {parent} is NOT on the mirror",
            f"Mirror the parent version too (server/mirror.py --versions {parent}) "
            "or remove the inheritsFrom field.",
        ))


def check_urls(version_id: str, data: dict, findings: List[Finding]) -> None:
    for url in find_url_strings(data):
        if not url:
            continue
        if any(url.startswith(p) for p in BLOCKED_URL_PREFIXES):
            findings.append(Finding(
                "ERROR", version_id,
                f"Library URL points at unreachable host: {url}",
                "Re-run mirror.py for this version — URL rewriting was missed.",
            ))
        elif not any(url.startswith(p) for p in ALLOWED_URL_PREFIXES) \
                and url.startswith("http"):
            findings.append(Finding(
                "WARN", version_id,
                f"Library URL is non-mirror: {url}",
                "Either intentional (whitelist host above) or rewrite to "
                "the NeoCraft mirror.",
            ))


def verify_one(version_id: str, json_path: Path, mirror_versions: set,
               findings: List[Finding]) -> None:
    try:
        data = json.loads(json_path.read_text(encoding="utf-8"))
    except Exception as e:
        findings.append(Finding(
            "ERROR", version_id, f"Cannot parse JSON: {e}",
            "Check the file is valid UTF-8 JSON.",
        ))
        return

    loader = detect_loader(version_id, data)
    check_inherits(version_id, data, mirror_versions, findings)
    check_urls(version_id, data, findings)

    # Check for legacy launchwrapper VanillaTweaker explicitly — this is the
    # specific failure mode that crashed OptiFine 1.20.6 in production.
    args = collect_game_args(data)
    for i, a in enumerate(args):
        if a == "--tweakClass" and i + 1 < len(args) \
                and args[i + 1] == "net.minecraft.launchwrapper.VanillaTweaker":
            findings.append(Finding(
                "ERROR", version_id,
                "tweakClass set to non-existent net.minecraft.launchwrapper.VanillaTweaker",
                "Replace with the loader-correct tweaker (OptiFineTweaker for "
                "OptiFine, FMLTweaker for legacy Forge, etc.)",
            ))

    if loader in ("OptiFine", "ForgeOptiFine"):
        check_optifine(version_id, data, findings)
    if loader in ("Forge", "ForgeOptiFine"):
        check_forge(version_id, data, findings)
    if loader == "Fabric":
        check_fabric(version_id, data, findings)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Audit mirror version JSONs for known launch failures."
    )
    parser.add_argument(
        "--mirror", required=True,
        help="Mirror root (the dir served at https://dl.neocraft.ir/).",
    )
    parser.add_argument(
        "--only", action="append",
        help="Limit to one or more version IDs (repeatable).",
    )
    parser.add_argument(
        "--json", action="store_true",
        help="Emit findings as JSON Lines instead of human-readable text.",
    )
    args = parser.parse_args()

    mirror = Path(args.mirror).expanduser().resolve()
    versions_dir = mirror / "versions"
    if not versions_dir.is_dir():
        print(f"ERROR: {versions_dir} does not exist", file=sys.stderr)
        return 1

    # Collect available version IDs (folder names under versions/).
    mirror_versions = {d.name for d in versions_dir.iterdir() if d.is_dir()}

    targets = [v for v in mirror_versions if not args.only or v in args.only]
    if args.only:
        missing = [v for v in args.only if v not in mirror_versions]
        for v in missing:
            print(f"WARN: --only {v!r} not on mirror, skipping", file=sys.stderr)

    findings: List[Finding] = []
    for vid in sorted(targets):
        json_path = versions_dir / vid / f"{vid}.json"
        if not json_path.is_file():
            findings.append(Finding(
                "ERROR", vid, "Version folder missing the version JSON",
                f"Expected {json_path} to exist.",
            ))
            continue
        verify_one(vid, json_path, mirror_versions, findings)

    if args.json:
        for f in findings:
            print(json.dumps({
                "level": f.level, "version": f.version,
                "message": f.message, "fix": f.fix,
            }, ensure_ascii=False))
    else:
        if not findings:
            print(f"OK — checked {len(targets)} version(s), no issues found.")
            return 0
        # Group by version for readability.
        by_version: dict = {}
        for f in findings:
            by_version.setdefault(f.version, []).append(f)
        errors = sum(1 for f in findings if f.level == "ERROR")
        warns = sum(1 for f in findings if f.level == "WARN")
        for vid in sorted(by_version):
            print(f"\n=== {vid} ===")
            for f in by_version[vid]:
                print(f"  [{f.level}] {f.message}")
                if f.fix:
                    print(f"     fix → {f.fix}")
        print(f"\nSummary: {errors} error(s), {warns} warning(s) "
              f"across {len(targets)} version(s).")

    # Exit code: non-zero if any ERROR-level finding so CI can gate on it.
    return 2 if any(f.level == "ERROR" for f in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
