"""Adaptive cost-grow chart segmentation (vectorized torch, CPU or GPU)."""
from __future__ import annotations

from typing import Tuple

import torch
from torch import Tensor
from tqdm import tqdm

from .mesh import MeshData, face_edge_lengths


DEFAULT_W_NORMAL_DEVIATION = 2.0
DEFAULT_W_ROUNDNESS = 0.01
DEFAULT_W_STRAIGHTNESS = 6.0
DEFAULT_MAX_COST = 2.0
NORMAL_DEVIATION_HARD_CUTOFF = 0.707  # ~75°


def _grow_iter(face_chart, frontier, ff, fn, fa, fel, basis, nsum, area, perim, K,
               nd_cutoff, tau, w_nd, w_round, w_straight):
    """One grow pass: each frontier face joins its lowest-cost adjacent chart if cost <= tau;
    returns the number of faces assigned."""
    u = frontier.nonzero(as_tuple=True)[0]
    if u.numel() == 0:
        return 0
    nb = ff[u]                                       # (U,3) neighbor face ids
    nbc = torch.where(nb >= 0, face_chart[nb.clamp_min(0)], nb.new_full((), -1))
    valid = nbc >= 0
    d = (fn[u][:, None, :] * basis[nbc.clamp_min(0)]).sum(-1)
    nd = (1.0 - d).clamp(0.0, 1.0)
    valid &= nd < nd_cutoff
    el = fel[u]                                      # (U,3)
    # l_in per candidate chart j: edge k counts if its (assigned) neighbor is in chart j
    inm = (nbc[:, :, None] == nbc[:, None, :]) & valid[:, None, :]
    l_in = (el[:, None, :] * inm).sum(-1)            # (U,3)
    tot = el.sum(-1, keepdim=True)
    l_out = tot - l_in
    ca = area[nbc.clamp_min(0)]
    cp = perim[nbc.clamp_min(0)]
    new_perim = cp - l_in + l_out
    new_r = new_perim * new_perim / (ca + fa[u][:, None]).clamp_min(1e-20)
    round_cost = torch.where((cp <= 1e-20) | (ca <= 1e-20) | (new_r <= 1e-20),
                             torch.zeros_like(new_r),
                             1.0 - (cp * cp / ca.clamp_min(1e-20)) / new_r.clamp_min(1e-20))
    straight_cost = ((l_out - l_in) / tot.clamp_min(1e-20)).clamp(max=0.0)
    cost = w_nd * nd + w_round * round_cost + w_straight * straight_cost
    cost = torch.where(valid, cost, cost.new_full((), float("inf")))
    best_cost, best_j = cost.min(1)
    acc = best_cost <= tau
    n_acc = int(acc.sum())
    if n_acc == 0:
        return 0

    f_acc = u[acc]
    c_acc = nbc.gather(1, best_j[:, None]).squeeze(1)[acc]
    nbc_old = nbc[acc]                               # neighbor charts before this commit
    face_chart[f_acc] = c_acc
    nb_acc = nb[acc]
    nbs_acc = nb_acc.clamp_min(0)
    nbc_post = torch.where(nb_acc >= 0, face_chart[nbs_acc], nb_acc.new_full((), -1))
    # frontier update: committed faces leave; their still-unassigned neighbors enter
    frontier[f_acc] = False
    grow_nb = nbs_acc[(nb_acc >= 0) & (nbc_post < 0)]
    frontier[grow_nb] = True
    el_acc = el[acc]
    cx = c_acc[:, None]
    dper = torch.where(nbc_old == cx, -el_acc,       # was member: edge turns interior
                       torch.where(nbc_post == cx, torch.zeros_like(el_acc),  # co-committer
                                   el_acc)).sum(1)   # boundary / other chart
    perim.scatter_add_(0, c_acc, dper)
    area.scatter_add_(0, c_acc, fa[f_acc])
    nsum.index_add_(0, c_acc, fn[f_acc] * fa[f_acc, None])
    nl = nsum[:K].norm(dim=1, keepdim=True)
    basis[:K] = torch.where(nl > 1e-20, nsum[:K] / nl.clamp_min(1e-20), basis[:K])
    return n_acc


def segment_charts(
    mesh: MeshData,
    max_cost: float = DEFAULT_MAX_COST,
    w_normal_deviation: float = DEFAULT_W_NORMAL_DEVIATION,
    w_roundness: float = DEFAULT_W_ROUNDNESS,
    w_straightness: float = DEFAULT_W_STRAIGHTNESS,
    progress_callback=None,
) -> Tensor:
    """Segment mesh into charts (parallel batch cost-grow). Returns face -> chart_id."""
    F = mesh.faces.shape[0]
    device = mesh.faces.device
    if F == 0:
        return torch.zeros(0, dtype=torch.long, device=device)

    fn = mesh.face_normal.detach().to(torch.float32)
    fa = mesh.face_area.detach().to(torch.float32)
    fc = mesh.face_centroid.detach().to(torch.float32)
    ff = mesh.face_face.detach().long()
    fel = face_edge_lengths(mesh.vertices, mesh.faces).detach().to(torch.float32)
    nd_cutoff = NORMAL_DEVIATION_HARD_CUTOFF

    # one seed per connected component (first face of each)
    comp = mesh.component.detach().long().to(device)
    ncomp = int(comp.max()) + 1 if comp.numel() else 0
    if ncomp:
        seeds = torch.full((ncomp,), F, dtype=torch.long, device=device)
        seeds.scatter_reduce_(0, comp, torch.arange(F, device=device), reduce="amin")
    else:
        seeds = torch.zeros(1, dtype=torch.long, device=device)
    K = seeds.shape[0]

    max_total_charts = max(F, 8000)
    cap = K + F + 1                                  # every re-seed assigns a face, so K < K0 + F
    face_chart = torch.full((F,), -1, dtype=torch.long, device=device)
    basis = torch.zeros(cap, 3, dtype=torch.float32, device=device)
    nsum = torch.zeros(cap, 3, dtype=torch.float32, device=device)
    area = torch.zeros(cap, dtype=torch.float32, device=device)
    perim = torch.zeros(cap, dtype=torch.float32, device=device)
    face_chart[seeds] = torch.arange(K, device=device)
    basis[:K] = fn[seeds]
    nsum[:K] = fn[seeds] * fa[seeds, None]
    area[:K] = fa[seeds]
    perim[:K] = fel[seeds].sum(1)
    frontier = torch.zeros(F, dtype=torch.bool, device=device)
    seed_nb = ff[seeds]
    seed_nb = seed_nb[seed_nb >= 0]
    frontier[seed_nb] = True
    frontier &= face_chart < 0

    min_d2 = torch.full((F,), float("inf"), dtype=torch.float32, device=device)
    for i in range(0, K, 32):                        # chunked: (F, <=32, 3) stays small
        d2 = ((fc[:, None, :] - fc[seeds[i:i + 32]][None, :, :]) ** 2).sum(-1)
        min_d2 = torch.minimum(min_d2, d2.amin(1))

    # Multi-pass threshold schedule (low-cost first); tau cap 0.5 keeps cones ~30deg.
    tau_final = min(max_cost * 0.25, 0.5)
    thresholds = [t for t in (0.05, 0.1, 0.25) if t < tau_final] + [tau_final]
    max_inner = max(64, int(F ** 0.5) * 2)
    outer_iter = 0
    assigned = 0
    tq = tqdm(total=F, desc="unwrap: segment (adaptive)", unit="face", leave=False)
    while True:
        outer_iter += 1
        if outer_iter > F + 16:
            break
        for tau in thresholds:
            for _ in range(max_inner):
                n_added = _grow_iter(face_chart, frontier, ff, fn, fa, fel, basis, nsum,
                                     area, perim, K, nd_cutoff, tau, w_normal_deviation,
                                     w_roundness, w_straightness)
                if n_added == 0:
                    break
                tq.update(n_added)
                assigned += n_added
                if progress_callback is not None:
                    progress_callback(assigned, F)
        unassigned = face_chart < 0
        if int(unassigned.sum()) == 0:
            break
        if K >= max_total_charts:
            break
        # re-seed at the unassigned face farthest from every existing seed
        new_seed = int(torch.where(unassigned, min_d2,
                                   min_d2.new_full((), float("-inf"))).argmax())
        face_chart[new_seed] = K
        basis[K] = fn[new_seed]
        nsum[K] = fn[new_seed] * fa[new_seed]
        area[K] = fa[new_seed]
        perim[K] = fel[new_seed].sum()
        K += 1
        min_d2 = torch.minimum(min_d2, ((fc - fc[new_seed]) ** 2).sum(-1))
        tq.update(1)
        frontier[new_seed] = False
        ns_nb = ff[new_seed]
        ns_nb = ns_nb[ns_nb >= 0]
        frontier[ns_nb[face_chart[ns_nb] < 0]] = True

    tq.close()

    # Orphan cleanup: leftover faces join their best-matching neighbor's chart.
    while True:
        orphans = (face_chart < 0).nonzero(as_tuple=True)[0]
        if orphans.numel() == 0:
            break
        nb = ff[orphans]
        nbc = torch.where(nb >= 0, face_chart[nb.clamp_min(0)], nb.new_full((), -1))
        valid = nbc >= 0
        assignable = valid.any(1)
        if not bool(assignable.any()):
            break
        d = (fn[orphans][:, None, :] * basis[nbc.clamp_min(0)]).sum(-1)
        ndv = torch.where(valid, 1.0 - d, d.new_full((), float("inf")))
        best_c = nbc.gather(1, ndv.argmin(1, keepdim=True)).squeeze(1)
        face_chart[orphans[assignable]] = best_c[assignable]
    leftover = (face_chart < 0).nonzero(as_tuple=True)[0]
    if leftover.numel():                             # isolated faces become singleton charts
        face_chart[leftover] = K + torch.arange(leftover.numel(), device=device)

    _, inverse = torch.unique(face_chart, sorted=True, return_inverse=True)
    return inverse


# Parallel edge-collapse (PEC) chart clustering (GPU)
def _combine_normal_cones(
    axis_a: Tensor, half_a: Tensor,
    axis_b: Tensor, half_b: Tensor,
) -> Tuple[Tensor, Tensor, Tensor]:
    """Merge two normal cones along the great circle from axis_a; returns (combined_axis, combined_half_angle, axis_angle)."""
    cos_angle = (axis_a * axis_b).sum(dim=-1).clamp(-1.0, 1.0)
    axis_angle = torch.acos(cos_angle)
    new_low = torch.minimum(-half_a, axis_angle - half_b)
    new_high = torch.maximum(half_a, axis_angle + half_b)
    new_half = (new_high - new_low) * 0.5
    rot_angle = (new_high + new_low) * 0.5
    b_perp = axis_b - axis_a * cos_angle.unsqueeze(-1)
    b_perp_norm = b_perp.norm(dim=-1, keepdim=True).clamp_min(1e-12)
    b_perp_unit = b_perp / b_perp_norm
    new_axis = (
        axis_a * torch.cos(rot_angle).unsqueeze(-1)
        + b_perp_unit * torch.sin(rot_angle).unsqueeze(-1)
    )
    new_axis_norm = new_axis.norm(dim=-1, keepdim=True).clamp_min(1e-12)
    new_axis = new_axis / new_axis_norm
    return new_axis, new_half, axis_angle


def _build_chart_edges(
    face_face: Tensor,
    chart_id: Tensor,
    face_edge_len: Tensor,
) -> Tuple[Tensor, Tensor]:
    """Build chart-edge list (chart_pairs[E,2] with a<b, edge_length[E]); same-chart edges dropped, duplicates summed."""
    F = face_face.shape[0]
    device = face_face.device
    f_idx = torch.arange(F, device=device).repeat_interleave(3)
    nb = face_face.flatten()
    valid = nb >= 0
    f_idx = f_idx[valid]
    nb = nb[valid]
    el = face_edge_len.flatten()[valid]

    ca = chart_id[f_idx]
    cb = chart_id[nb]
    diff = ca != cb
    ca = ca[diff]
    cb = cb[diff]
    el = el[diff]
    if ca.numel() == 0:
        return (
            torch.empty((0, 2), dtype=torch.long, device=device),
            torch.empty(0, device=device),
        )

    lo = torch.minimum(ca, cb)
    hi = torch.maximum(ca, cb)
    V = int(chart_id.max().item()) + 1
    key = lo * V + hi
    sort_idx = torch.argsort(key)
    sorted_key = key[sort_idx]
    sorted_lo = lo[sort_idx]
    sorted_hi = hi[sort_idx]
    sorted_el = el[sort_idx]
    unique_key, inverse, counts = torch.unique(
        sorted_key, return_inverse=True, return_counts=True
    )
    n_unique = unique_key.shape[0]
    reduced_el = torch.zeros(n_unique, device=device, dtype=el.dtype)
    reduced_el.scatter_add_(0, inverse, sorted_el)
    first_idx = torch.cat([
        torch.zeros(1, dtype=torch.long, device=device),
        counts.cumsum(0)[:-1],
    ])
    pair_lo = sorted_lo[first_idx]
    pair_hi = sorted_hi[first_idx]
    chart_pairs = torch.stack([pair_lo, pair_hi], dim=1)
    return chart_pairs, reduced_el


def _merge_small_charts(
    chart_id: Tensor, face_normal: Tensor, face_area: Tensor,
    face_face: Tensor, face_edge_len: Tensor,
    min_faces: int, cost_cap: float,
) -> Tensor:
    """Absorb charts under min_faces faces into their lowest-cone-cost neighbor (capped at cost_cap)."""
    if chart_id.numel() == 0:
        return chart_id
    device = chart_id.device
    for _ in range(16):
        N = int(chart_id.max().item()) + 1
        sizes = torch.bincount(chart_id, minlength=N)
        # recompute cones from scratch: area-weighted mean axis, max deviation as half-angle
        axis = torch.zeros(N, 3, dtype=torch.float32, device=device)
        axis.index_add_(0, chart_id, face_normal * face_area[:, None])
        axis = axis / axis.norm(dim=1, keepdim=True).clamp_min(1e-12)
        dev = torch.acos((face_normal * axis[chart_id]).sum(1).clamp(-1.0, 1.0))
        half = torch.zeros(N, dtype=torch.float32, device=device)
        half.scatter_reduce_(0, chart_id, dev, reduce="amax")

        edges, _ = _build_chart_edges(face_face, chart_id, face_edge_len)
        if edges.shape[0] == 0:
            break
        a, b = edges[:, 0], edges[:, 1]
        _, new_half, _ = _combine_normal_cones(axis[a], half[a], axis[b], half[b])
        ok = new_half <= cost_cap
        E = edges.shape[0]
        key = (torch.clamp(new_half * 1e6, max=2e9).to(torch.int64) << 32) \
            | torch.arange(E, dtype=torch.long, device=device)
        best = torch.full((N,), 1 << 62, dtype=torch.long, device=device)
        va = (sizes[a] < min_faces) & ok
        vb = (sizes[b] < min_faces) & ok
        best.scatter_reduce_(0, a[va], key[va], reduce="amin")
        best.scatter_reduce_(0, b[vb], key[vb], reduce="amin")
        src = (best < (1 << 62)).nonzero(as_tuple=True)[0]
        if src.numel() == 0:
            break
        eid = best[src] & 0xFFFFFFFF
        ea, eb = a[eid], b[eid]
        tgt = torch.where(ea == src, eb, ea)
        # cycle break: keep src->tgt only if tgt merges nowhere itself or src > tgt;
        # the kept graph is then a DAG, so the pointer-doubling below terminates
        prop = torch.arange(N, dtype=torch.long, device=device)
        prop[src] = tgt
        keepm = (prop[tgt] == tgt) | (src > tgt)
        remap = torch.arange(N, dtype=torch.long, device=device)
        remap[src[keepm]] = tgt[keepm]
        for _ in range(32):
            nr = remap[remap]
            if torch.equal(nr, remap):
                break
            remap = nr
        chart_id = remap[chart_id]
        _, chart_id = torch.unique(chart_id, return_inverse=True)
    return chart_id


def cluster_charts_pec(
    mesh: MeshData,
    max_cost: float = 0.7,
    max_iters: int = 1024,
    min_faces: int = 8,
    progress_callback=None,
) -> Tensor:
    """Parallel edge-collapse clustering; returns face_chart [F]. max_cost is the per-merge
    cutoff (~0.7 rad ~ 40deg); charts under min_faces are then absorbed at a relaxed 2x cutoff."""
    device = mesh.faces.device
    F = mesh.faces.shape[0]
    faces = mesh.faces.to(torch.long)
    vertices = mesh.vertices.to(torch.float32)
    face_normal = mesh.face_normal.to(torch.float32)
    face_face = mesh.face_face.to(torch.long)

    face_edge_len = face_edge_lengths(vertices, faces)

    chart_id = torch.arange(F, dtype=torch.long, device=device)
    chart_axis = face_normal.clone()
    chart_half = torch.zeros(F, dtype=torch.float32, device=device)

    for it in range(max_iters):
        edges, _ = _build_chart_edges(face_face, chart_id, face_edge_len)
        if edges.shape[0] == 0:
            break

        a = edges[:, 0]
        b = edges[:, 1]
        axis_a = chart_axis[a]
        axis_b = chart_axis[b]
        half_a = chart_half[a]
        half_b = chart_half[b]
        _, new_half, _ = _combine_normal_cones(axis_a, half_a, axis_b, half_b)
        cost = new_half.clone()

        # Pack (cost, edge_id) so scatter_reduce amin picks the right edge.
        E = edges.shape[0]
        N = int(chart_id.max().item()) + 1
        edge_ids = torch.arange(E, dtype=torch.long, device=device)
        cost_i32 = torch.clamp(cost * 1e6, max=2e9).to(torch.int64)
        key = (cost_i32 << 32) | edge_ids
        chart_min = torch.full((N,), (2**62), dtype=torch.long, device=device)
        chart_min.scatter_reduce_(0, a, key, reduce="amin", include_self=True)
        chart_min.scatter_reduce_(0, b, key, reduce="amin", include_self=True)

        # Mutual-min collapse: each chart in at most one merge per iter (winners are disjoint pairs).
        is_a_min = chart_min[a] == key
        is_b_min = chart_min[b] == key
        mutual = is_a_min & is_b_min
        within = cost <= max_cost
        winners = mutual & within

        n_merge = int(winners.sum().item())
        if n_merge == 0:
            break
        if progress_callback is not None:
            progress_callback(F - N + n_merge, F)      # saturating: charts remaining vs faces

        win_a = a[winners]
        win_b = b[winners]

        axis_a_w = chart_axis[win_a]
        half_a_w = chart_half[win_a]
        axis_b_w = chart_axis[win_b]
        half_b_w = chart_half[win_b]
        new_axis, new_half_w, _ = _combine_normal_cones(
            axis_a_w, half_a_w, axis_b_w, half_b_w,
        )
        chart_axis[win_a] = new_axis
        chart_half[win_a] = new_half_w

        remap = torch.arange(N, dtype=torch.long, device=device)
        remap[win_b] = win_a
        chart_id = remap[chart_id]

    if min_faces > 1:
        chart_id = _merge_small_charts(chart_id, face_normal, mesh.face_area.to(torch.float32),
                                       face_face, face_edge_len, min_faces, 2.0 * max_cost)
    _, inverse = torch.unique(chart_id, sorted=True, return_inverse=True)
    return inverse
