#!/usr/bin/env python3
"""Build global editable master masks from per-tile mask files.

This script keeps 1025-tile semantics intact for river erosion while making
manual source/sink editing easier in one global image.
"""

from __future__ import annotations

import argparse
import json
import math
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, Optional

import numpy as np
from PIL import Image


@dataclass
class TileRef:
    key: str
    water_path: Path
    water_stem: str
    width: int
    height: int
    px: float
    py: float
    minx: float
    miny: float
    maxx: float
    maxy: float
    source_path: Optional[Path] = None
    sink_path: Optional[Path] = None


_TILE_KEY_RE = re.compile(r"^(dgm\d+_\d+_\d+_\d+)")


def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(description="Build master water/source/sink masks from tiled inputs.")
    p.add_argument("--water-dir", default="raw/water_masks", help="Directory with tile water masks.")
    p.add_argument(
        "--fallback-water-dir",
        default="work/river_masks",
        help="Fallback water mask directory used for tiles missing in --water-dir.",
    )
    p.add_argument("--source-dir", default="raw/water_source_masks", help="Directory with tile source masks.")
    p.add_argument("--sink-dir", default="raw/water_sink_masks", help="Directory with tile sink masks.")
    p.add_argument("--out-dir", default="work/mask_master", help="Output directory for master masks.")
    p.add_argument("--water-pattern", default="*_mask_viz.png", help="Glob for water mask files.")
    p.add_argument("--fallback-water-pattern", default="*_mask.png", help="Glob for fallback water mask files.")
    p.add_argument("--write-master-wld", action="store_true", help="Write worldfiles for master images.")
    return p.parse_args()


def tile_key_from_name(name: str) -> Optional[str]:
    stem = Path(name).stem
    m = _TILE_KEY_RE.match(stem)
    if not m:
        return None
    return m.group(1)


def read_worldfile(path: Path) -> tuple[float, float, float, float, float, float]:
    vals = []
    for line in path.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if line:
            vals.append(float(line))
    if len(vals) != 6:
        raise ValueError(f"Expected 6 values in worldfile {path}, got {len(vals)}.")
    return vals[0], vals[1], vals[2], vals[3], vals[4], vals[5]


def infer_bounds(width: int, height: int, wld: tuple[float, float, float, float, float, float]) -> tuple[float, float, float, float]:
    a, d, b, e, c, f = wld
    if abs(b) > 1e-9 or abs(d) > 1e-9:
        raise ValueError("Rotated worldfiles are not supported.")
    minx = c - (a / 2.0)
    maxy = f - (e / 2.0)
    maxx = minx + (a * width)
    miny = maxy + (e * height)
    return minx, miny, maxx, maxy


def load_rgb(path: Path, width: int, height: int) -> np.ndarray:
    img = Image.open(path).convert("RGB")
    arr = np.array(img, dtype=np.uint8)
    if arr.shape[0] == height and arr.shape[1] == width:
        return arr
    resized = img.resize((width, height), resample=Image.Resampling.NEAREST)
    return np.array(resized, dtype=np.uint8)


def write_worldfile(path: Path, px: float, py: float, minx: float, maxy: float) -> None:
    c = minx + (px / 2.0)
    f = maxy + (py / 2.0)
    text = "\n".join(
        [
            f"{px:.12f}",
            "0.0",
            "0.0",
            f"{py:.12f}",
            f"{c:.12f}",
            f"{f:.12f}",
        ]
    )
    path.write_text(text + "\n", encoding="utf-8")


def index_masks_by_tile(mask_dir: Path, suffix: str) -> Dict[str, Path]:
    out: Dict[str, Path] = {}
    if not mask_dir.exists():
        return out
    for p in sorted(mask_dir.glob(f"*{suffix}")):
        key = tile_key_from_name(p.name)
        if key is None:
            continue
        if key in out:
            print(f"[mask_build_master] Warning: duplicate {suffix} for {key}, keeping {out[key].name}, ignoring {p.name}")
            continue
        out[key] = p
    return out


def collect_tiles(water_dir: Path, water_pattern: str, source_dir: Path, sink_dir: Path) -> list[TileRef]:
    source_by_key = index_masks_by_tile(source_dir, ".png")
    sink_by_key = index_masks_by_tile(sink_dir, ".png")
    tiles: list[TileRef] = []

    for p in sorted(water_dir.glob(water_pattern)):
        if p.name.endswith(".tmp.png"):
            continue
        key = tile_key_from_name(p.name)
        if key is None:
            continue

        wld = p.with_suffix(".wld")
        if not wld.exists():
            print(f"[mask_build_master] Skipping {p.name}: missing worldfile {wld.name}")
            continue

        img = Image.open(p)
        width, height = img.size
        img.close()

        a, d, b, e, c, f = read_worldfile(wld)
        if abs(b) > 1e-9 or abs(d) > 1e-9:
            raise SystemExit(f"[mask_build_master] Rotated worldfile is not supported: {wld}")
        if a <= 0 or e >= 0:
            raise SystemExit(f"[mask_build_master] Unexpected worldfile pixel size signs in {wld}")

        minx, miny, maxx, maxy = infer_bounds(width, height, (a, d, b, e, c, f))
        tiles.append(
            TileRef(
                key=key,
                water_path=p,
                water_stem=p.stem,
                width=width,
                height=height,
                px=a,
                py=e,
                minx=minx,
                miny=miny,
                maxx=maxx,
                maxy=maxy,
                source_path=source_by_key.get(key),
                sink_path=sink_by_key.get(key),
            )
        )
    return tiles


def collect_fallback_tiles(
    fallback_dir: Path,
    fallback_pattern: str,
    existing_keys: set[str],
    source_dir: Path,
    sink_dir: Path,
) -> list[TileRef]:
    source_by_key = index_masks_by_tile(source_dir, ".png")
    sink_by_key = index_masks_by_tile(sink_dir, ".png")
    tiles: list[TileRef] = []
    if not fallback_dir.exists():
        return tiles

    for p in sorted(fallback_dir.glob(fallback_pattern)):
        key = tile_key_from_name(p.name)
        if key is None or key in existing_keys:
            continue
        if p.name.endswith(".tmp.png"):
            continue

        wld = p.with_suffix(".wld")
        if not wld.exists():
            print(f"[mask_build_master] Skipping fallback {p.name}: missing worldfile {wld.name}")
            continue

        img = Image.open(p)
        width, height = img.size
        img.close()

        a, d, b, e, c, f = read_worldfile(wld)
        if abs(b) > 1e-9 or abs(d) > 1e-9:
            raise SystemExit(f"[mask_build_master] Rotated worldfile is not supported: {wld}")
        if a <= 0 or e >= 0:
            raise SystemExit(f"[mask_build_master] Unexpected worldfile pixel size signs in {wld}")

        minx, miny, maxx, maxy = infer_bounds(width, height, (a, d, b, e, c, f))
        tiles.append(
            TileRef(
                key=key,
                water_path=p,
                water_stem=p.stem,
                width=width,
                height=height,
                px=a,
                py=e,
                minx=minx,
                miny=miny,
                maxx=maxx,
                maxy=maxy,
                source_path=source_by_key.get(key),
                sink_path=sink_by_key.get(key),
            )
        )
    return tiles


def check_resolution_consistency(tiles: Iterable[TileRef]) -> tuple[float, float]:
    pxs = [t.px for t in tiles]
    pys = [t.py for t in tiles]
    px = float(np.median(pxs))
    py = float(np.median(pys))
    for t in tiles:
        if not math.isclose(t.px, px, rel_tol=0.0, abs_tol=1e-9):
            raise SystemExit(f"[mask_build_master] Inconsistent px: {t.water_path} has {t.px}, expected {px}")
        if not math.isclose(t.py, py, rel_tol=0.0, abs_tol=1e-9):
            raise SystemExit(f"[mask_build_master] Inconsistent py: {t.water_path} has {t.py}, expected {py}")
    return px, py


def merge_non_black(dst: np.ndarray, src: np.ndarray) -> None:
    nz = np.any(src != 0, axis=2)
    dst[nz] = src[nz]


def main() -> int:
    args = parse_args()

    water_dir = Path(args.water_dir)
    fallback_water_dir = Path(args.fallback_water_dir)
    source_dir = Path(args.source_dir)
    sink_dir = Path(args.sink_dir)
    out_dir = Path(args.out_dir)

    if not water_dir.exists():
        raise SystemExit(f"[mask_build_master] water-dir not found: {water_dir}")

    primary_tiles = collect_tiles(water_dir, args.water_pattern, source_dir, sink_dir)
    fallback_tiles = collect_fallback_tiles(
        fallback_water_dir,
        args.fallback_water_pattern,
        {t.key for t in primary_tiles},
        source_dir,
        sink_dir,
    )
    tiles = primary_tiles + fallback_tiles
    if not tiles:
        raise SystemExit("[mask_build_master] No water tiles found.")

    px, py = check_resolution_consistency(tiles)
    minx = min(t.minx for t in tiles)
    miny = min(t.miny for t in tiles)
    maxx = max(t.maxx for t in tiles)
    maxy = max(t.maxy for t in tiles)

    master_w = int(round((maxx - minx) / px))
    master_h = int(round((maxy - miny) / abs(py)))
    if master_w <= 0 or master_h <= 0:
        raise SystemExit("[mask_build_master] Invalid master size.")

    water_master = np.zeros((master_h, master_w, 3), dtype=np.uint8)
    source_master = np.zeros((master_h, master_w, 3), dtype=np.uint8)
    sink_master = np.zeros((master_h, master_w, 3), dtype=np.uint8)

    tile_rows = []
    for t in sorted(tiles, key=lambda k: (k.key, k.minx, k.miny)):
        xoff = int(round((t.minx - minx) / px))
        yoff = int(round((maxy - t.maxy) / abs(py)))

        if xoff < 0 or yoff < 0 or xoff + t.width > master_w or yoff + t.height > master_h:
            raise SystemExit(f"[mask_build_master] Tile placement out of bounds for {t.water_path}")

        w_arr = load_rgb(t.water_path, t.width, t.height)
        merge_non_black(water_master[yoff : yoff + t.height, xoff : xoff + t.width], w_arr)

        if t.source_path and t.source_path.exists():
            s_arr = load_rgb(t.source_path, t.width, t.height)
            merge_non_black(source_master[yoff : yoff + t.height, xoff : xoff + t.width], s_arr)

        if t.sink_path and t.sink_path.exists():
            k_arr = load_rgb(t.sink_path, t.width, t.height)
            merge_non_black(sink_master[yoff : yoff + t.height, xoff : xoff + t.width], k_arr)

        tile_rows.append(
            {
                "key": t.key,
                "water_file": t.water_path.name,
                "water_stem": t.water_stem,
                "source_file": t.source_path.name if t.source_path else "",
                "sink_file": t.sink_path.name if t.sink_path else "",
                "xoff": xoff,
                "yoff": yoff,
                "width": t.width,
                "height": t.height,
                "minx": t.minx,
                "miny": t.miny,
                "maxx": t.maxx,
                "maxy": t.maxy,
            }
        )

    out_dir.mkdir(parents=True, exist_ok=True)
    water_out = out_dir / "water_master.png"
    source_out = out_dir / "source_master.png"
    sink_out = out_dir / "sink_master.png"
    Image.fromarray(water_master, mode="RGB").save(water_out)
    Image.fromarray(source_master, mode="RGB").save(source_out)
    Image.fromarray(sink_master, mode="RGB").save(sink_out)

    if args.write_master_wld:
        write_worldfile(water_out.with_suffix(".wld"), px, py, minx, maxy)
        write_worldfile(source_out.with_suffix(".wld"), px, py, minx, maxy)
        write_worldfile(sink_out.with_suffix(".wld"), px, py, minx, maxy)

    meta = {
        "schema_version": 1,
        "master": {
            "width": master_w,
            "height": master_h,
            "minx": minx,
            "miny": miny,
            "maxx": maxx,
            "maxy": maxy,
            "px": px,
            "py": py,
            "water_master": water_out.name,
            "source_master": source_out.name,
            "sink_master": sink_out.name,
        },
        "inputs": {
            "water_dir": str(water_dir),
            "fallback_water_dir": str(fallback_water_dir),
            "source_dir": str(source_dir),
            "sink_dir": str(sink_dir),
            "water_pattern": args.water_pattern,
            "fallback_water_pattern": args.fallback_water_pattern,
        },
        "tiles": tile_rows,
    }
    (out_dir / "master_meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")

    print(f"[mask_build_master] Wrote {water_out}")
    print(f"[mask_build_master] Wrote {source_out}")
    print(f"[mask_build_master] Wrote {sink_out}")
    print(f"[mask_build_master] Wrote {out_dir / 'master_meta.json'}")
    print(f"[mask_build_master] Master size: {master_w}x{master_h}")
    print(f"[mask_build_master] Tiles merged: {len(tile_rows)}")
    print(f"[mask_build_master]   from primary: {len(primary_tiles)}")
    print(f"[mask_build_master]   from fallback: {len(fallback_tiles)}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
