# BEGIN CHANGE: extract item PNGs via open-tibia-library (same engine as ots.me) + PNG->GIF
"""
Extract item images from Tibia.spr + Tibia.dat + items.otb using Gesior OTL
(browser generator automated with Playwright), then convert PNGs to GIFs.

Usage:
  python extract_item_gifs.py
  python extract_item_gifs.py --all
  python extract_item_gifs.py --from 12600 --to 12750
  python extract_item_gifs.py --range 2160,2376,12600-12700
    python extract_item_gifs.py --ids-file portal/tools/visual_item_scan.json
  python extract_item_gifs.py --convert-only PATH/to/png_or_zip
"""
from __future__ import annotations

import argparse
import json
import os
import shutil
import sys
import time
import zipfile
from pathlib import Path

from PIL import Image

# BEGIN CHANGE: paths host-aware (OT_SERVER_PATH / WA_HTML_REPO / WA_HTML_ROOT)
REPO_HTML = Path(os.environ.get("WA_HTML_REPO", Path(__file__).resolve().parent.parent))
PEDRO = REPO_HTML.parent
OTL = REPO_HTML / "tools" / "open-tibia-library"
OUT = REPO_HTML / "tools" / "item-images-out"
PNG_DIR = OUT / "png"
GIF_DIR = OUT / "gif"

OT_ROOT = Path(os.environ.get("OT_SERVER_PATH", PEDRO / "otx2_reseted")).expanduser()
OTB = OT_ROOT / "data" / "items" / "items.otb"

SPR = PEDRO / "WhiteAntidoteCustomClient" / "Tibia.spr"
DAT = PEDRO / "WhiteAntidoteCustomClient" / "Tibia.dat"

if not SPR.is_file():
    SPR = REPO_HTML / "files-extended" / "Tibia.spr"
if not DAT.is_file():
    DAT = REPO_HTML / "files-extended" / "Tibia.dat"
if not SPR.is_file():
    SPR = Path(os.environ.get("WA_HTML_ROOT", "/home/ubuntu/portal/assets")) / "files-extended" / "Tibia.spr"
if not DAT.is_file():
    DAT = Path(os.environ.get("WA_HTML_ROOT", "/home/ubuntu/portal/assets")) / "files-extended" / "Tibia.dat"


def publish_gif_dir(src_dir: Path) -> dict[str, int]:
    """Copy generated GIFs to repo images/items and portal assets."""
    targets = [
        REPO_HTML / "images" / "items",
        Path(os.environ.get("WA_HTML_ROOT", "/home/ubuntu/portal/assets")) / "images" / "items",
    ]
    seen: set[Path] = set()
    copied = 0
    for dst_root in targets:
        try:
            dst_root = dst_root.resolve()
        except OSError:
            continue
        if dst_root in seen:
            continue
        seen.add(dst_root)
        dst_root.mkdir(parents=True, exist_ok=True)
        for gif in sorted(src_dir.glob("*.gif")):
            try:
                shutil.copy2(gif, dst_root / gif.name)
                copied += 1
            except OSError as e:
                print(f"WARN publish {gif.name} -> {dst_root}: {e}", flush=True)
    return {"targets": len(seen), "filesCopied": copied}
# END CHANGE


def parse_id_list(spec: str) -> list[int]:
    """Parse '1,5,10-20,30' into sorted unique server IDs."""
    ids: set[int] = set()
    for part in spec.split(","):
        part = part.strip()
        if not part:
            continue
        if "-" in part:
            a_s, b_s = part.split("-", 1)
            a, b = int(a_s.strip()), int(b_s.strip())
            if b < a:
                a, b = b, a
            ids.update(range(a, b + 1))
        else:
            ids.add(int(part))
    return sorted(ids)


# BEGIN CHANGE: load visual_item_scan.json or a flat id list from disk
def load_ids_file(path: Path) -> list[int]:
    raw = path.read_text(encoding="utf-8")
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        return parse_id_list(raw.replace("\n", ","))
    ids: set[int] = set()
    if isinstance(data, list):
        for x in data:
            ids.add(int(x))
    elif isinstance(data, dict):
        for v in data.values():
            if isinstance(v, list):
                for x in v:
                    ids.add(int(x))
            elif isinstance(v, (int, str)):
                ids.add(int(v))
    return sorted(ids)
# END CHANGE


def png_to_gif(src: Path, dst: Path) -> None:
    im = Image.open(src)
    if im.mode not in ("P", "RGB", "RGBA"):
        im = im.convert("RGBA")
    # Keep transparency: quantize with alpha
    if im.mode == "RGBA":
        alpha = im.split()[-1]
        # palette GIF with transparency index
        bg = Image.new("RGBA", im.size, (0, 0, 0, 0))
        composed = Image.alpha_composite(bg, im)
        pal = composed.convert("P", palette=Image.Palette.ADAPTIVE, colors=255)
        mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0)
        pal.paste(255, mask=mask)
        pal.save(dst, format="GIF", transparency=255, optimize=True)
    else:
        im.convert("P", palette=Image.Palette.ADAPTIVE, colors=256).save(
            dst, format="GIF", optimize=True
        )


# BEGIN CHANGE: animated GIF shared palette + transparency index 0 (no black box)
def _rgba_frames_to_animated_gif(
    rgba_frames: list[Image.Image], dst: Path, delay_ms: int
) -> None:
    """Write multi-frame GIF with real transparency (shared palette, index 0).

    Pillow save_all with per-frame ADAPTIVE palettes + transparency=255 bakes
    transparent pixels into opaque black - visible as a black box on the portal.
    """
    if not rgba_frames:
        raise ValueError("no frames")
    if len(rgba_frames) == 1:
        tmp = dst.with_suffix(".png")
        rgba_frames[0].save(tmp)
        try:
            png_to_gif(tmp, dst)
        finally:
            if tmp.exists():
                tmp.unlink()
        return

    rgb_frames: list[Image.Image] = []
    trans_masks: list[Image.Image] = []
    for tile in rgba_frames:
        tile = tile.convert("RGBA")
        alpha = tile.getchannel("A")
        opaque = alpha.point(lambda v: 255 if v >= 128 else 0)
        trans_masks.append(alpha.point(lambda v: 255 if v < 128 else 0))
        rgb = Image.new("RGB", tile.size, (0, 0, 0))
        rgb.paste(tile.convert("RGB"), mask=opaque)
        rgb_frames.append(rgb)

    base = rgb_frames[0].quantize(colors=255, method=Image.Quantize.MEDIANCUT)
    base_pal = list(base.getpalette() or [])
    # index 0 = transparent; shift quantized colors to 1..255
    new_pal = [0, 0, 0] + base_pal[: 255 * 3]
    new_pal = (new_pal + [0] * 768)[:768]

    out_frames: list[Image.Image] = []
    for rgb, mask in zip(rgb_frames, trans_masks):
        q = rgb.quantize(palette=base, dither=Image.Dither.NONE)
        data = bytearray(q.tobytes())
        m = mask.tobytes()
        for i, flag in enumerate(m):
            if flag:
                data[i] = 0
            else:
                data[i] = min(255, data[i] + 1)
        p = Image.frombytes("P", q.size, bytes(data))
        p.putpalette(new_pal)
        p.info["transparency"] = 0
        out_frames.append(p)

    delay = max(80, min(400, int(delay_ms)))
    out_frames[0].save(
        dst,
        format="GIF",
        save_all=True,
        append_images=out_frames[1:],
        duration=delay,
        loop=0,
        transparency=0,
        disposal=2,
        background=0,
    )


def strip_png_to_animated_gif(src: Path, dst: Path, frame_count: int, delay_ms: int) -> None:
    """Split horizontal OTL strip (frame0|frame1|...) into animated GIF."""
    im = Image.open(src).convert("RGBA")
    w, h = im.size
    if frame_count < 1 or w % frame_count != 0:
        png_to_gif(src, dst)
        return
    if frame_count == 1:
        png_to_gif(src, dst)
        return
    fw = w // frame_count
    rgba_frames = [im.crop((i * fw, 0, (i + 1) * fw, h)) for i in range(frame_count)]
    _rgba_frames_to_animated_gif(rgba_frames, dst, delay_ms)
# END CHANGE


def convert_tree(src_dir: Path, dst_dir: Path) -> int:
    dst_dir.mkdir(parents=True, exist_ok=True)
    n = 0
    for png in sorted(src_dir.rglob("*.png")):
        name = png.stem
        # BEGIN CHANGE: id_frames_delay.png strips -> animated GIF
        parts = name.split("_")
        if len(parts) >= 3 and parts[0].isdigit() and parts[1].isdigit() and parts[2].isdigit():
            item_id, frame_count, delay_ms = int(parts[0]), int(parts[1]), int(parts[2])
            out = dst_dir / f"{item_id}.gif"
            try:
                strip_png_to_animated_gif(png, out, frame_count, delay_ms)
                n += 1
            except Exception as e:
                print(f"FAIL strip {png}: {e}")
            continue
        if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
            # legacy frames generator: id_framecount.png
            item_id, frame_count = int(parts[0]), int(parts[1])
            out = dst_dir / f"{item_id}.gif"
            try:
                strip_png_to_animated_gif(png, out, frame_count, 200)
                n += 1
            except Exception as e:
                print(f"FAIL strip {png}: {e}")
            continue
        # END CHANGE
        if not name.isdigit():
            continue
        out = dst_dir / f"{name}.gif"
        try:
            png_to_gif(png, out)
            n += 1
        except Exception as e:
            print(f"FAIL {png}: {e}")
    return n


def convert_zip(zip_path: Path, dst_dir: Path) -> int:
    """Copy native .gif from OTL zip; convert .png (incl. animation strips)."""
    tmp = OUT / "_unzip"
    if tmp.exists():
        shutil.rmtree(tmp)
    tmp.mkdir(parents=True)
    with zipfile.ZipFile(zip_path, "r") as zf:
        zf.extractall(tmp)
    dst_dir.mkdir(parents=True, exist_ok=True)
    n = 0
    # BEGIN CHANGE: prefer animated GIF from OTL; PNG strips assembled below
    for gif in sorted(tmp.rglob("*.gif")):
        name = gif.stem
        if not name.isdigit():
            continue
        shutil.copy2(gif, dst_dir / f"{name}.gif")
        n += 1
    png_n = convert_tree(tmp, dst_dir)
    # END CHANGE
    return n + png_n


def run_generator(
    only_pickable: bool,
    *,
    u32: bool = True,
    alpha: bool = False,
    only_ids: list[int] | None = None,
) -> Path:
    from playwright.sync_api import sync_playwright

    html = OTL / "itemImageGenerator.html"
    if not (OTL / "js").is_dir():
        raise SystemExit("OTL not built. Run: cd tools/open-tibia-library && npm run build")
    for p, label in ((SPR, "SPR"), (DAT, "DAT"), (OTB, "OTB")):
        if not p.is_file():
            raise SystemExit(f"Missing {label}: {p}")

    OUT.mkdir(parents=True, exist_ok=True)
    PNG_DIR.mkdir(parents=True, exist_ok=True)
    download_dir = OUT / "download"
    if download_dir.exists():
        shutil.rmtree(download_dir)
    download_dir.mkdir(parents=True)

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context(accept_downloads=True)
        page = context.new_page()
        page.goto(html.as_uri())
        page.fill("#clientversion", "860")
        page.set_input_files("#spr", str(SPR))
        page.set_input_files("#dat", str(DAT))
        page.set_input_files("#otb", str(OTB))
        # Falumir/OTC 860: SPR is U32. Alpha must stay OFF
        # (GameSpritesAlphaChannel commented out in features.lua).
        # Alpha ON -> noise/static (previous bad extract).
        if u32:
            page.check("#forceEnableExtendedSprites")
        else:
            page.uncheck("#forceEnableExtendedSprites")
        try:
            if alpha:
                page.check("#enableTransparency")
            else:
                page.uncheck("#enableTransparency")
        except Exception:
            pass
        try:
            page.uncheck("#enableEnhancedAnimations")
            page.uncheck("#enableIdleAnimations")
        except Exception:
            pass
        if only_pickable:
            page.check("#onlyPickable")
        else:
            page.uncheck("#onlyPickable")

        print(
            f"Flags: u32={u32} alpha={alpha} pickable={only_pickable} ids={only_ids or 'all'}",
            flush=True,
        )
        print("Loading SPR/DAT/OTB (can take several minutes)...", flush=True)
        page.click("#loadFiles")
        page.wait_for_function(
            """() => {
              const t = (document.getElementById('progressBar')?.innerText || '').toLowerCase();
              return t.includes('data loaded') || t.includes('generate images') || t.includes('error');
            }""",
            timeout=600_000,
        )
        time.sleep(1)
        progress = page.locator("#progressBar").inner_text()
        print("Progress after load:", progress, flush=True)
        if "error" in progress.lower():
            raise RuntimeError(progress)

        # Optional: only generate listed server IDs (needs window.__itemImageGenerator)
        if only_ids:
            page.evaluate(
                """(ids) => {
                  const gen = window.__itemImageGenerator;
                  if (!gen) throw new Error('__itemImageGenerator missing; rebuild OTL');
                  const set = new Set(ids);
                  const orig = gen.generateItemImage.bind(gen);
                  const maxId = Math.max.apply(null, ids);
                  gen.generateItemImage = function(imageGenerator, zip, serverId) {
                    if (serverId > maxId) {
                      const last = gen.otbManager.getLastId();
                      return orig(imageGenerator, zip, last + 1);
                    }
                    if (!set.has(serverId)) {
                      setTimeout(function () {
                        gen.generateItemImage(imageGenerator, zip, serverId + 1);
                      }, 1);
                      return;
                    }
                    return orig(imageGenerator, zip, serverId);
                  };
                }""",
                only_ids,
            )

        print("Generating images (long)...", flush=True)
        with page.expect_download(timeout=3_600_000) as dl_info:
            page.click("#generateImages")
            last = ""
            for _ in range(1800):
                txt = page.locator("#progressBar").inner_text()
                if txt != last:
                    print(txt, flush=True)
                    last = txt
                low = txt.lower()
                if "error" in low and "failed" in low:
                    raise RuntimeError(txt)
                if "download now" in low or "zip generated" in low:
                    break
                time.sleep(2)

        download = dl_info.value
        zip_path = download_dir / "items.zip"
        download.save_as(str(zip_path))
        print("Saved", zip_path, "size", zip_path.stat().st_size, flush=True)
        browser.close()
        return zip_path


def main() -> int:
    try:
        sys.stdout.reconfigure(line_buffering=True)  # type: ignore[attr-defined]
    except Exception:
        pass
    ap = argparse.ArgumentParser(
        description="Extract item GIFs from SPR/DAT/OTB (Gesior OTL + Playwright)."
    )
    ap.add_argument("--all", action="store_true", help="All OTB items (not only pickable)")
    ap.add_argument("--convert-only", type=Path, help="ZIP or folder of PNGs to convert to GIF")
    ap.add_argument("--skip-generate", action="store_true")
    ap.add_argument("--no-u32", action="store_true", help="Disable GameSpritesU32 (breaks Falumir SPR)")
    ap.add_argument("--alpha", action="store_true", help="Enable alpha (WRONG for WA OTC; causes noise)")
    ap.add_argument(
        "--ids",
        type=str,
        default="",
        help="Comma server IDs and/or ranges, e.g. 2160,2376,12600-12700",
    )
    ap.add_argument(
        "--range",
        type=str,
        default="",
        dest="range_spec",
        help="Same as --ids (alias). Prefer for ranges: 12600-12750",
    )
    ap.add_argument("--from", type=int, dest="id_from", default=None, help="Inclusive start server ID")
    ap.add_argument("--to", type=int, dest="id_to", default=None, help="Inclusive end server ID")
    ap.add_argument(
        "--ids-file",
        type=Path,
        default=None,
        help="JSON {class:[ids]} or JSON [ids] or text of ids/ranges",
    )
    ap.add_argument(
        "--publish",
        action="store_true",
        help="Copy GIFs to html/images/items and WA_HTML_ROOT/images/items",
    )
    ap.add_argument(
        "--keep-pickable-filter",
        action="store_true",
        help="With --ids/--range/--from, still skip non-pickable (default: generate any mapped ID)",
    )
    args = ap.parse_args()

    if args.convert_only:
        src = args.convert_only
        if src.is_file() and src.suffix.lower() == ".zip":
            n = convert_zip(src, GIF_DIR)
        elif src.is_dir():
            n = convert_tree(src, GIF_DIR)
        else:
            print("Need ZIP or directory")
            return 1
        print(f"Converted {n} GIFs -> {GIF_DIR}")
        return 0

    only_ids: list[int] | None = None
    id_parts: list[str] = []
    file_ids: list[int] = []
    if args.ids_file is not None:
        if not args.ids_file.is_file():
            print("ids-file not found:", args.ids_file)
            return 1
        file_ids = load_ids_file(args.ids_file)
        if not file_ids:
            print("ids-file had no IDs")
            return 1
    if args.ids.strip():
        id_parts.append(args.ids.strip())
    if args.range_spec.strip():
        id_parts.append(args.range_spec.strip())
    if args.id_from is not None or args.id_to is not None:
        if args.id_from is None or args.id_to is None:
            print("Use both --from and --to together")
            return 1
        if args.id_to < args.id_from:
            print("--to must be >= --from")
            return 1
        id_parts.append(f"{args.id_from}-{args.id_to}")
    if id_parts:
        only_ids = parse_id_list(",".join(id_parts))
    if file_ids:
        only_ids = sorted(set((only_ids or []) + file_ids))
    if only_ids is not None:
        if not only_ids:
            print("No valid IDs parsed")
            return 1
        print(f"Target IDs: {len(only_ids)} ({only_ids[0]} .. {only_ids[-1]})", flush=True)

    zip_path = None
    if not args.skip_generate:
        # Default: pickable only. --all = everything. ID selection = those IDs (no pickable gate unless asked).
        if only_ids:
            only_pickable = bool(args.keep_pickable_filter)
        else:
            only_pickable = not args.all
        zip_path = run_generator(
            only_pickable=only_pickable,
            u32=not args.no_u32,
            alpha=bool(args.alpha),
            only_ids=only_ids,
        )
    else:
        zip_path = OUT / "download" / "items.zip"
        if not zip_path.is_file():
            print("No ZIP to convert")
            return 1

    n = convert_zip(zip_path, GIF_DIR)
    print(f"Done. {n} GIFs in {GIF_DIR}")
    if args.publish:
        pub = publish_gif_dir(GIF_DIR)
        print(f"Published: {pub['filesCopied']} copies across {pub['targets']} target dir(s)")
    else:
        print("Review locally, then copy to html/images/items/ and portal assets if OK.")
    return 0


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