#!/usr/bin/env python3
"""Write a full EEAE solved-scene timeline from one active mesh sidecar.

This script is intentionally a scene-series writer, not a final reconstruction
engine. It creates a viewer-ready timeline manifest and per-scene sidecars so
EEAE can scrub/play 0 Ma through maxSceneMa without using ad-hoc 000/005/010
buttons in the Sidecar Cabinet.

For now, scenes after the first step are direct look-ahead previews from the
active mesh. They reuse the best available authored zipper controls, usually
5 Ma controls, and scale closure by elapsed time. That is suitable for judging
large-scale progression, not for final locked reconstruction.

Outputs in --out-dir:
  build1.scene_000ma.preview.geojson
  build1.scene_000ma.groups.geojson
  build1.scene_005ma.preview.geojson
  build1.scene_005ma.groups.geojson
  build1.scene_005ma.diagnostics.json
  ... through maxMa

Optional debug mode can also write build1.scene_005ma.mesh.json, etc.,
but full timeline playback does not need per-frame solved mesh JSON files.
  build1.scene_series.manifest.json
"""

from __future__ import annotations

import argparse
import copy
import json
import math
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Dict, List, Optional, Sequence, Tuple

SCRIPT_DIR = Path(__file__).resolve().parent
STEP_SCRIPT = SCRIPT_DIR / "ee_solve_step.py"

# Authoritative EE radius table shared with ee_solve_step.py and the viewer.
EE_RADIUS_PERCENT_TABLE = [
    (0, 100.0), (5, 98.0), (10, 96.0), (15, 93.9), (20, 91.8),
    (25, 89.5), (30, 87.3), (35, 85.2), (40, 83.3), (45, 81.5),
    (50, 80.0), (55, 78.7), (60, 77.6), (65, 76.5), (70, 75.5),
    (75, 74.6), (80, 73.7), (85, 72.9), (90, 72.0), (95, 71.2),
    (100, 70.3), (105, 69.4), (110, 68.4), (115, 67.3),
    (120, 66.1), (125, 64.8), (130, 63.5), (135, 62.3),
    (140, 61.3), (145, 60.4), (150, 59.8), (155, 59.4),
    (160, 59.0), (165, 58.6), (170, 58.3), (175, 58.0),
    (180, 57.8), (185, 57.7), (190, 57.6),
]

# Reuse the existing group overlay slicer where possible so the 0 Ma group
# overlay follows the same feature/path mapping convention as solved previews.
try:
    from ee_solve_step import transform_project_group_segments_geojson, write_json, utc_now_iso
except Exception:  # pragma: no cover - helps when the step script is unavailable.
    transform_project_group_segments_geojson = None  # type: ignore[assignment]

    def write_json(path: Path, value: Any, pretty: bool = False) -> None:  # type: ignore[no-redef]
        path.parent.mkdir(parents=True, exist_ok=True)
        with path.open("w", encoding="utf-8") as f:
            json.dump(value, f, indent=2 if pretty else None)

    def utc_now_iso() -> str:  # type: ignore[no-redef]
        from datetime import datetime, timezone
        return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Write a full EEAE scene preview timeline.")
    parser.add_argument("--project", required=True, type=Path)
    parser.add_argument("--mesh", required=True, type=Path)
    parser.add_argument("--out-dir", required=True, type=Path)
    parser.add_argument("--series-manifest-out", required=True, type=Path)
    parser.add_argument("--from-ma", type=float, default=0.0)
    parser.add_argument("--max-ma", type=float, default=None)
    parser.add_argument("--step-ma", type=float, default=5.0)
    parser.add_argument("--project-base", default="build1")
    parser.add_argument("--include-zero", action="store_true", default=True)
    parser.add_argument("--no-include-zero", action="store_false", dest="include_zero")

    # Solver options forwarded to ee_solve_step.py.
    parser.add_argument("--radius0-km", type=float, default=6371.0)
    parser.add_argument("--radius-at-200ma-km", type=float, default=3500.0)
    parser.add_argument("--closure-fraction", type=float, default=1.0)
    parser.add_argument("--zipper-age-mode", default="auto")
    parser.add_argument("--zipper-age-ma", type=float, default=None)
    parser.add_argument("--zipper-age-scale", default="delta")
    parser.add_argument("--max-zipper-closure-fraction", type=float, default=None)
    parser.add_argument("--dynamic-age-targets", action="store_true", default=True)
    parser.add_argument("--no-dynamic-age-targets", action="store_false", dest="dynamic_age_targets")
    parser.add_argument("--dynamic-age-window-ma", type=float, default=0.75)
    parser.add_argument("--max-dynamic-anchors-per-group", type=int, default=0)
    parser.add_argument("--metric-surface-expansion", action="store_true", default=True)
    parser.add_argument("--no-metric-surface-expansion", action="store_false", dest="metric_surface_expansion")
    parser.add_argument("--metric-expansion-iterations", type=int, default=18)
    parser.add_argument("--metric-expansion-alpha", type=float, default=0.55)
    parser.add_argument("--metric-expansion-max-step-deg", type=float, default=1.25)
    parser.add_argument("--active-boundary-raw-export", action="store_true", default=True)
    parser.add_argument("--no-active-boundary-raw-export", action="store_false", dest="active_boundary_raw_export")
    parser.add_argument("--no-group-attachment", action="store_false", dest="group_attach_selected_segments", default=True)
    parser.add_argument("--group-attachment-blend", type=float, default=0.90)
    parser.add_argument("--continental-group-attachment-blend", type=float, default=1.0)
    parser.add_argument("--soft-group-attached-continental", action="store_true")
    parser.add_argument("--group-motion-min-anchors", type=int, default=1)
    parser.add_argument("--influence-sigma-km", type=float, default=900.0)
    parser.add_argument("--influence-cutoff-km", type=float, default=2600.0)
    parser.add_argument("--influence-k", type=int, default=12)
    parser.add_argument("--smoothing-iterations", type=int, default=8)
    parser.add_argument("--smoothing-alpha", type=float, default=0.42)
    parser.add_argument("--continental-scale", type=float, default=0.18)
    parser.add_argument("--young-continental-scale", type=float, default=0.72)
    parser.add_argument("--background-scale", type=float, default=0.55)
    parser.add_argument("--oceanic-scale", type=float, default=1.0)
    parser.add_argument("--mor-scale", type=float, default=0.0)
    parser.add_argument("--coastal-flex", action="store_true", default=True)
    parser.add_argument("--no-coastal-flex", action="store_false", dest="coastal_flex")
    parser.add_argument("--coastal-flex-width-km", type=float, default=850.0)
    parser.add_argument("--coastal-flex-scale", type=float, default=0.65)
    parser.add_argument("--coastal-flex-strength", type=float, default=1.0)
    parser.add_argument("--release-mor", action="store_true")
    parser.add_argument("--interpolation-k", type=int, default=10)
    parser.add_argument("--interpolation-power", type=float, default=1.5)
    parser.add_argument("--antimeridian-repair", action="store_true", default=True)
    parser.add_argument("--no-antimeridian-repair", action="store_false", dest="antimeridian_repair")
    parser.add_argument("--antimeridian-epsilon-deg", type=float, default=1e-6)
    parser.add_argument("--line-coherence-mode", default="hybrid")
    parser.add_argument("--line-rigid-blend", type=float, default=0.35)
    parser.add_argument("--line-smoothing-iterations", type=int, default=4)
    parser.add_argument("--line-smoothing-window", type=int, default=9)
    parser.add_argument("--move-mor-preview", action="store_true")
    parser.add_argument("--drop-younger-than-to-ma", action="store_true", default=True)
    parser.add_argument("--no-drop-younger-than-to-ma", action="store_false", dest="drop_younger_than_to_ma")
    parser.add_argument("--max-preview-features", type=int, default=0)
    parser.add_argument(
        "--write-scene-meshes",
        action="store_true",
        default=False,
        help="Write full per-frame solved mesh JSON sidecars. Off by default because timeline playback only needs preview/groups/diagnostics.",
    )
    parser.add_argument("--pretty", action="store_true")
    ns = parser.parse_args(argv)

    if ns.step_ma <= 0:
        parser.error("--step-ma must be positive.")
    if ns.max_ma is not None and ns.max_ma < ns.from_ma:
        parser.error("--max-ma must be greater than or equal to --from-ma.")
    if ns.dynamic_age_window_ma < 0:
        parser.error("--dynamic-age-window-ma must be non-negative.")
    if ns.max_dynamic_anchors_per_group < 0:
        parser.error("--max-dynamic-anchors-per-group must be non-negative.")
    if ns.group_attachment_blend < 0 or ns.group_attachment_blend > 1:
        parser.error("--group-attachment-blend must be between 0 and 1.")
    if ns.continental_group_attachment_blend < 0 or ns.continental_group_attachment_blend > 1:
        parser.error("--continental-group-attachment-blend must be between 0 and 1.")
    if ns.group_motion_min_anchors < 1:
        parser.error("--group-motion-min-anchors must be at least 1.")
    if ns.max_zipper_closure_fraction is None:
        # Let a 0->190 direct look-ahead preview reach full nominal closure when
        # reusing 5 Ma controls, but cap it enough to avoid accidental infinity.
        ns.max_zipper_closure_fraction = max(5.0, min(60.0, (ns.max_ma or 200.0) / max(1.0, ns.step_ma)))
    return ns


def main(argv: Optional[Sequence[str]] = None) -> int:
    ns = parse_args(argv)
    project = read_json(ns.project)
    mesh = read_json(ns.mesh)

    if project.get("schemaVersion") != "ee-project-v1":
        raise ValueError("Project must be an ee-project-v1 file.")
    if mesh.get("schemaVersion") not in {"ee-mesh-v1", "ee-solved-mesh-v1"}:
        raise ValueError("Mesh must be ee-mesh-v1 or ee-solved-mesh-v1.")

    project_meta = project.get("project") or {}
    max_ma = float(ns.max_ma if ns.max_ma is not None else project_meta.get("maxSceneMa", 190))
    step_ma = float(ns.step_ma or project_meta.get("intervalMa", 5) or 5)
    from_ma = float(ns.from_ma)
    ns.out_dir.mkdir(parents=True, exist_ok=True)
    ns.series_manifest_out.parent.mkdir(parents=True, exist_ok=True)

    scene_entries: List[Dict[str, Any]] = []
    generated_at = utc_now_iso()
    series_id = make_series_id(project_meta.get("id"), mesh.get("meshId"), from_ma, max_ma, step_ma)

    if ns.include_zero and from_ma == 0:
        zero_entry = write_zero_scene(project, ns, series_id)
        scene_entries.append(zero_entry)
        print(json.dumps({"event": "scene", "timeMa": 0, "status": "source", "preview": zero_entry.get("previewFile"), "groups": zero_entry.get("groupsFile")}), flush=True)

    target_ages = make_target_ages(from_ma, max_ma, step_ma)
    for index, to_ma in enumerate(target_ages, start=1):
        scene_tag = scene_ma_tag(to_ma)
        out_mesh = ns.out_dir / f"{ns.project_base}.scene_{scene_tag}.mesh.json" if ns.write_scene_meshes else None
        out_preview = ns.out_dir / f"{ns.project_base}.scene_{scene_tag}.preview.geojson"
        out_groups = ns.out_dir / f"{ns.project_base}.scene_{scene_tag}.groups.geojson"
        out_diag = ns.out_dir / f"{ns.project_base}.scene_{scene_tag}.diagnostics.json"

        cmd = build_step_command(ns, to_ma, out_mesh, out_preview, out_groups, out_diag)
        print(json.dumps({"event": "start-scene", "timeMa": to_ma, "index": index, "count": len(target_ages)}), flush=True)
        proc = subprocess.run(cmd, cwd=str(SCRIPT_DIR.parent), text=True, capture_output=True)
        if proc.stdout.strip():
            print(proc.stdout.strip(), flush=True)
        if proc.stderr.strip():
            print(proc.stderr.strip(), file=sys.stderr, flush=True)
        if proc.returncode != 0:
            raise RuntimeError(f"ee_solve_step.py exited with code {proc.returncode} while solving {to_ma:g} Ma")

        diagnostics = read_json(out_diag) if out_diag.exists() else {}
        frame_radius_km = radius_at_age(to_ma, ns.radius0_km, ns.radius_at_200ma_km)
        annotate_scene_geojson(out_preview, {
            "ee_sceneRadiusKm": frame_radius_km,
            "ee_sceneRadiusScale": frame_radius_km / ns.radius0_km if ns.radius0_km else 1,
            "ee_mathSpace": "shrinking-radius-sphere",
            "ee_renderSpace": "unit-sphere-lonlat",
        })
        annotate_scene_geojson(out_groups, {
            "ee_sceneRadiusKm": frame_radius_km,
            "ee_sceneRadiusScale": frame_radius_km / ns.radius0_km if ns.radius0_km else 1,
            "ee_mathSpace": "shrinking-radius-sphere",
            "ee_renderSpace": "unit-sphere-lonlat",
        })
        entry = {
            "timeMa": normalize_number(to_ma),
            "kind": "solved-preview",
            "status": diagnostics.get("status") or "prototype",
            "fromSceneMa": normalize_number(from_ma),
            "radiusKm": normalize_number(frame_radius_km),
            "radiusScale": frame_radius_km / ns.radius0_km if ns.radius0_km else 1,
            "previewFile": out_preview.name,
            "groupsFile": out_groups.name,
            "diagnosticsFile": out_diag.name,
            "summary": summarize_scene_diagnostics(diagnostics),
        }
        if out_mesh is not None:
            entry["meshFile"] = out_mesh.name
        scene_entries.append(entry)
        print(json.dumps({"event": "complete-scene", "timeMa": to_ma, "preview": out_preview.name, "groups": out_groups.name}), flush=True)

    manifest = {
        "schemaVersion": "ee-scene-series-v1",
        "seriesId": series_id,
        "generatedAtUtc": generated_at,
        "sourceProject": {
            "id": project_meta.get("id"),
            "name": project_meta.get("name"),
            "sourceName": project_meta.get("sourceName") or project.get("source", {}).get("sourceName"),
        },
        "sourceMeshId": mesh.get("meshId"),
        "mode": "direct-from-active-mesh-lookahead",
        "fromSceneMa": normalize_number(from_ma),
        "maxSceneMa": normalize_number(max_ma),
        "stepMa": normalize_number(step_ma),
        "sceneCount": len(scene_entries),
        "outputPolicy": {
            "writeSceneMeshes": bool(ns.write_scene_meshes),
            "defaultPlaybackArtifacts": ["previewGeoJson", "groupsGeoJson", "diagnosticsJson"],
            "note": "Full per-frame mesh JSON is disabled by default; timeline playback loads lightweight GeoJSON frames on demand. GeoJSON frames are still generated from the in-memory solved mesh after metric surface expansion.",
            "metricSurfaceExpansion": bool(ns.metric_surface_expansion),
        },
        "solverOptions": solver_options_for_manifest(ns),
        "scenes": scene_entries,
        "notes": [
            "This series is viewer-ready output for EEAE timeline playback.",
            "Scenes after the first authored target are direct look-ahead previews from the active mesh, not final locked reconstructions.",
            "Final reconstruction should promote/edit/lock each scene and rebuild from that accepted scene before solving the next step.",
        ],
    }
    write_json(ns.series_manifest_out, manifest, pretty=True)
    print(json.dumps({
        "seriesManifestOut": str(ns.series_manifest_out),
        "seriesId": series_id,
        "sourceMeshId": mesh.get("meshId"),
        "fromSceneMa": normalize_number(from_ma),
        "maxSceneMa": normalize_number(max_ma),
        "stepMa": normalize_number(step_ma),
        "sceneCount": len(scene_entries),
        "solvedSceneCount": len([s for s in scene_entries if s.get("kind") == "solved-preview"]),
        "writeSceneMeshes": bool(ns.write_scene_meshes),
    }, indent=2), flush=True)
    return 0


def build_step_command(ns: argparse.Namespace, to_ma: float, out_mesh: Optional[Path], out_preview: Path, out_groups: Path, out_diag: Path) -> List[str]:
    cmd = [
        sys.executable,
        str(STEP_SCRIPT),
        "--project", str(ns.project),
        "--mesh", str(ns.mesh),
        "--preview-out", str(out_preview),
        "--groups-out", str(out_groups),
        "--diag-out", str(out_diag),
        "--from-ma", str(ns.from_ma),
        "--to-ma", str(to_ma),
        "--radius0-km", str(ns.radius0_km),
        "--radius-at-200ma-km", str(ns.radius_at_200ma_km),
        "--closure-fraction", str(ns.closure_fraction),
        "--zipper-age-mode", str(ns.zipper_age_mode),
        "--zipper-age-scale", str(ns.zipper_age_scale),
        "--max-zipper-closure-fraction", str(ns.max_zipper_closure_fraction),
        "--dynamic-age-window-ma", str(ns.dynamic_age_window_ma),
        "--max-dynamic-anchors-per-group", str(ns.max_dynamic_anchors_per_group),
        "--metric-expansion-iterations", str(ns.metric_expansion_iterations),
        "--metric-expansion-alpha", str(ns.metric_expansion_alpha),
        "--metric-expansion-max-step-deg", str(ns.metric_expansion_max_step_deg),
        "--group-attachment-blend", str(ns.group_attachment_blend),
        "--continental-group-attachment-blend", str(ns.continental_group_attachment_blend),
        "--group-motion-min-anchors", str(ns.group_motion_min_anchors),
        "--influence-sigma-km", str(ns.influence_sigma_km),
        "--influence-cutoff-km", str(ns.influence_cutoff_km),
        "--influence-k", str(ns.influence_k),
        "--smoothing-iterations", str(ns.smoothing_iterations),
        "--smoothing-alpha", str(ns.smoothing_alpha),
        "--continental-scale", str(ns.continental_scale),
        "--young-continental-scale", str(ns.young_continental_scale),
        "--background-scale", str(ns.background_scale),
        "--oceanic-scale", str(ns.oceanic_scale),
        "--mor-scale", str(ns.mor_scale),
        "--coastal-flex-width-km", str(ns.coastal_flex_width_km),
        "--coastal-flex-scale", str(ns.coastal_flex_scale),
        "--coastal-flex-strength", str(ns.coastal_flex_strength),
        "--interpolation-k", str(ns.interpolation_k),
        "--interpolation-power", str(ns.interpolation_power),
        "--antimeridian-epsilon-deg", str(ns.antimeridian_epsilon_deg),
        "--line-coherence-mode", str(ns.line_coherence_mode),
        "--line-rigid-blend", str(ns.line_rigid_blend),
        "--line-smoothing-iterations", str(ns.line_smoothing_iterations),
        "--line-smoothing-window", str(ns.line_smoothing_window),
    ]
    if out_mesh is not None:
        cmd.extend(["--out-mesh", str(out_mesh)])

    if ns.zipper_age_ma is not None:
        cmd.extend(["--zipper-age-ma", str(ns.zipper_age_ma)])
    if ns.dynamic_age_targets:
        cmd.append("--dynamic-age-targets")
    else:
        cmd.append("--no-dynamic-age-targets")
    if ns.metric_surface_expansion:
        cmd.append("--metric-surface-expansion")
    else:
        cmd.append("--no-metric-surface-expansion")
    if ns.active_boundary_raw_export:
        cmd.append("--active-boundary-raw-export")
    else:
        cmd.append("--no-active-boundary-raw-export")
    if not ns.group_attach_selected_segments:
        cmd.append("--no-group-attachment")
    if ns.soft_group_attached_continental:
        cmd.append("--soft-group-attached-continental")
    if ns.coastal_flex:
        cmd.append("--coastal-flex")
    else:
        cmd.append("--no-coastal-flex")
    if ns.release_mor:
        cmd.append("--release-mor")
    if ns.antimeridian_repair:
        cmd.append("--antimeridian-repair")
    else:
        cmd.append("--no-antimeridian-repair")
    if ns.move_mor_preview:
        cmd.append("--move-mor-preview")
    if ns.drop_younger_than_to_ma:
        cmd.append("--drop-younger-than-to-ma")
    if ns.max_preview_features:
        cmd.extend(["--max-preview-features", str(ns.max_preview_features)])
    if ns.pretty:
        cmd.append("--pretty")
    return cmd


def write_zero_scene(project: Dict[str, Any], ns: argparse.Namespace, series_id: str) -> Dict[str, Any]:
    scene_tag = scene_ma_tag(0)
    preview_file = ns.out_dir / f"{ns.project_base}.scene_{scene_tag}.preview.geojson"
    groups_file = ns.out_dir / f"{ns.project_base}.scene_{scene_tag}.groups.geojson"
    diagnostics_file = ns.out_dir / f"{ns.project_base}.scene_{scene_tag}.diagnostics.json"

    zero_preview = make_zero_preview(project, series_id)
    write_json(preview_file, zero_preview, pretty=False)

    group_stats: Dict[str, Any] = {"featureCount": 0, "pathCount": 0, "vertexCount": 0}
    if transform_project_group_segments_geojson is not None:
        options = SimpleNamespace(from_ma=0.0, to_ma=0.0)
        zero_groups, group_stats = transform_project_group_segments_geojson(project, zero_preview, options)  # type: ignore[arg-type]
    else:
        zero_groups = {"type": "FeatureCollection", "features": [], "properties": {"schemaVersion": "ee-solved-groups-preview-v1", "toSceneMa": 0}}
    write_json(groups_file, zero_groups, pretty=False)

    preview_stats = geojson_line_summary(zero_preview)
    diagnostics = {
        "schemaVersion": "ee-scene-series-source-diagnostics-v1",
        "status": "source",
        "toSceneMa": 0,
        "previewStats": preview_stats,
        "groupPreviewStats": group_stats,
    }
    write_json(diagnostics_file, diagnostics, pretty=True)
    radius_km = radius_at_age(0, ns.radius0_km, ns.radius_at_200ma_km)
    annotate_scene_geojson(preview_file, {
        "ee_sceneRadiusKm": radius_km,
        "ee_sceneRadiusScale": radius_km / ns.radius0_km if ns.radius0_km else 1,
        "ee_mathSpace": "shrinking-radius-sphere",
        "ee_renderSpace": "unit-sphere-lonlat",
    })
    annotate_scene_geojson(groups_file, {
        "ee_sceneRadiusKm": radius_km,
        "ee_sceneRadiusScale": radius_km / ns.radius0_km if ns.radius0_km else 1,
        "ee_mathSpace": "shrinking-radius-sphere",
        "ee_renderSpace": "unit-sphere-lonlat",
    })
    return {
        "timeMa": 0,
        "kind": "source",
        "status": "source",
        "fromSceneMa": 0,
        "radiusKm": normalize_number(radius_km),
        "radiusScale": radius_km / ns.radius0_km if ns.radius0_km else 1,
        "previewFile": preview_file.name,
        "groupsFile": groups_file.name,
        "diagnosticsFile": diagnostics_file.name,
        "summary": {"previewStats": preview_stats, "groupPreviewStats": group_stats},
    }


def make_zero_preview(project: Dict[str, Any], series_id: str) -> Dict[str, Any]:
    source = copy.deepcopy(project.get("source", {}).get("geojson") or {"type": "FeatureCollection", "features": []})
    features = source.get("features")
    if not isinstance(features, list):
        features = []
        source["features"] = features
    for index, feature in enumerate(features):
        if not isinstance(feature, dict):
            continue
        props = dict(feature.get("properties") or {})
        feature["properties"] = {
            **props,
            "ee_scene_series_preview": True,
            "ee_sceneSeriesId": series_id,
            "ee_sourceFeatureIndex": index,
            "ee_fromMa": 0,
            "ee_toMa": 0,
            "ee_isSourceScene": True,
        }
        feature.setdefault("id", f"source_feature_{index:06d}")
    source["name"] = "ee_scene_000ma_source_preview"
    source["properties"] = {
        **dict(source.get("properties") or {}),
        "schemaVersion": "ee-scene-series-source-preview-v1",
        "toSceneMa": 0,
        "description": "0 Ma source scene copied into the solved scene timeline for playback.",
    }
    return source


def make_target_ages(from_ma: float, max_ma: float, step_ma: float) -> List[float]:
    ages: List[float] = []
    value = from_ma + step_ma
    while value <= max_ma + 1e-9:
        ages.append(round(value, 8))
        value += step_ma
    return ages


def summarize_scene_diagnostics(diagnostics: Dict[str, Any]) -> Dict[str, Any]:
    return {
        "status": diagnostics.get("status"),
        "solvedMeshId": diagnostics.get("solvedMeshId") or diagnostics.get("meshId"),
        "counts": diagnostics.get("counts"),
        "zipperSelection": diagnostics.get("zipperSelection"),
        "previewStats": diagnostics.get("previewStats"),
        "groupPreviewStats": diagnostics.get("groupPreviewStats"),
        "warnings": diagnostics.get("warnings") or [],
    }


def geojson_line_summary(raw: Dict[str, Any]) -> Dict[str, int]:
    features = raw.get("features") or []
    path_count = 0
    vertex_count = 0
    line_count = 0
    multi_count = 0
    for feature in features:
        geometry = (feature or {}).get("geometry") or {}
        gtype = geometry.get("type")
        coords = geometry.get("coordinates")
        if gtype == "LineString" and isinstance(coords, list):
            line_count += 1
            path_count += 1
            vertex_count += len(coords)
        elif gtype == "MultiLineString" and isinstance(coords, list):
            multi_count += 1
            for path in coords:
                if isinstance(path, list):
                    path_count += 1
                    vertex_count += len(path)
    return {"featureCount": len(features), "lineStringCount": line_count, "multiLineStringCount": multi_count, "pathCount": path_count, "vertexCount": vertex_count}


def radius_percent_at_age(age_ma: float) -> float:
    age = float(age_ma)
    if age <= EE_RADIUS_PERCENT_TABLE[0][0]:
        return EE_RADIUS_PERCENT_TABLE[0][1]
    if age >= EE_RADIUS_PERCENT_TABLE[-1][0]:
        return EE_RADIUS_PERCENT_TABLE[-1][1]
    for idx in range(1, len(EE_RADIUS_PERCENT_TABLE)):
        a_age, a_pct = EE_RADIUS_PERCENT_TABLE[idx - 1]
        b_age, b_pct = EE_RADIUS_PERCENT_TABLE[idx]
        if age <= b_age:
            t = 0.0 if b_age == a_age else (age - a_age) / (b_age - a_age)
            return a_pct + (b_pct - a_pct) * t
    return EE_RADIUS_PERCENT_TABLE[-1][1]


def radius_at_age(age_ma: float, radius0_km: float, radius_at_200ma_km: float) -> float:
    # Keep radius_at_200ma_km for CLI compatibility; the hardwired EE table is authoritative.
    _ = radius_at_200ma_km
    return float(radius0_km) * max(0.05, radius_percent_at_age(age_ma) / 100.0)


def annotate_scene_geojson(path: Path, properties: Dict[str, Any]) -> None:
    if not path.exists():
        return
    try:
        raw = read_json(path)
    except Exception:
        return
    raw_props = raw.get("properties") if isinstance(raw.get("properties"), dict) else {}
    raw["properties"] = {**raw_props, **properties}
    write_json(path, raw, pretty=False)


def solver_options_for_manifest(ns: argparse.Namespace) -> Dict[str, Any]:
    keys = [
        "radius0_km", "radius_at_200ma_km", "closure_fraction", "zipper_age_mode",
        "zipper_age_ma", "zipper_age_scale", "max_zipper_closure_fraction",
        "dynamic_age_targets", "dynamic_age_window_ma", "max_dynamic_anchors_per_group",
        "metric_surface_expansion", "metric_expansion_iterations", "metric_expansion_alpha",
        "metric_expansion_max_step_deg", "active_boundary_raw_export",
        "group_attach_selected_segments", "group_attachment_blend",
        "continental_group_attachment_blend", "soft_group_attached_continental",
        "group_motion_min_anchors",
        "influence_sigma_km", "influence_cutoff_km", "influence_k", "smoothing_iterations",
        "smoothing_alpha", "continental_scale", "young_continental_scale", "background_scale",
        "oceanic_scale", "mor_scale", "coastal_flex", "coastal_flex_width_km",
        "coastal_flex_scale", "coastal_flex_strength", "release_mor", "interpolation_k",
        "interpolation_power", "antimeridian_repair", "antimeridian_epsilon_deg",
        "line_coherence_mode", "line_rigid_blend", "line_smoothing_iterations",
        "line_smoothing_window", "move_mor_preview", "drop_younger_than_to_ma",
        "write_scene_meshes",
    ]
    out: Dict[str, Any] = {}
    for key in keys:
        value = getattr(ns, key, None)
        camel = snake_to_camel(key)
        if isinstance(value, float) and not math.isfinite(value):
            continue
        out[camel] = value
    return out


def snake_to_camel(value: str) -> str:
    parts = value.split("_")
    return parts[0] + "".join(part[:1].upper() + part[1:] for part in parts[1:])


def make_series_id(project_id: Any, mesh_id: Any, from_ma: float, max_ma: float, step_ma: float) -> str:
    import hashlib
    seed = f"{project_id}|{mesh_id}|{from_ma:g}|{max_ma:g}|{step_ma:g}"
    return "scene_series_" + hashlib.sha1(seed.encode("utf-8")).hexdigest()[:12]


def scene_ma_tag(value: float) -> str:
    numeric = float(value)
    whole = int(round(abs(numeric)))
    return f"{'neg_' if numeric < 0 else ''}{whole:03d}ma"


def normalize_number(value: float) -> int | float:
    rounded = round(float(value), 8)
    if abs(rounded - round(rounded)) < 1e-8:
        return int(round(rounded))
    return rounded


def read_json(path: Path) -> Dict[str, Any]:
    with path.open("r", encoding="utf-8") as f:
        return json.load(f)


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