"""Extract the ERA5 lake: Earthmover Icechunk archive -> the Thermograph bucket.

Builds the lake that ``data/era5lake.py`` reads (layout documented there):
per-point serving files, the tile/year/month hive table, and the manifest.
Pulls are tile-aligned to the source's chunk grid (8736h x 12 x 12) — one
46-year pull (~19s measured) decodes all 144 points of a tile, so per-point
pulls would waste 144x the wire. Roughly: ~600 tiles cover the curated
cities (hours), ~3000 cover land (a day or two); both resume from the
manifest, so ctrl-C and re-run is always safe.

    pip install -r requirements-seed.txt 'numcodecs[pcodec]' boto3
    python gen_era5_lake.py --cities --dry-run      # one tile, no writes
    python gen_era5_lake.py --cities                # tiles covering cities.json
    python gen_era5_lake.py --tiles 20:30,40:60     # explicit tile-index window
    python gen_era5_lake.py --land                  # every land-containing tile

Destination: the bucket from THERMOGRAPH_LAKE_S3_* (see era5lake.s3_config),
or a local mirror when THERMOGRAPH_LAKE_LOCAL_DIR is set (tests/rehearsal).
Seed-only deps (icechunk/xarray/zarr) stay out of the app image, same policy
as seed_era5.py.
"""
import argparse
import concurrent.futures
import datetime
import io
import os
import sys
import time

import polars as pl

from data import cities
from data import climate
from data import era5lake
from seed_era5 import ERA5_BUCKET, ERA5_GROUP, ERA5_PREFIX, ERA5_REGION, ERA5_VARS, \
    hourly_to_daily

# The lake keeps each location's WHOLE available record (the store reaches
# back to 1940); the app slices to its own grading window (climate.START_DATE)
# at read time, so the extra decades are analytical surface, not a grading
# change.
START_DATE = "1940-01-01"


def _open_store():
    import icechunk           # noqa: PLC0415 - seed-only dep
    import xarray as xr       # noqa: PLC0415

    storage = icechunk.s3_storage(
        bucket=ERA5_BUCKET, prefix=ERA5_PREFIX, region=ERA5_REGION, anonymous=True)
    repo = icechunk.Repository.open(storage)
    return xr.open_zarr(repo.readonly_session("main").store,
                        group=ERA5_GROUP, consolidated=False)


class _Sink:
    """Writes lake objects to the bucket (boto3) or a local mirror dir."""

    def __init__(self):
        self.local = era5lake.local_dir()
        self.cfg = era5lake.s3_config()
        if not self.local and self.cfg is None:
            sys.exit("no destination: set THERMOGRAPH_LAKE_S3_* or "
                     "THERMOGRAPH_LAKE_LOCAL_DIR")
        self._s3 = None
        if not self.local:
            import boto3  # noqa: PLC0415 - extractor-only dep
            from botocore.config import Config  # noqa: PLC0415
            # Contabo throttles bursts with 429s (seen live at 16 concurrent
            # PUTs); adaptive mode rate-limits client-side and retries with
            # backoff instead of dying after botocore's default 4 attempts.
            self._s3 = boto3.client(
                "s3", endpoint_url=self.cfg["endpoint"],
                region_name=self.cfg["region"],
                aws_access_key_id=self.cfg["access_key"],
                aws_secret_access_key=self.cfg["secret_key"],
                config=Config(retries={"max_attempts": 10, "mode": "adaptive"}))

    def put(self, key: str, df: pl.DataFrame) -> None:
        if self.local:
            path = os.path.join(self.local, key)
            os.makedirs(os.path.dirname(path), exist_ok=True)
            df.write_parquet(path)
            return
        buf = io.BytesIO()
        df.write_parquet(buf)
        self._s3.put_object(Bucket=self.cfg["bucket"], Key=key,
                            Body=buf.getvalue())

    def read_manifest(self) -> "pl.DataFrame | None":
        try:
            if self.local:
                return pl.read_parquet(
                    os.path.join(self.local, era5lake.MANIFEST_KEY))
            obj = self._s3.get_object(Bucket=self.cfg["bucket"],
                                      Key=era5lake.MANIFEST_KEY)
            return pl.read_parquet(io.BytesIO(obj["Body"].read()))
        except Exception:  # noqa: BLE001 - a fresh lake has no manifest yet
            return None


def _tile_frame(ds, ti: int, tj: int, end: str) -> "pl.DataFrame | None":
    """Pull one 12x12 tile's hourly series and return the finalized daily frame
    for all its points (lat_idx, lon_idx, date, ...). None for all-sea tiles."""
    t = era5lake.TILE
    i0, j0 = ti * t, tj * t
    lat_sl = slice(i0, min(i0 + t, era5lake.LAT_N))
    lon_sl = slice(j0, j0 + t)
    if "lsm" in ds:            # land-sea mask: skip all-sea tiles outright
        lsm = ds["lsm"].isel(latitude=lat_sl, longitude=lon_sl)
        if "valid_time" in lsm.dims:   # static field, stored with a time axis
            lsm = lsm.isel(valid_time=-1)
        if float(lsm.max()) < 0.05:
            return None
    sub = (ds[list(ERA5_VARS)]
           .isel(latitude=lat_sl, longitude=lon_sl)
           .sel(valid_time=slice(START_DATE, end))
           .load())

    frames = []
    for a, lat in enumerate(sub.latitude.values):
        for b, lon in enumerate(sub.longitude.values):
            cols = {"time": sub.valid_time.values}
            for src, dst in ERA5_VARS.items():
                cols[dst] = sub[src].isel(latitude=a, longitude=b).values
            daily = climate._finalize_approximated(hourly_to_daily(pl.DataFrame(cols)))
            i, j = era5lake.to_idx(float(lat), float(lon))
            frames.append(daily.with_columns(
                pl.lit(i, dtype=pl.Int32).alias("lat_idx"),
                pl.lit(j, dtype=pl.Int32).alias("lon_idx")))
    return pl.concat(frames)


def _write_tile(sink: _Sink, ti: int, tj: int, tile_df: pl.DataFrame) -> pl.DataFrame:
    """Write one tile's point files + hive partitions; return its manifest rows.
    Uploads run on a thread pool: a tile is ~1.2k small objects and sequential
    PUTs are pure round-trip latency (measured 2.5 obj/s -> 8 min/tile; the
    pool brings that to well under a minute). boto3 clients are thread-safe."""
    puts = []
    rows = []
    for (i, j), pdf in tile_df.group_by(["lat_idx", "lon_idx"]):
        pdf = pdf.sort("date")
        puts.append((era5lake.point_key(i, j), pdf.drop(["lat_idx", "lon_idx"])))
        lat, lon = era5lake.to_coords(i, j)
        rows.append({"lat_idx": i, "lon_idx": j, "lat": lat, "lon": lon,
                     "tile": f"{ti}_{tj}", "rows": pdf.height,
                     "date_min": str(pdf["date"].min()),
                     "date_max": str(pdf["date"].max())})
    parts = tile_df.with_columns(
        pl.col("date").dt.year().alias("year"),
        pl.col("date").dt.month().alias("month"))
    for (year, month), mdf in parts.group_by(["year", "month"]):
        puts.append((era5lake.daily_part_key(ti, tj, year, month),
                     mdf.drop(["year", "month"]).sort(["lat_idx", "lon_idx", "date"])))
    with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
        for f in [pool.submit(sink.put, k, df) for k, df in puts]:
            f.result()   # surface the first failure instead of hiding it
    return pl.DataFrame(rows)


def _city_tiles() -> list:
    from data import grid  # noqa: PLC0415

    tiles = set()
    for c in cities.all_cities():
        cell = grid.snap(c["lat"], c["lon"])
        tiles.add(era5lake.tile_of(
            *era5lake.to_idx(cell["center_lat"], cell["center_lon"])))
    return sorted(tiles)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--cities", action="store_true")
    ap.add_argument("--land", action="store_true")
    ap.add_argument("--tiles", help="ti0:ti1,tj0:tj1 tile-index window")
    ap.add_argument("--limit", type=int)
    ap.add_argument("--overwrite", action="store_true")
    ap.add_argument("--dry-run", action="store_true")
    args = ap.parse_args()

    if args.cities:
        todo = _city_tiles()
    elif args.tiles:
        (a, b), (c, d) = (p.split(":") for p in args.tiles.split(","))
        todo = [(ti, tj) for ti in range(int(a), int(b))
                for tj in range(int(c), int(d))]
    elif args.land:
        todo = [(ti, tj) for ti in range(era5lake.LAT_N // era5lake.TILE + 1)
                for tj in range(era5lake.LON_N // era5lake.TILE)]
    else:
        ap.error("pick one of --cities / --tiles / --land")
    if args.limit:
        todo = todo[:args.limit]

    sink = _Sink()
    ds = _open_store()
    end = (datetime.date.today()
           - datetime.timedelta(days=climate.ARCHIVE_LATENCY_DAYS)).isoformat()

    manifest = sink.read_manifest()
    done = set() if manifest is None or args.overwrite else \
        set(manifest["tile"].unique().to_list())

    written = skipped = empty = failed = 0
    for n, (ti, tj) in enumerate(todo, 1):
        if f"{ti}_{tj}" in done:
            skipped += 1
            continue
        t0 = time.time()
        try:
            tile_df = _tile_frame(ds, ti, tj, end)
            if tile_df is None:
                empty += 1
                continue
            if args.dry_run:
                print(f"[dry-run] tile {ti}_{tj}: {tile_df.height} rows "
                      f"({time.time() - t0:.1f}s); nothing written")
                print(tile_df.head())
                return
            rows = _write_tile(sink, ti, tj, tile_df)
        except Exception as e:  # noqa: BLE001 - keep going; the tile isn't in the
            failed += 1         # manifest, so the next run retries it
            print(f"[{n}/{len(todo)}] FAILED tile {ti}_{tj}: {e}")
            continue
        manifest = rows if manifest is None else pl.concat(
            [manifest.filter(pl.col("tile") != f"{ti}_{tj}"), rows])
        sink.put(era5lake.MANIFEST_KEY, manifest)   # after every tile: resumable
        written += 1
        print(f"[{n}/{len(todo)}] tile {ti}_{tj}: {rows.height} points "
              f"in {time.time() - t0:.1f}s")
    print(f"done: tiles written={written} skipped(done)={skipped} sea={empty} "
          f"failed={failed}")


if __name__ == "__main__":
    main()
