#!/usr/bin/env python3
"""
Mirror the public Aniyomi extension stores into ./anime/.

Aniyomi is a separate app from Mihon: different package namespace
(eu.kanade.tachiyomi.animeextension.*), different APK builds (aniyomi-*.apk),
and it reads only the legacy index.min.json schema - there is no index.pb on
the anime side. So this is a second, independent index served alongside the
manga one:

    https://changed.dami3nz.au/anime/index.min.json   <- add this in Aniyomi/Anikku
    https://changed.dami3nz.au/index.pb               <- the Mihon one

Assets are mirrored locally (anime/apk, anime/icon) and the index is rewritten
to reference them, so the mirror survives upstream deletion.

Stores are listed in anime-stores.txt. Duplicate packages across stores are
deduped: highest versionCode wins, first store listed breaks ties.

Usage:
    ./anime_sync.py              # sync everything listed in anime-stores.txt
    ./anime_sync.py --dry-run
    ./anime_sync.py --prune      # delete mirrored files no longer referenced
    ./anime_sync.py --sfw-only   # skip nsfw=1 entries
"""

import argparse
import json
import os
import re
import shutil
import sys
sys.dont_write_bytecode = True  # keep __pycache__ out of the public web root
import time
import urllib.request

ROOT = os.path.dirname(os.path.abspath(__file__))
BASE_URL = "https://changed.dami3nz.au"
OUT_DIR = os.path.join(ROOT, "anime")
APK_DIR = os.path.join(OUT_DIR, "apk")
ICON_DIR = os.path.join(OUT_DIR, "icon")
STORES_FILE = os.path.join(ROOT, "anime-stores.txt")

# Aniyomi's TrustAnimeExtension.isTrusted() auto-trusts an APK only when its
# signing cert matches the signingKey of some added store. This index merges
# four upstream stores with four different keys, so only one can be declared
# here - Yuzono's, which accounts for the overwhelming majority of entries.
# Extensions from the other three stores install fine but raise the normal
# one-time "Untrusted extension" confirmation.
ANIME_META = {
    "name": "dami3nz (anime)",
    "shortName": "D3N-A",
    "website": BASE_URL,
    "signingKeyFingerprint":
        "cbec121aa82ebb02aaa73806992e0368a97d47b5451ed6524816d03084c45905",
}

sys.path.insert(0, ROOT)
import sync  # noqa: E402  - reuse download/extract helpers


def log(msg):
    print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)


def read_stores():
    if not os.path.exists(STORES_FILE):
        return []
    out = []
    for line in open(STORES_FILE):
        line = line.split("#", 1)[0].strip()
        if not line:
            continue
        label, _, url = line.partition("|")
        if url.strip():
            out.append((label.strip(), url.strip()))
    return out


def fetch(url, timeout=60):
    req = urllib.request.Request(url, headers={"User-Agent": "mihon-repo-sync/1.0"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return r.read()


def version_code(entry):
    """Prefer the store's own code; fall back to parsing the version string."""
    try:
        code = int(entry.get("code") or 0)
    except (TypeError, ValueError):
        code = 0
    parts = [int(x) for x in re.findall(r"\d+", entry.get("version", ""))][:3]
    while len(parts) < 3:
        parts.append(0)
    derived = parts[0] * 100000 + parts[1] * 1000 + parts[2]
    return max(code, derived)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--dry-run", action="store_true")
    ap.add_argument("--prune", action="store_true")
    ap.add_argument("--sfw-only", action="store_true")
    args = ap.parse_args()

    stores = read_stores()
    if not stores:
        log(f"{STORES_FILE} is empty - nothing to do")
        return 1

    excluded = sync.read_excluded()
    merged = {}          # pkg -> (entry, base_url, store_label)
    dupes = []
    for label, url in stores:
        try:
            data = json.loads(fetch(url))
        except Exception as ex:
            log(f"  ! {label} unreachable: {ex}")
            continue
        base = url.rsplit("/", 1)[0]
        kept = 0
        for e in data:
            if args.sfw_only and e.get("nsfw"):
                continue
            pkg = e["pkg"]
            if pkg in excluded:
                continue
            if pkg in merged:
                old_e, _, old_label = merged[pkg]
                if version_code(e) > version_code(old_e):
                    dupes.append((pkg, old_label, old_e["version"], label, e["version"], label))
                    merged[pkg] = (e, base, label)
                else:
                    dupes.append((pkg, old_label, old_e["version"], label, e["version"], old_label))
                continue
            merged[pkg] = (e, base, label)
            kept += 1
        log(f"  + {label}: {len(data)} entries, {kept} new")

    if dupes:
        log(f"deduped {len(dupes)} package(s) offered by more than one store")
        for pkg, s1, v1, s2, v2, win in dupes[:12]:
            log(f"    {pkg.split('.')[-1]}: {s1} {v1} vs {s2} {v2} -> {win}")

    log(f"merged catalogue: {len(merged)} unique anime extensions "
        f"({len(excluded)} package(s) in excluded.txt)")

    if args.dry_run:
        nsfw = sum(1 for e, _, _ in merged.values() if e.get("nsfw"))
        log(f"[dry-run] would mirror {len(merged)} extensions ({nsfw} nsfw), nothing written")
        return 0

    os.makedirs(APK_DIR, exist_ok=True)
    os.makedirs(ICON_DIR, exist_ok=True)

    out, keep_apk, keep_icon = [], set(), set()
    downloaded = failed = extracted = 0

    for pkg in sorted(merged):
        e, base, label = merged[pkg]
        apk_name = e["apk"]
        icon_name = pkg + ".png"
        apk_path = os.path.join(APK_DIR, apk_name)

        try:
            if sync.download(f"{base}/apk/{apk_name}", apk_path):
                downloaded += 1
        except Exception as ex:
            log(f"  ! apk {pkg}: {ex}")
            failed += 1
            continue

        keep_apk.add(apk_name)
        icon_path = os.path.join(ICON_DIR, icon_name)
        have_icon = os.path.exists(icon_path) and os.path.getsize(icon_path) > 0
        if not have_icon:
            try:
                sync.download(f"{base}/icon/{pkg}.png", icon_path)
                downloaded += 1
                have_icon = True
            except Exception:
                if sync.extract_icon_from_apk(apk_path, icon_path):
                    extracted += 1
                    have_icon = True
        if have_icon:
            keep_icon.add(icon_name)

        entry = dict(e)
        entry["apk"] = apk_name          # resolved relative to the index URL
        out.append(entry)

    log(f"mirrored {len(out)} extensions - {downloaded} downloaded, "
        f"{extracted} icons extracted from apk, {failed} failed")

    with open(os.path.join(OUT_DIR, "index.min.json"), "w") as fh:
        json.dump(out, fh, separators=(",", ":"))
    with open(os.path.join(OUT_DIR, "index.json"), "w") as fh:
        json.dump(out, fh, indent=2)
    log("wrote anime/index.min.json + anime/index.json")

    # repo.json - REQUIRED, do not delete. Current Aniyomi resolves a legacy
    # store by rewriting ".../index.min.json" -> ".../repo.json" and reading the
    # metadata from there (AnimeExtensionStoreService.fetch); without it the
    # store simply cannot be added. No "index_v2" key: there is no anime
    # index.pb, so clients must stay on the legacy index.min.json path.
    with open(os.path.join(OUT_DIR, "repo.json"), "w") as fh:
        json.dump({"index_v2": None, "meta": ANIME_META}, fh, indent=2)
    log("wrote anime/repo.json (required by Aniyomi to register the store)")

    if args.prune:
        for d, kept in ((APK_DIR, keep_apk), (ICON_DIR, keep_icon)):
            for f in os.listdir(d):
                if f not in kept:
                    log(f"  - pruning {os.path.basename(d)}/{f}")
                    os.remove(os.path.join(d, f))

    print()
    log(f"done. Aniyomi / Anikku repo URL:  {BASE_URL}/anime/index.min.json")
    return 0


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