from __future__ import annotations

import argparse
import ast
import hashlib
import json
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import numpy as np
import pandas as pd
import tifffile
from scipy.ndimage import map_coordinates

from .io import save_image, write_json, write_jsonl


@dataclass
class MovieSpec:
    path: Path
    movie: np.ndarray
    axes: str
    t_axis: int
    c_axis: int | None
    y_axis: int
    x_axis: int
    n_frames: int
    n_channels: int


def _safe_json_loads(value: Any) -> Any:
    if value is None:
        return None
    if isinstance(value, float) and math.isnan(value):
        return None
    if isinstance(value, (list, tuple, dict)):
        return value
    if not isinstance(value, str):
        return None
    txt = value.strip()
    if not txt:
        return None
    try:
        return json.loads(txt)
    except Exception:
        try:
            return ast.literal_eval(txt)
        except Exception:
            return None


def _pick_anchor_cell(row: pd.Series) -> Any:
    # New Tracy export uses "Kymo-Anchors"; older exports used "Anchors".
    for key in ("Kymo-Anchors", "Anchors"):
        if key not in row.index:
            continue
        val = row.get(key)
        if val is None:
            continue
        if isinstance(val, float) and math.isnan(val):
            continue
        if isinstance(val, str) and not val.strip():
            continue
        return val
    return None


def _canonicalize_roi(roi_raw: Any) -> dict[str, Any] | None:
    if not isinstance(roi_raw, dict):
        return None
    xs = roi_raw.get("x")
    ys = roi_raw.get("y")
    if xs is None or ys is None:
        return None
    try:
        xs_f = [float(v) for v in xs]
        ys_f = [float(v) for v in ys]
    except Exception:
        return None
    if len(xs_f) < 2 or len(ys_f) < 2 or len(xs_f) != len(ys_f):
        return None
    points = roi_raw.get("points")
    if isinstance(points, list) and points:
        pts = []
        for p in points:
            if not isinstance(p, (list, tuple)) or len(p) < 2:
                continue
            try:
                pts.append((float(p[0]), float(p[1])))
            except Exception:
                continue
        points_f = pts
    else:
        points_f = [(float(x), float(y)) for x, y in zip(xs_f, ys_f)]

    return {
        "type": str(roi_raw.get("type", "line")),
        "x": xs_f,
        "y": ys_f,
        "points": points_f,
    }


def _roi_key(roi: dict[str, Any]) -> str:
    payload = {
        "x": [round(float(v), 5) for v in roi.get("x", [])],
        "y": [round(float(v), 5) for v in roi.get("y", [])],
    }
    return json.dumps(payload, sort_keys=True, separators=(",", ":"))


def _infer_axes(movie: np.ndarray, axes_meta: str | None) -> str:
    ndim = movie.ndim
    if axes_meta:
        axes = axes_meta.strip().upper()
        if len(axes) == ndim:
            return axes
    if ndim == 3:
        return "TYX"
    if ndim == 4:
        return "TCYX"
    raise ValueError(f"Unsupported movie ndim={ndim}; expected 3D or 4D")


def _axis_positions(axes: str, ndim: int) -> tuple[int, int | None, int, int]:
    a = axes.upper()
    if len(a) != ndim:
        raise ValueError(f"axes='{axes}' length must match ndim={ndim}")
    t_axis = a.find("T")
    if t_axis < 0:
        t_axis = 0
    c_axis = a.find("C")
    y_axis = a.find("Y")
    x_axis = a.find("X")
    if y_axis < 0 or x_axis < 0:
        y_axis, x_axis = ndim - 2, ndim - 1
    return t_axis, (c_axis if c_axis >= 0 else None), y_axis, x_axis


def _load_movie_spec(path: Path) -> MovieSpec:
    with tifffile.TiffFile(path) as tif:
        movie = tif.asarray()
        ij = tif.imagej_metadata or {}
        axes_meta = ij.get("axes")
    axes = _infer_axes(movie, axes_meta)
    t_axis, c_axis, y_axis, x_axis = _axis_positions(axes, movie.ndim)
    n_frames = int(movie.shape[t_axis])
    n_channels = int(movie.shape[c_axis]) if c_axis is not None else 1
    return MovieSpec(
        path=path,
        movie=np.asarray(movie),
        axes=axes,
        t_axis=t_axis,
        c_axis=c_axis,
        y_axis=y_axis,
        x_axis=x_axis,
        n_frames=n_frames,
        n_channels=n_channels,
    )


def _extract_frame_2d(spec: MovieSpec, frame_idx: int, channel_1based: int) -> np.ndarray:
    if frame_idx < 0 or frame_idx >= spec.n_frames:
        raise IndexError(frame_idx)
    channel_idx = max(0, int(channel_1based) - 1)
    if spec.c_axis is None:
        channel_idx = 0
    else:
        channel_idx = int(np.clip(channel_idx, 0, spec.n_channels - 1))

    idx: list[Any] = [0] * spec.movie.ndim
    idx[spec.t_axis] = frame_idx
    if spec.c_axis is not None:
        idx[spec.c_axis] = channel_idx
    idx[spec.y_axis] = slice(None)
    idx[spec.x_axis] = slice(None)

    frame = spec.movie[tuple(idx)]
    frame = np.asarray(frame)
    if frame.ndim != 2:
        frame = np.squeeze(frame)
    if frame.ndim != 2:
        raise ValueError(f"Expected 2D frame after slicing, got shape {frame.shape}")
    return frame


def _build_kymograph(spec: MovieSpec, roi: dict[str, Any], channel_1based: int, line_width: int, line_method: str) -> np.ndarray:
    xs = np.asarray(roi["x"], dtype=float)
    ys = np.asarray(roi["y"], dtype=float)
    d = np.sqrt(np.diff(xs) ** 2 + np.diff(ys) ** 2)
    cum_dist = np.concatenate(([0.0], np.cumsum(d)))
    total_length = float(cum_dist[-1]) if cum_dist.size else 0.0
    num_samples = max(int(total_length), 2)
    sample_pos = np.linspace(0.0, total_length, num_samples, endpoint=True)
    sample_x = np.interp(sample_pos, cum_dist, xs)
    sample_y = np.interp(sample_pos, cum_dist, ys)

    tangent_dx = np.gradient(sample_x)
    tangent_dy = np.gradient(sample_y)
    normal_x = -tangent_dy
    normal_y = tangent_dx
    norm = np.sqrt(normal_x**2 + normal_y**2)
    norm[norm == 0] = 1.0
    normal_x = normal_x / norm
    normal_y = normal_y / norm

    offsets = np.arange(-line_width, line_width + 1, dtype=float)
    sx_full = sample_x[:, None] + normal_x[:, None] * offsets[None, :]
    sy_full = sample_y[:, None] + normal_y[:, None] * offsets[None, :]
    coords = np.vstack((sy_full.ravel(), sx_full.ravel()))

    rows = []
    method = (line_method or "max").lower()
    for t in range(spec.n_frames):
        frame = _extract_frame_2d(spec, t, channel_1based=channel_1based)
        vals = map_coordinates(frame, coords, order=1, mode="reflect")
        vals = vals.reshape(num_samples, offsets.size)
        profile = np.mean(vals, axis=1) if method == "average" else np.max(vals, axis=1)
        rows.append(profile.astype(np.float32))
    return np.vstack(rows).astype(np.float32)


def _roi_cache(roi: dict[str, Any]) -> tuple[np.ndarray, np.ndarray, np.ndarray, float]:
    xr = np.asarray(roi["x"], dtype=float)
    yr = np.asarray(roi["y"], dtype=float)
    if xr.size < 2:
        return xr, yr, np.array([0.0], dtype=float), 0.0
    seg_lengths = np.hypot(np.diff(xr), np.diff(yr))
    cumulative = np.concatenate(([0.0], np.cumsum(seg_lengths)))
    return xr, yr, cumulative, float(cumulative[-1])


def _kymo_x_from_movie_point(
    roi_cache: tuple[np.ndarray, np.ndarray, np.ndarray, float],
    x_orig: float,
    y_orig: float,
    kymo_width: int,
) -> float | None:
    xr, yr, cum, total = roi_cache
    if total <= 0 or kymo_width <= 1:
        return None
    best_dist = float("inf")
    best_along = 0.0
    for i in range(len(xr) - 1):
        x_a, y_a = xr[i], yr[i]
        x_b, y_b = xr[i + 1], yr[i + 1]
        vx = x_b - x_a
        vy = y_b - y_a
        seg_len_sq = vx * vx + vy * vy
        if seg_len_sq <= 0:
            continue
        t = ((x_orig - x_a) * vx + (y_orig - y_a) * vy) / seg_len_sq
        t = float(np.clip(t, 0.0, 1.0))
        xp = x_a + t * vx
        yp = y_a + t * vy
        d2 = (xp - x_orig) ** 2 + (yp - y_orig) ** 2
        if d2 < best_dist:
            best_dist = d2
            best_along = float(cum[i] + t * math.sqrt(seg_len_sq))
    return float((best_along / total) * float(kymo_width))


def _dedupe_frame_points(points: list[tuple[int, float, float]]) -> list[tuple[int, float, float]]:
    if not points:
        return []
    points = sorted(points, key=lambda p: p[0])
    out: list[tuple[int, float, float]] = []
    cur_f = points[0][0]
    xs: list[float] = []
    ys: list[float] = []
    for f, x, y in points:
        if f != cur_f:
            out.append((cur_f, float(np.mean(xs)), float(np.mean(ys))))
            cur_f = f
            xs = [x]
            ys = [y]
        else:
            xs.append(x)
            ys.append(y)
    out.append((cur_f, float(np.mean(xs)), float(np.mean(ys))))
    return out


def _pick_xy_from_datapoint_row(row: pd.Series, point_source: str) -> tuple[float, float, str] | None:
    src = (point_source or "auto").strip().lower()
    if src == "spot":
        order = [("Spot Center X", "Spot Center Y", "spot")]
    elif src == "search":
        order = [("Search Center X", "Search Center Y", "search")]
    elif src == "original":
        order = [("Original Coordinate X", "Original Coordinate Y", "original")]
    else:
        # auto: prefer fitted spot centers, then search centers, then original coords.
        order = [
            ("Spot Center X", "Spot Center Y", "spot"),
            ("Search Center X", "Search Center Y", "search"),
            ("Original Coordinate X", "Original Coordinate Y", "original"),
        ]

    for x_col, y_col, tag in order:
        if x_col not in row.index or y_col not in row.index:
            continue
        xv = row.get(x_col)
        yv = row.get(y_col)
        if pd.isna(xv) or pd.isna(yv):
            continue
        try:
            x = float(xv)
            y = float(yv)
        except Exception:
            continue
        if not (math.isfinite(x) and math.isfinite(y)):
            continue
        return x, y, tag
    return None


def _parse_datapoints_sheet(
    df: pd.DataFrame,
    *,
    point_source: str = "auto",
) -> tuple[dict[int, list[tuple[int, float, float]]], dict[str, int]]:
    if "Trajectory" not in df.columns or "Frame" not in df.columns:
        return {}, {"spot": 0, "search": 0, "original": 0}

    out: dict[int, list[tuple[int, float, float]]] = {}
    source_counts = {"spot": 0, "search": 0, "original": 0}
    for tid, grp in df.groupby("Trajectory"):
        try:
            traj_id = int(round(float(tid)))
        except Exception:
            continue
        rows = grp.copy().sort_values("Frame")
        frames_raw = []
        parsed_rows: list[tuple[int, float, float]] = []
        for _, r in rows.iterrows():
            f_raw = r.get("Frame")
            try:
                f = int(round(float(f_raw)))
            except Exception:
                continue
            picked = _pick_xy_from_datapoint_row(r, point_source=point_source)
            if picked is None:
                continue
            x, y, used = picked
            if used in source_counts:
                source_counts[used] += 1
            frames_raw.append(f)
            parsed_rows.append((f, x, y))

        if not parsed_rows:
            continue
        has_zero = any(f == 0 for f in frames_raw)
        shifted = []
        for f, x, y in parsed_rows:
            f0 = f - 1 if (f >= 1 and not has_zero) else f
            shifted.append((int(f0), float(x), float(y)))
        out[traj_id] = _dedupe_frame_points(shifted)
    return out, source_counts


def _draw_segment(mask: np.ndarray, t0: float, x0: float, t1: float, x1: float, thickness: int) -> None:
    dt = float(t1 - t0)
    dx = float(x1 - x0)
    steps = int(max(abs(dt), abs(dx))) + 1
    if steps < 2:
        steps = 2
    rr = max(0, int(thickness // 2))

    for i in range(steps):
        a = i / (steps - 1)
        t = t0 + a * dt
        x = x0 + a * dx
        ti = int(round(t))
        xj = int(round(x))
        tmin = max(0, ti - rr)
        tmax = min(mask.shape[0], ti + rr + 1)
        xmin = max(0, xj - rr)
        xmax = min(mask.shape[1], xj + rr + 1)
        if tmin >= tmax or xmin >= xmax:
            continue
        yy, xx = np.ogrid[tmin:tmax, xmin:xmax]
        disk = (yy - t) ** 2 + (xx - x) ** 2 <= rr**2 if rr > 0 else np.ones((tmax - tmin, xmax - xmin), dtype=bool)
        mask[tmin:tmax, xmin:xmax][disk] = 1


def _rasterize_track(mask: np.ndarray, path_tx: list[tuple[int, float]], thickness: int, max_gap_frames: int) -> None:
    if len(path_tx) < 2:
        return
    ordered = sorted(path_tx, key=lambda p: p[0])
    for (t0, x0), (t1, x1) in zip(ordered[:-1], ordered[1:]):
        if t1 <= t0:
            continue
        if max_gap_frames > 0 and (t1 - t0) > max_gap_frames:
            continue
        _draw_segment(mask, float(t0), float(x0), float(t1), float(x1), thickness=thickness)


def _normalize_to_u16(arr: np.ndarray) -> np.ndarray:
    x = np.asarray(arr, dtype=np.float32)
    x = np.nan_to_num(x, nan=0.0, posinf=0.0, neginf=0.0)
    p1, p99 = np.percentile(x, (1, 99))
    denom = float(max(1e-6, p99 - p1))
    x = np.clip((x - p1) / denom, 0.0, 1.0)
    return np.clip(np.rint(x * 65535.0), 0, 65535).astype(np.uint16)


def _preview_overlay(image_u16: np.ndarray, label_u8: np.ndarray) -> np.ndarray:
    base = np.asarray(image_u16, dtype=np.float32)
    if base.size == 0:
        return np.zeros((1, 1, 3), dtype=np.uint8)
    p1, p99 = np.percentile(base, (1, 99))
    denom = float(max(1e-6, p99 - p1))
    gray = np.clip((base - p1) / denom, 0.0, 1.0)
    rgb = np.repeat((gray * 255.0)[..., None], 3, axis=2)
    moving = label_u8 > 0
    rgb[moving] = 0.35 * rgb[moving] + 0.65 * np.array([255.0, 72.0, 72.0], dtype=np.float32)
    return np.clip(rgb, 0, 255).astype(np.uint8)


def _find_movie_path(movie_name: str | None, xlsx_path: Path, movie_root: Path | None, cache: dict[str, Path | None]) -> Path | None:
    key = str(movie_name or "").strip().lower()
    if key in cache:
        return cache[key]

    candidates: list[Path] = []
    if movie_name:
        p = Path(movie_name)
        if p.is_absolute():
            candidates.append(p)
        candidates.append(xlsx_path.parent / movie_name)
        if movie_root is not None:
            candidates.append(movie_root / movie_name)

        base = p.name
        if base:
            candidates.append(xlsx_path.parent / base)
            if movie_root is not None:
                candidates.append(movie_root / base)

    stem = xlsx_path.stem
    for ext in (".tif", ".tiff"):
        candidates.append(xlsx_path.with_suffix(ext))
        candidates.append(xlsx_path.parent / f"{stem}{ext}")
        if movie_root is not None:
            candidates.append(movie_root / f"{stem}{ext}")

    for c in candidates:
        if c.exists() and c.is_file():
            cache[key] = c.resolve()
            return cache[key]

    # Last resort recursive search by basename.
    roots = [xlsx_path.parent]
    if movie_root is not None and movie_root != xlsx_path.parent:
        roots.append(movie_root)
    names = []
    if movie_name:
        names.append(Path(movie_name).name)
    names += [f"{stem}.tif", f"{stem}.tiff"]

    for root in roots:
        for n in names:
            if not n:
                continue
            hits = list(root.rglob(n))
            if hits:
                cache[key] = hits[0].resolve()
                return cache[key]
    cache[key] = None
    return None


def _collect_xlsx_paths(explicit: list[str], xlsx_root: str | None) -> list[Path]:
    paths: list[Path] = []
    for s in explicit:
        p = Path(s).expanduser()
        if p.is_file() and p.suffix.lower() == ".xlsx":
            paths.append(p.resolve())
        elif p.is_dir():
            paths.extend(sorted(pp.resolve() for pp in p.rglob("*.xlsx")))
    if xlsx_root:
        root = Path(xlsx_root).expanduser()
        if root.is_file() and root.suffix.lower() == ".xlsx":
            paths.append(root.resolve())
        elif root.is_dir():
            paths.extend(sorted(pp.resolve() for pp in root.rglob("*.xlsx")))
    dedup = []
    seen = set()
    for p in paths:
        if p.name.startswith("~$"):  # temporary Excel lock file
            continue
        if p not in seen:
            dedup.append(p)
            seen.add(p)
    return dedup


def parse_args() -> argparse.Namespace:
    ap = argparse.ArgumentParser(description="Convert Tracy trajectory xlsx + TIFF movies into real kymograph dataset")
    ap.add_argument("--xlsx", nargs="*", default=[], help="One or more xlsx files or directories")
    ap.add_argument("--xlsx_root", default=None, help="Directory (or xlsx file) to scan for exports")
    ap.add_argument("--movie_root", default=None, help="Directory to search for TIFF movies")
    ap.add_argument("--out", required=True, help="Output directory, e.g. data/real")
    ap.add_argument("--line_width", type=int, default=2, help="Half-width for normal-direction integration")
    ap.add_argument("--line_method", choices=("max", "average"), default="max", help="Integration mode across ROI width")
    ap.add_argument("--thickness", type=int, default=3, help="Rasterized label thickness in px")
    ap.add_argument("--max_interp_gap", type=int, default=8, help="Do not connect datapoints across larger frame gaps")
    ap.add_argument("--min_track_points", type=int, default=2, help="Minimum points per track to rasterize")
    ap.add_argument("--include_empty", action="store_true", help="Keep samples even if no labeled tracks are found")
    ap.add_argument(
        "--point_source",
        choices=("auto", "spot", "search", "original"),
        default="auto",
        help="Which Data Points coordinates to use. 'auto' prefers Spot Center, then Search Center, then Original.",
    )
    return ap.parse_args()


def main() -> None:
    args = parse_args()
    xlsx_paths = _collect_xlsx_paths(args.xlsx, args.xlsx_root)
    if not xlsx_paths:
        raise SystemExit("No xlsx files found. Provide --xlsx and/or --xlsx_root.")

    movie_root = Path(args.movie_root).expanduser().resolve() if args.movie_root else None
    out = Path(args.out).expanduser().resolve()
    img_dir = out / "images"
    lbl_dir = out / "labels"
    meta_dir = out / "meta"
    preview_dir = out / "preview"
    for d in (img_dir, lbl_dir, meta_dir, preview_dir):
        d.mkdir(parents=True, exist_ok=True)

    movie_path_cache: dict[str, Path | None] = {}
    movie_spec_cache: dict[Path, MovieSpec] = {}
    manifest_rows = []
    stats = {
        "xlsx_seen": 0,
        "xlsx_used": 0,
        "groups_total": 0,
        "samples_written": 0,
        "samples_skipped_empty": 0,
        "tracks_from_datapoints": 0,
        "tracks_from_anchors": 0,
        "datapoint_rows_spot": 0,
        "datapoint_rows_search": 0,
        "datapoint_rows_original": 0,
        "unresolved_movies": 0,
        "invalid_rows": 0,
    }

    for xlsx_path in xlsx_paths:
        stats["xlsx_seen"] += 1
        try:
            xl = pd.ExcelFile(xlsx_path)
        except Exception:
            continue
        if "Per-trajectory" not in xl.sheet_names:
            continue
        stats["xlsx_used"] += 1

        df_traj = pd.read_excel(xl, sheet_name="Per-trajectory")
        if df_traj.empty:
            continue

        datapoints_by_tid: dict[int, list[tuple[int, float, float]]] = {}
        if "Data Points" in xl.sheet_names:
            try:
                df_pts = pd.read_excel(xl, sheet_name="Data Points")
                datapoints_by_tid, src_counts = _parse_datapoints_sheet(
                    df_pts,
                    point_source=str(args.point_source),
                )
                stats["datapoint_rows_spot"] += int(src_counts.get("spot", 0))
                stats["datapoint_rows_search"] += int(src_counts.get("search", 0))
                stats["datapoint_rows_original"] += int(src_counts.get("original", 0))
            except Exception:
                datapoints_by_tid = {}

        # One workbook typically maps to one movie, but we resolve per-row movie names robustly.
        # Group key: (movie_path, channel, roi_key)
        groups: dict[tuple[Path, int, str], dict[str, Any]] = {}

        for _, row in df_traj.iterrows():
            roi_raw = _safe_json_loads(row.get("ROI", ""))
            roi = _canonicalize_roi(roi_raw)
            if roi is None:
                stats["invalid_rows"] += 1
                continue

            movie_name = row.get("Movie")
            movie_name_str = str(movie_name).strip() if isinstance(movie_name, str) else None
            movie_path = _find_movie_path(movie_name_str, xlsx_path, movie_root, movie_path_cache)
            if movie_path is None:
                stats["unresolved_movies"] += 1
                continue

            ch_raw = row.get("Channel", 1)
            try:
                channel = int(round(float(ch_raw)))
            except Exception:
                channel = 1
            channel = max(1, channel)

            try:
                tid = int(round(float(row.get("Trajectory"))))
            except Exception:
                tid = -1

            anchors_raw = _safe_json_loads(_pick_anchor_cell(row))
            anchors: list[tuple[int, float]] = []
            if isinstance(anchors_raw, list):
                tmp = []
                for a in anchors_raw:
                    if not isinstance(a, (list, tuple)) or len(a) < 2:
                        continue
                    try:
                        tf = int(round(float(a[0])))
                        xf = float(a[1])
                    except Exception:
                        continue
                    tmp.append((tf, xf))
                # Handle possible 1-based frame data in older files.
                has_zero = any(t == 0 for t, _ in tmp)
                anchors = [(t - 1 if (t >= 1 and not has_zero) else t, x) for t, x in tmp]
                anchors = sorted(anchors, key=lambda z: z[0])

            key = (movie_path, channel, _roi_key(roi))
            g = groups.get(key)
            if g is None:
                g = {
                    "movie_path": movie_path,
                    "movie_name": movie_name_str or movie_path.name,
                    "channel": channel,
                    "roi": roi,
                    "tracks": [],
                }
                groups[key] = g
            g["tracks"].append(
                {
                    "trajectory": tid,
                    "anchors": anchors,
                    "datapoints": datapoints_by_tid.get(tid, []),
                }
            )

        for (_movie_path, _channel, _roi), group in groups.items():
            stats["groups_total"] += 1
            movie_path = Path(group["movie_path"])
            spec = movie_spec_cache.get(movie_path)
            if spec is None:
                spec = _load_movie_spec(movie_path)
                movie_spec_cache[movie_path] = spec

            channel = int(group["channel"])
            if channel > spec.n_channels:
                channel = spec.n_channels

            kymo = _build_kymograph(
                spec,
                roi=group["roi"],
                channel_1based=channel,
                line_width=int(args.line_width),
                line_method=str(args.line_method),
            )
            tdim, xdim = kymo.shape
            roi_cache = _roi_cache(group["roi"])
            label = np.zeros((tdim, xdim), dtype=np.uint8)

            track_meta = []
            for tr in group["tracks"]:
                path_tx: list[tuple[int, float]] = []
                source = ""
                dps = tr.get("datapoints") or []
                if len(dps) >= int(args.min_track_points):
                    converted = []
                    for f, x, y in dps:
                        if f < 0 or f >= tdim:
                            continue
                        xk = _kymo_x_from_movie_point(roi_cache, x, y, kymo_width=xdim)
                        if xk is None:
                            continue
                        converted.append((int(f), float(xk)))
                    # Deduplicate by frame after projection.
                    if converted:
                        by_f: dict[int, list[float]] = {}
                        for f, x in converted:
                            by_f.setdefault(f, []).append(x)
                        path_tx = sorted((f, float(np.mean(xs))) for f, xs in by_f.items())
                        source = "data_points"

                if len(path_tx) < int(args.min_track_points):
                    anchors = tr.get("anchors") or []
                    anchors = [(int(t), float(x)) for t, x in anchors if 0 <= int(t) < tdim]
                    if len(anchors) >= int(args.min_track_points):
                        path_tx = anchors
                        source = "anchors"

                if len(path_tx) < int(args.min_track_points):
                    continue

                max_gap = int(args.max_interp_gap) if source == "data_points" else 0
                _rasterize_track(label, path_tx, thickness=int(args.thickness), max_gap_frames=max_gap)

                if source == "data_points":
                    stats["tracks_from_datapoints"] += 1
                else:
                    stats["tracks_from_anchors"] += 1
                track_meta.append(
                    {
                        "trajectory": int(tr.get("trajectory", -1)),
                        "source": source,
                        "n_points": int(len(path_tx)),
                        "start_t": int(path_tx[0][0]),
                        "end_t": int(path_tx[-1][0]),
                        # Persist projected kymograph-space path for identity-aware eval.
                        "path_tx": [[int(f), float(x)] for f, x in path_tx],
                        "max_interp_gap": int(max_gap),
                    }
                )

            if not args.include_empty and int(np.count_nonzero(label)) == 0:
                stats["samples_skipped_empty"] += 1
                continue

            xlsx_stem = xlsx_path.stem
            movie_stem = movie_path.stem
            roi_digest = hashlib.sha1(_roi_key(group["roi"]).encode("utf-8")).hexdigest()
            roi_tag = roi_digest[:10]
            sample_id = f"{xlsx_stem}__{movie_stem}__ch{channel:02d}__r{roi_tag}"

            image_u16 = _normalize_to_u16(kymo)
            label_u8 = label.astype(np.uint8)
            preview = _preview_overlay(image_u16, label_u8)

            rel_img = Path("images") / f"{sample_id}.png"
            rel_lbl = Path("labels") / f"{sample_id}.png"
            rel_meta = Path("meta") / f"{sample_id}.json"
            rel_preview = Path("preview") / f"{sample_id}.png"

            save_image(out / rel_img, image_u16)
            save_image(out / rel_lbl, label_u8)
            save_image(out / rel_preview, preview)

            meta = {
                "sample_id": sample_id,
                "source_xlsx": str(xlsx_path),
                "source_movie": str(movie_path),
                "movie_name": str(group["movie_name"]),
                "movie_axes": spec.axes,
                "movie_shape": list(spec.movie.shape),
                "channel_1based": int(channel),
                "line_width": int(args.line_width),
                "line_method": str(args.line_method),
                "point_source": str(args.point_source),
                "label_thickness": int(args.thickness),
                "roi": group["roi"],
                "shape": [int(tdim), int(xdim)],
                "n_tracks": int(len(track_meta)),
                "tracks": track_meta,
            }
            write_json(out / rel_meta, meta)

            manifest_rows.append(
                {
                    "image": str(rel_img),
                    "label": str(rel_lbl),
                    "meta": str(rel_meta),
                    "preview": str(rel_preview),
                }
            )
            stats["samples_written"] += 1

    write_jsonl(out / "index.jsonl", manifest_rows)
    write_json(out / "conversion_summary.json", stats)
    print(json.dumps(stats, indent=2))
    print(f"Wrote manifest: {out / 'index.jsonl'}")


if __name__ == "__main__":
    main()
