from datetime import datetime, timedelta
from pathlib import Path
import json
import math
import os
import struct
import zlib

import mercantile
import numpy as np

from .mask_compositor import apply_mask_policy, build_land_mask_context, sample_land_mask
from .mask_compositor import build_coast_mask_cache
from .mask_policy import DISPLAY_MASK_POLICIES
from .product_definitions import get_product_definition
from src.geo_mask.coast_transition import DEFAULT_TRANSITION_RADIUS_BY_ZOOM, build_coast_transition_band

try:
    import cfgrib
    import xarray as xr
except ModuleNotFoundError:  # pragma: no cover - optional runtime dependency
    cfgrib = None
    xr = None

PROJECT_ROOT = Path(__file__).resolve().parents[2]
GRID_DIR = PROJECT_ROOT / "data" / "grid"
DISPLAY_GRIB_DIR = PROJECT_ROOT / "data" / "display" / "grib"
OUTPUT_ROOT = Path("/home/wwwroot/weather/display")
TILE_ROOT = OUTPUT_ROOT / "raster"
META_ROOT = OUTPUT_ROOT / "meta"
LEGEND_ROOT = OUTPUT_ROOT / "legend"
FRAME_INDEX_PATH = OUTPUT_ROOT / "frame-index.json"
PRODUCT_INDEX_PATH = OUTPUT_ROOT / "product-index.json"

DEFAULT_TILE_SIZE = 256
DEFAULT_ZOOMS = [2, 4, 6, 8]
JAPAN_DISPLAY_BOUNDS = (120.0, 20.0, 150.0, 50.0)
PRODUCT_FIELD_MAP = {
    "wind": "wind_speed",
    "wave": "wave_h",
    "rain": "rain",
    "pressure": "pressure",
}
RASTER_PRODUCTS = tuple(PRODUCT_FIELD_MAP.keys())
WAVE_SUPERSAMPLE_FACTOR = 2
GLOBAL_ZOOM_PRODUCTS = {"wind", "rain"}
GLOBAL_FIELD_CANDIDATES = {
    "wind": ("u10", "u", "v10", "v"),
    "rain": ("tp", "prate", "unknown"),
}


def ensure_directories():
    for path in (TILE_ROOT, META_ROOT, LEGEND_ROOT):
        path.mkdir(parents=True, exist_ok=True)


def get_zoom_levels():
    raw_value = os.environ.get("DISPLAY_RASTER_ZOOMS", "").strip()
    if not raw_value:
        return DEFAULT_ZOOMS

    zooms = []
    for chunk in raw_value.split(","):
        chunk = chunk.strip()
        if not chunk:
            continue
        zoom = int(chunk)
        if zoom < 0:
            raise ValueError(f"invalid display zoom: {zoom}")
        zooms.append(zoom)

    return sorted(set(zooms))


def zoom_display_bounds(data_bounds, zoom):
    if zoom <= 2:
        return data_bounds

    west, south, east, north = JAPAN_DISPLAY_BOUNDS
    data_west, data_south, data_east, data_north = data_bounds
    clipped = (
        max(west, data_west),
        max(south, data_south),
        min(east, data_east),
        min(north, data_north),
    )
    if clipped[0] >= clipped[2] or clipped[1] >= clipped[3]:
        return None
    return clipped


def grid_time_to_frame_key(grid_time):
    date, cycle, forecast = grid_time.split("_")
    cycle_time = datetime.strptime(f"{date}{cycle}", "%Y%m%d%H")
    forecast_hour = int(forecast.removeprefix("f"))
    frame_time = cycle_time + timedelta(hours=forecast_hour)
    return frame_time.strftime("%Y%m%dT%H%MZ")


def load_grid(path):
    with path.open(encoding="utf-8") as file_handle:
        payload = json.load(file_handle)

    grid = payload["grid"]
    lat = np.asarray(grid["lat"], dtype=np.float32)
    lon = np.asarray(grid["lon"], dtype=np.float32)
    fields = {
        product: np.asarray(grid[field_name], dtype=np.float32)
        for product, field_name in PRODUCT_FIELD_MAP.items()
    }
    return {
        "grid_time": payload["time"],
        "frame_key": grid_time_to_frame_key(payload["time"]),
        "lat": lat,
        "lon": lon,
        "fields": fields,
        "lon_min": float(lon[0]),
        "lon_max": float(lon[-1]),
        "lat_max": float(lat[0]),
        "lat_min": float(lat[-1]),
        "lat_step": float(abs(lat[0] - lat[1])) if len(lat) > 1 else 1.0,
        "lon_step": float(abs(lon[1] - lon[0])) if len(lon) > 1 else 1.0,
        "bounds": (
            float(lon[0]),
            float(lat[-1]),
            float(lon[-1]),
            float(lat[0]),
        ),
    }


def load_cfgrib_datasets(path):
    if cfgrib is None or xr is None:
        raise RuntimeError("cfgrib and xarray are required to render zoom 2 global wind/rain tiles")
    with xr.set_options(use_new_combine_kwarg_defaults=True):
        return cfgrib.xarray_store.open_datasets(str(path))


def find_dataset_variable(datasets, candidates):
    for candidate in candidates:
        for dataset in datasets:
            if candidate in dataset:
                return dataset[candidate]
    return None


def orient_lat_lon(lat_values, lon_values, fields):
    lat_values = np.asarray(lat_values, dtype=np.float32)
    lon_values = np.asarray(lon_values, dtype=np.float32)
    prepared_fields = [np.asarray(field, dtype=np.float32) for field in fields]

    normalized_lon = ((lon_values + 180.0) % 360.0) - 180.0
    lon_order = np.argsort(normalized_lon)
    normalized_lon = normalized_lon[lon_order]
    prepared_fields = [field[:, lon_order] for field in prepared_fields]

    if lat_values[0] < lat_values[-1]:
        lat_values = lat_values[::-1]
        prepared_fields = [field[::-1, :] for field in prepared_fields]

    return lat_values, normalized_lon, prepared_fields


def build_global_display_grid_info(grid_time):
    wind_path = DISPLAY_GRIB_DIR / f"{grid_time}.grib2"
    rain_path = DISPLAY_GRIB_DIR / f"{grid_time}_rain.grib2"
    if not wind_path.exists() or not rain_path.exists():
        return None

    wind_datasets = load_cfgrib_datasets(wind_path)
    rain_datasets = load_cfgrib_datasets(rain_path)
    try:
        coord_source = None
        for dataset in [*wind_datasets, *rain_datasets]:
            if "latitude" in dataset and "longitude" in dataset:
                coord_source = dataset
                break
        if coord_source is None:
            raise ValueError(f"missing latitude/longitude coordinates for {grid_time}")

        lat_values = coord_source["latitude"].values
        lon_values = coord_source["longitude"].values
        lat_size = len(lat_values)
        lon_size = len(lon_values)

        u10 = find_dataset_variable(wind_datasets, ("u10", "u"))
        v10 = find_dataset_variable(wind_datasets, ("v10", "v"))
        rain = find_dataset_variable(rain_datasets, ("tp", "prate", "unknown"))
        if u10 is None or v10 is None or rain is None:
            raise ValueError(f"missing zoom 2 global field(s) for {grid_time}")

        u10_values = np.asarray(u10.squeeze().values, dtype=np.float32).reshape(lat_size, lon_size)
        v10_values = np.asarray(v10.squeeze().values, dtype=np.float32).reshape(lat_size, lon_size)
        rain_values = np.asarray(rain.squeeze().values, dtype=np.float32).reshape(lat_size, lon_size)
        wind_values = np.sqrt(u10_values ** 2 + v10_values ** 2)

        lat_values, lon_values, fields = orient_lat_lon(lat_values, lon_values, [wind_values, rain_values])
        wind_values, rain_values = fields

        return {
            "lat": lat_values,
            "lon": lon_values,
            "fields": {
                "wind": wind_values,
                "rain": rain_values,
            },
            "lon_min": float(lon_values[0]),
            "lon_max": float(lon_values[-1]),
            "lat_max": float(lat_values[0]),
            "lat_min": float(lat_values[-1]),
            "lat_step": float(abs(lat_values[0] - lat_values[1])) if len(lat_values) > 1 else 1.0,
            "lon_step": float(abs(lon_values[1] - lon_values[0])) if len(lon_values) > 1 else 1.0,
            "bounds": (
                float(lon_values[0]),
                float(lat_values[-1]),
                float(lon_values[-1]),
                float(lat_values[0]),
            ),
        }
    finally:
        for dataset in wind_datasets:
            dataset.close()
        for dataset in rain_datasets:
            dataset.close()


def hex_to_rgba(hex_color, alpha=255):
    value = hex_color.lstrip("#")
    return np.array(
        [
            int(value[0:2], 16),
            int(value[2:4], 16),
            int(value[4:6], 16),
            alpha,
        ],
        dtype=np.uint8,
    )


def build_palette(color_stops):
    return [
        (float(stop["value"]), hex_to_rgba(stop["color"]))
        for stop in color_stops
    ]


def colorize(values, palette, transparent_mask):
    rgba = np.zeros(values.shape + (4,), dtype=np.uint8)

    if np.all(transparent_mask):
        return rgba

    for index, (stop_value, stop_color) in enumerate(palette):
        if index == 0:
            mask = (~transparent_mask) & (values <= stop_value)
            rgba[mask] = stop_color
            continue

        prev_value, prev_color = palette[index - 1]
        band_mask = (~transparent_mask) & (values > prev_value) & (values <= stop_value)
        if np.any(band_mask):
            ratio = (values[band_mask] - prev_value) / (stop_value - prev_value)
            start = prev_color.astype(np.float32)
            end = stop_color.astype(np.float32)
            rgba[band_mask] = np.round(start + (end - start) * ratio[:, None]).astype(np.uint8)

    upper_mask = (~transparent_mask) & (values > palette[-1][0])
    rgba[upper_mask] = palette[-1][1]
    return rgba


def apply_product_specific_transparency(product, values, rgba):
    if product == "rain":
        weak_mask = values < 0.1
        rgba[weak_mask] = np.array([0, 0, 0, 0], dtype=np.uint8)
    return rgba


def downsample_rgba(rgba, factor):
    if factor <= 1:
        return rgba

    height, width, _ = rgba.shape
    reduced_height = height // factor
    reduced_width = width // factor
    trimmed = rgba[: reduced_height * factor, : reduced_width * factor].astype(np.float32)
    reshaped = trimmed.reshape(reduced_height, factor, reduced_width, factor, 4)

    alpha = reshaped[:, :, :, :, 3] / 255.0
    alpha_sum = alpha.sum(axis=(1, 3))
    out_alpha = alpha.mean(axis=(1, 3))

    rgb = reshaped[:, :, :, :, :3]
    premultiplied = rgb * alpha[:, :, :, :, None]
    premultiplied_sum = premultiplied.sum(axis=(1, 3))

    out_rgb = np.zeros((reduced_height, reduced_width, 3), dtype=np.float32)
    valid = alpha_sum > 1e-6
    out_rgb[valid] = premultiplied_sum[valid] / alpha_sum[valid, None]

    output = np.zeros((reduced_height, reduced_width, 4), dtype=np.uint8)
    output[:, :, :3] = np.clip(np.round(out_rgb), 0, 255).astype(np.uint8)
    output[:, :, 3] = np.clip(np.round(out_alpha * 255.0), 0, 255).astype(np.uint8)
    return output


def render_wave_supersampled(grid_info, tile, definition, land_mask_context, factor=WAVE_SUPERSAMPLE_FACTOR):
    sample_size = DEFAULT_TILE_SIZE * factor
    palette = build_palette(definition["legend"]["color_stops"])
    values, transparent_mask = sample_grid(grid_info, "wave", tile, tile_size=sample_size)
    rgba = colorize(values, palette, transparent_mask)
    rgba = apply_product_specific_transparency("wave", values, rgba)

    lon_pixels, lat_pixels = tile_pixel_lon_lat(tile, sample_size)
    lon_grid, lat_grid = np.meshgrid(lon_pixels, lat_pixels)
    land_mask = sample_land_mask(land_mask_context, tile, lon_grid, lat_grid)
    coast_radius = DEFAULT_TRANSITION_RADIUS_BY_ZOOM.get(tile.z, 6.0) * factor
    coast_band = build_coast_transition_band(land_mask, coast_radius)
    rgba = apply_mask_policy("wave", rgba, land_mask, coast_band)

    return downsample_rgba(rgba, factor)


def png_chunk(chunk_type, data):
    return (
        struct.pack(">I", len(data))
        + chunk_type
        + data
        + struct.pack(">I", zlib.crc32(chunk_type + data) & 0xFFFFFFFF)
    )


def encode_png(rgba):
    height, width, _ = rgba.shape
    raw = b"".join(b"\x00" + rgba[row].tobytes() for row in range(height))
    ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
    return b"".join(
        [
            b"\x89PNG\r\n\x1a\n",
            png_chunk(b"IHDR", ihdr),
            png_chunk(b"IDAT", zlib.compress(raw, level=6)),
            png_chunk(b"IEND", b""),
        ]
    )


def tile_pixel_lon_lat(tile, tile_size):
    zoom_scale = 2 ** tile.z
    x_pixels = tile.x * tile_size + np.arange(tile_size, dtype=np.float64) + 0.5
    y_pixels = tile.y * tile_size + np.arange(tile_size, dtype=np.float64) + 0.5

    lon = x_pixels / (tile_size * zoom_scale) * 360.0 - 180.0
    mercator_y = math.pi * (1.0 - 2.0 * y_pixels / (tile_size * zoom_scale))
    lat = np.degrees(np.arctan(np.sinh(mercator_y)))
    return lon.astype(np.float32), lat.astype(np.float32)


def sample_grid(grid_info, product, tile, tile_size=DEFAULT_TILE_SIZE):
    lon_pixels, lat_pixels = tile_pixel_lon_lat(tile, tile_size)
    lon_grid, lat_grid = np.meshgrid(lon_pixels, lat_pixels)

    inside = (
        (lon_grid >= grid_info["lon_min"])
        & (lon_grid <= grid_info["lon_max"])
        & (lat_grid >= grid_info["lat_min"])
        & (lat_grid <= grid_info["lat_max"])
    )

    values = np.zeros((tile_size, tile_size), dtype=np.float32)
    if np.any(inside):
        row_pos = (grid_info["lat_max"] - lat_grid[inside]) / grid_info["lat_step"]
        col_pos = (lon_grid[inside] - grid_info["lon_min"]) / grid_info["lon_step"]

        row0 = np.floor(row_pos).astype(np.int32)
        col0 = np.floor(col_pos).astype(np.int32)
        row1 = np.clip(row0 + 1, 0, len(grid_info["lat"]) - 1)
        col1 = np.clip(col0 + 1, 0, len(grid_info["lon"]) - 1)
        row0 = np.clip(row0, 0, len(grid_info["lat"]) - 1)
        col0 = np.clip(col0, 0, len(grid_info["lon"]) - 1)

        row_weight = (row_pos - row0).astype(np.float32)
        col_weight = (col_pos - col0).astype(np.float32)

        field = grid_info["fields"][product]
        top_left = field[row0, col0]
        top_right = field[row0, col1]
        bottom_left = field[row1, col0]
        bottom_right = field[row1, col1]

        top = top_left * (1.0 - col_weight) + top_right * col_weight
        bottom = bottom_left * (1.0 - col_weight) + bottom_right * col_weight
        values[inside] = top * (1.0 - row_weight) + bottom * row_weight

    return values, ~inside


def sample_scalar_field(grid_info, field_name, lon, lat):
    if lon < grid_info["lon_min"] or lon > grid_info["lon_max"] or lat < grid_info["lat_min"] or lat > grid_info["lat_max"]:
        return None

    row_pos = (grid_info["lat_max"] - lat) / grid_info["lat_step"]
    col_pos = (lon - grid_info["lon_min"]) / grid_info["lon_step"]

    row0 = max(0, min(int(math.floor(row_pos)), len(grid_info["lat"]) - 1))
    col0 = max(0, min(int(math.floor(col_pos)), len(grid_info["lon"]) - 1))
    row1 = max(0, min(row0 + 1, len(grid_info["lat"]) - 1))
    col1 = max(0, min(col0 + 1, len(grid_info["lon"]) - 1))

    row_weight = float(row_pos - row0)
    col_weight = float(col_pos - col0)

    field = grid_info["fields"][field_name]
    top_left = field[row0, col0]
    top_right = field[row0, col1]
    bottom_left = field[row1, col0]
    bottom_right = field[row1, col1]

    top = top_left * (1.0 - col_weight) + top_right * col_weight
    bottom = bottom_left * (1.0 - col_weight) + bottom_right * col_weight
    return float(top * (1.0 - row_weight) + bottom * row_weight)


def alpha_blend_pixel(rgba, x, y, color):
    if x < 0 or y < 0 or x >= rgba.shape[1] or y >= rgba.shape[0]:
        return
    src_alpha = color[3] / 255.0
    if src_alpha <= 0:
        return
    dst = rgba[y, x].astype(np.float32)
    src = color.astype(np.float32)
    out_alpha = src_alpha + (dst[3] / 255.0) * (1.0 - src_alpha)
    if out_alpha <= 0:
        rgba[y, x] = np.array([0, 0, 0, 0], dtype=np.uint8)
        return
    out_rgb = (src[:3] * src_alpha + dst[:3] * (dst[3] / 255.0) * (1.0 - src_alpha)) / out_alpha
    rgba[y, x] = np.array(
        [
            int(np.clip(round(out_rgb[0]), 0, 255)),
            int(np.clip(round(out_rgb[1]), 0, 255)),
            int(np.clip(round(out_rgb[2]), 0, 255)),
            int(np.clip(round(out_alpha * 255.0), 0, 255)),
        ],
        dtype=np.uint8,
    )




def tile_output_path(product, frame_key, tile):
    return TILE_ROOT / product / frame_key / str(tile.z) / str(tile.x) / f"{tile.y}.png"


def write_tile(product, frame_key, tile, rgba):
    output_path = tile_output_path(product, frame_key, tile)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_bytes(encode_png(rgba))


def flatten_values(values):
    return np.asarray(values, dtype=np.float32).reshape(-1)


def write_json(path, payload):
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as file_handle:
        json.dump(payload, file_handle, ensure_ascii=False, indent=2)


def build_meta(product, frame_key, values):
    definition = get_product_definition(product)
    flat_values = flatten_values(values)
    mask_policy = DISPLAY_MASK_POLICIES.get(product, {"land": "normal", "sea": "normal"})
    return {
        "product": product,
        "time": frame_key,
        "unit": definition["unit"],
        "display_type": definition["display_type"],
        "palette_id": definition["palette_id"],
        "recommended_min": definition["recommended_range"]["min"],
        "recommended_max": definition["recommended_range"]["max"],
        "data_min": round(float(np.nanmin(flat_values)), 3),
        "data_max": round(float(np.nanmax(flat_values)), 3),
        "no_data": {
            "type": "transparent",
            "value": None,
            "description": "No display data is rendered for missing cells.",
        },
        "land_attenuation_mode": mask_policy["land"],
        "no_data_mode": "transparent",
        "render_resolution_class": "display-grid-bilinear-256px",
        "smoothing_class": "field-bilinear-no-image-blur",
        "coast_transition_mode": mask_policy.get("coast", "normal"),
        "mask_dependency": "/geo-mask/land-sea/{z}/{x}/{y}.png",
        "supported_zoom": definition["supported_zoom"],
        "opacity_suggestion": definition["opacity"],
        "path": definition["path_template"].format(time=frame_key, z="{z}", x="{x}", y="{y}"),
    }


def write_supporting_metadata(frame_keys, per_product_frames, sample_fields):
    unique_frame_keys = list(dict.fromkeys(frame_keys))
    unique_product_frames = {
        product: list(dict.fromkeys(frames))
        for product, frames in per_product_frames.items()
    }

    for product in RASTER_PRODUCTS:
        definition = get_product_definition(product)
        legend = definition["legend"]
        write_json(
            LEGEND_ROOT / f"{product}.json",
            {
                "product": product,
                "title": definition["title"],
                "subtitle": definition["subtitle"],
                "unit": definition["unit"],
                "scale_type": legend["scale_type"],
                "legend_sections": legend["sections"],
                "color_stops": legend["color_stops"],
            },
        )

        for frame_key, values in sample_fields[product].items():
            write_json(META_ROOT / product / f"{frame_key}.json", build_meta(product, frame_key, values))

    write_json(
        PRODUCT_INDEX_PATH,
        {
            "products": [
                {
                    "product": product,
                    "display_type": "raster",
                    "legend": f"/weather-display/legend/{product}",
                    "meta": f"/weather-display/meta/{product}" + "/{time}",
                }
                for product in RASTER_PRODUCTS
            ]
        },
    )
    write_json(
        FRAME_INDEX_PATH,
        {
            "available_frames": unique_frame_keys,
            "frame_step_hours": 3,
            "earliest": unique_frame_keys[0] if unique_frame_keys else None,
            "latest": unique_frame_keys[-1] if unique_frame_keys else None,
            "product_availability": unique_product_frames,
        },
    )


def build_land_mask_cache(bounds, zooms, land_mask_context):
    cache = {}
    if land_mask_context is None:
        return cache

    for zoom in zooms:
        zoom_bounds = zoom_display_bounds(bounds, zoom)
        if zoom_bounds is None:
            continue
        for tile in mercantile.tiles(*zoom_bounds, zooms=[zoom]):
            lon_pixels, lat_pixels = tile_pixel_lon_lat(tile, DEFAULT_TILE_SIZE)
            lon_grid, lat_grid = np.meshgrid(lon_pixels, lat_pixels)
            cache[(tile.z, tile.x, tile.y)] = sample_land_mask(land_mask_context, tile, lon_grid, lat_grid)

    return cache


def generate_tiles_for_grid(grid_info, zooms, land_mask_context, land_mask_cache, coast_mask_cache):
    frame_key = grid_info["frame_key"]
    global_grid_info = None
    if 2 in zooms:
        global_grid_info = build_global_display_grid_info(grid_info["grid_time"])

    for zoom in zooms:
        zoom_source_bounds = grid_info["bounds"]
        if zoom <= 2 and global_grid_info is not None:
            zoom_source_bounds = global_grid_info["bounds"]
        zoom_bounds = zoom_display_bounds(zoom_source_bounds, zoom)
        if zoom_bounds is None:
            continue
        for tile in mercantile.tiles(*zoom_bounds, zooms=[zoom]):
            land_mask = land_mask_cache.get((tile.z, tile.x, tile.y))
            if land_mask is None:
                if land_mask_context is not None:
                    lon_pixels, lat_pixels = tile_pixel_lon_lat(tile, DEFAULT_TILE_SIZE)
                    lon_grid, lat_grid = np.meshgrid(lon_pixels, lat_pixels)
                    land_mask = sample_land_mask(land_mask_context, tile, lon_grid, lat_grid)
                else:
                    land_mask = np.zeros((DEFAULT_TILE_SIZE, DEFAULT_TILE_SIZE), dtype=bool)
            coast_band = coast_mask_cache.get((tile.z, tile.x, tile.y))
            if coast_band is None:
                coast_radius = DEFAULT_TRANSITION_RADIUS_BY_ZOOM.get(tile.z, 6.0)
                coast_band = build_coast_transition_band(land_mask, coast_radius)

            for product in RASTER_PRODUCTS:
                source_grid_info = grid_info
                if zoom <= 2 and product in GLOBAL_ZOOM_PRODUCTS and global_grid_info is not None:
                    source_grid_info = global_grid_info

                definition = get_product_definition(product)
                if (
                    product == "wave"
                    and tile.z >= 6
                    and (np.any(land_mask) or np.any(coast_band > 0.01))
                ):
                    rgba = render_wave_supersampled(source_grid_info, tile, definition, land_mask_context)
                else:
                    palette = build_palette(definition["legend"]["color_stops"])
                    values, transparent_mask = sample_grid(source_grid_info, product, tile)
                    rgba = colorize(values, palette, transparent_mask)
                    rgba = apply_product_specific_transparency(product, values, rgba)
                    rgba = apply_mask_policy(product, rgba, land_mask, coast_band)
                if np.all(rgba[:, :, 3] == 0):
                    continue
                write_tile(product, frame_key, tile, rgba)


def generate_all():
    ensure_directories()
    zooms = get_zoom_levels()
    land_mask_context = build_land_mask_context()
    grid_paths = sorted(GRID_DIR.glob("grid_*.json"))
    preview_grid_info = load_grid(grid_paths[0]) if grid_paths else None
    land_mask_cache = build_land_mask_cache(preview_grid_info["bounds"], zooms, land_mask_context) if preview_grid_info else {}
    coast_mask_cache = build_coast_mask_cache(land_mask_cache)
    frame_keys = []
    per_product_frames = {product: [] for product in RASTER_PRODUCTS}
    sample_fields = {product: {} for product in RASTER_PRODUCTS}

    for path in grid_paths:
        grid_info = load_grid(path)
        frame_keys.append(grid_info["frame_key"])
        for product in RASTER_PRODUCTS:
            per_product_frames[product].append(grid_info["frame_key"])
            sample_fields[product][grid_info["frame_key"]] = grid_info["fields"][product]
        print("rendering", grid_info["grid_time"])
        generate_tiles_for_grid(grid_info, zooms, land_mask_context, land_mask_cache, coast_mask_cache)

    write_supporting_metadata(sorted(frame_keys), per_product_frames, sample_fields)


def main():
    generate_all()


if __name__ == "__main__":
    main()
