// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
use super::*;

impl Scene {
    /// Collect model-space wires projected into paper space for all (or one specific)
    /// user viewports.  `only_vp = Some(h)` restricts output to that viewport.
    pub(super) fn viewport_content_wires(
        &self,
        paper_block: Handle,
        only_vp: Option<Handle>,
        exclude_vp: Option<Handle>,
    ) -> Vec<WireModel> {
        use acadrust::entities::Viewport;

        let (_, _, viewport_handles) = self.paper_viewport_handles();
        let viewports: Vec<&Viewport> = viewport_handles
            .iter()
            .filter_map(|handle| match self.document.get_entity(*handle) {
                Some(EntityType::Viewport(vp)) => Some(vp),
                _ => None,
            })
            .filter(|vp| {
                vp.common.owner_handle == paper_block
                    && vp.status.is_on
                    && only_vp.map_or(true, |h| vp.common.handle == h)
                    && exclude_vp.map_or(true, |h| vp.common.handle != h)
            })
            .collect();

        if viewports.is_empty() {
            return vec![];
        }

        let mut result = Vec::new();

        for vp in viewports {
            let vp_handle = vp.common.handle;

            // ── Fast path: return cached projected wires ──────────────────
            {
                let cache = self.paper_projected_cache.borrow();
                if let Some((cached_epoch, ref wires)) = cache.get(&vp_handle) {
                    if *cached_epoch == self.geometry_epoch {
                        result.extend_from_slice(wires);
                        continue;
                    }
                }
            }

            // ── Cache miss: compute projection ────────────────────────────

            // Use camera_for_viewport so the axes match the GPU renderer exactly.
            let cam_frame = match self.camera_for_viewport(vp_handle) {
                Some(c) => c,
                None => continue,
            };
            let view_right = cam_frame.rotation * glam::Vec3::X;
            let view_up = cam_frame.rotation * glam::Vec3::Y;

            // Scale (paper units per model unit) comes straight from the camera
            // the GPU uses: the model height shown is `2 * ortho_size`, mapped
            // onto `vp.height` of paper. `camera_for_viewport` already made the
            // saved-view-vs-auto-fit decision (with the twist-correct overlap
            // test), so deriving scale from it keeps the CPU projection (used
            // for hit-test / snap / fit) locked to the GPU render — no second,
            // independently-computed scale that could disagree under a twist.
            let view_height_eff = (cam_frame.ortho_size() * 2.0) as f64;
            let scale = if view_height_eff > 1e-9 {
                (vp.height / view_height_eff) as f32
            } else {
                1.0
            };

            let pcx = vp.center.x as f32;
            let pcy = vp.center.y as f32;
            let pcz = vp.center.z as f32;
            let hw = (vp.width / 2.0) as f32;
            let hh = (vp.height / 2.0) as f32;

            // ── Use cached tessellation (model_wires_for_viewport_arc) ────
            // This eliminates the per-frame tessellate_one() loop that was here
            // previously; tessellation is now O(1) on navigation frames.
            // Pass 0.0 for screen height — the CPU-projection / hit-test
            // path wants the full-fidelity (no-LOD-stub) wire list,
            // regardless of paper zoom.
            let model_wires = self.model_wires_for_viewport_arc(vp_handle, 0.0);

            // ── Project and clip wires into viewport ──────────────────────
            let vp_x0 = pcx - hw;
            let vp_x1 = pcx + hw;
            let vp_y0 = pcy - hh;
            let vp_y1 = pcy + hh;

            // camera_dist: how far the camera is from the target plane.
            let use_perspective = vp.status.perspective && vp.lens_length > 1.0;
            let camera_dist = if use_perspective {
                (vp.view_height as f32 * vp.lens_length as f32 / 24.0).max(0.001)
            } else {
                0.0
            };

            let mut projected: Vec<WireModel> = Vec::new();

            // Precompute precision-stable WCS-space projection inputs in
            // f64. The previous f32 inner loop suffered catastrophic
            // cancellation on UTM-scale drawings: `(wire_offset_rel -
            // target_offset_rel).dot(view_right) - view_center` is a
            // small paper offset computed by subtracting two values at
            // ~5e6 magnitude — f32 ULP there is ~0.5 m, so paper output
            // jittered by cm even when the actual model was clean.
            //
            // Do everything WCS-relative in f64; cast to f32 only at the
            // final paper position.
            // Display centre = the camera's target, in WCS. `camera_for_viewport`
            // already folded view_center through the (twisted) view basis and
            // applied the empty-WCS auto-fit, so taking its target keeps the CPU
            // projection identical to the GPU renderer under any twist.
            let display_center_x = cam_frame.target.x as f64 + [0.0_f64; 3][0];
            let display_center_y = cam_frame.target.y as f64 + [0.0_f64; 3][1];
            let display_center_z = cam_frame.target.z as f64 + [0.0_f64; 3][2];
            let view_right_d = (
                view_right.x as f64,
                view_right.y as f64,
                view_right.z as f64,
            );
            let view_up_d = (view_up.x as f64, view_up.y as f64, view_up.z as f64);
            let view_fwd = cam_frame.rotation * glam::Vec3::Z;
            let view_fwd_d = (view_fwd.x as f64, view_fwd.y as f64, view_fwd.z as f64);
            let camera_dist_d = camera_dist as f64;
            let scale_d = scale as f64;
            let pcx_d = pcx as f64;
            let pcy_d = pcy as f64;
            // Project one ABSOLUTE-WCS model point (f64) onto the paper sheet.
            // Shared by the polyline points, snap points and key vertices so the
            // hit-test / snap geometry lands in the same paper frame the wire is
            // drawn in — otherwise snaps and the click-AABB stay in model (UTM)
            // space and the cursor never reaches them.
            let proj_abs = |ax: f64, ay: f64, az: f64| -> [f32; 3] {
                let mp_x = ax - display_center_x;
                let mp_y = ay - display_center_y;
                let mp_z = az - display_center_z;
                let u = mp_x * view_right_d.0 + mp_y * view_right_d.1 + mp_z * view_right_d.2;
                let v = mp_x * view_up_d.0 + mp_y * view_up_d.1 + mp_z * view_up_d.2;
                if use_perspective {
                    let d_vd = mp_x * view_fwd_d.0 + mp_y * view_fwd_d.1 + mp_z * view_fwd_d.2;
                    let fwd = camera_dist_d - d_vd;
                    if fwd <= 0.001 {
                        return [f32::NAN; 3];
                    }
                    let factor = camera_dist_d / fwd;
                    [
                        (pcx_d + u * factor * scale_d) as f32,
                        (pcy_d + v * factor * scale_d) as f32,
                        pcz,
                    ]
                } else {
                    [(pcx_d + u * scale_d) as f32, (pcy_d + v * scale_d) as f32, pcz]
                }
            };
            let in_vp = |x: f32, y: f32| x >= vp_x0 && x <= vp_x1 && y >= vp_y0 && y <= vp_y1;

            for wire in model_wires.iter() {
                let projected_pts: Vec<[f32; 3]> = wire
                    .points
                    .iter()
                    .enumerate()
                    .map(|(pi, &[mx, my, mz])| {
                        if mx.is_nan() || my.is_nan() || mz.is_nan() {
                            return [f32::NAN; 3];
                        }
                        // Reconstruct absolute WCS from the double-single high
                        // (`points`) + low (`points_low`) pair — the high f32
                        // alone is ~0.5 m off at UTM scale.
                        let lo = wire.points_low.get(pi).copied().unwrap_or([0.0; 3]);
                        proj_abs(mx as f64 + lo[0] as f64, my as f64 + lo[1] as f64, mz as f64 + lo[2] as f64)
                    })
                    .collect();

                // SDF glyph quads ride `text_verts` in absolute WCS, so they need
                // the same projection the points get — a bare clone leaves the
                // text at model (UTM) coordinates while its own dimension lines
                // move to the sheet, putting the glyphs kilometres off the page
                // (issue #385 for layout plots). Cull per glyph on the quad's
                // centroid: the text analogue of `clip_polyline_to_rect`, since a
                // glyph can't be split at the viewport border.
                let mut projected_text = if wire.text_verts.is_empty() {
                    Vec::new()
                } else {
                    model::wire_model::map_text_verts(&wire.text_verts, |x, y, z| {
                        let p = proj_abs(x, y, z);
                        (p[0] as f64, p[1] as f64, p[2] as f64)
                    })
                    .chunks_exact(6)
                    .filter(|quad| {
                        let (sx, sy) = quad.iter().fold((0.0f32, 0.0f32), |(ax, ay), v| {
                            (ax + v.pos[0] + v.pos_low[0], ay + v.pos[1] + v.pos_low[1])
                        });
                        let (cx, cy) = (sx / 6.0, sy / 6.0);
                        cx.is_finite() && cy.is_finite() && in_vp(cx, cy)
                    })
                    .flatten()
                    .copied()
                    .collect::<Vec<_>>()
                };

                // Fast AABB pre-reject.
                let any_near = projected_pts.iter().any(|&[x, y, _]| {
                    x.is_finite()
                        && y.is_finite()
                        && x >= vp_x0 - 1.0
                        && x <= vp_x1 + 1.0
                        && y >= vp_y0 - 1.0
                        && y <= vp_y1 + 1.0
                });
                let (min_x, max_x, min_y, max_y) =
                    projected_pts.iter().filter(|p| p[0].is_finite()).fold(
                        (
                            f32::INFINITY,
                            f32::NEG_INFINITY,
                            f32::INFINITY,
                            f32::NEG_INFINITY,
                        ),
                        |(mnx, mxx, mny, mxy), &[x, y, _]| {
                            (mnx.min(x), mxx.max(x), mny.min(y), mxy.max(y))
                        },
                    );
                let aabb_hits =
                    max_x >= vp_x0 && min_x <= vp_x1 && max_y >= vp_y0 && min_y <= vp_y1;
                // A text-only wire (TEXT / MTEXT carry no stroke `points` since
                // text went SDF-only) has nothing to pre-reject on, so gate both
                // rejections on the glyphs too or it never reaches the sheet.
                if !any_near && !aabb_hits && projected_text.is_empty() {
                    continue;
                }

                let clipped =
                    clip_polyline_to_rect(&projected_pts, vp_x0, vp_y0, vp_x1, vp_y1, pcz);
                if clipped.is_empty() && projected_text.is_empty() {
                    continue;
                }

                // Paper-space AABB of the clipped polyline — the cloned model
                // (UTM) AABB would make click_hit's screen-projected pre-reject
                // discard the wire (box selection has no pre-reject, which is why
                // it kept working while picking didn't).
                let mut pmnx = f32::INFINITY;
                let mut pmny = f32::INFINITY;
                let mut pmxx = f32::NEG_INFINITY;
                let mut pmxy = f32::NEG_INFINITY;
                for &[x, y, _] in clipped.iter().filter(|p| p[0].is_finite()) {
                    pmnx = pmnx.min(x);
                    pmny = pmny.min(y);
                    pmxx = pmxx.max(x);
                    pmxy = pmxy.max(y);
                }
                // Glyphs count towards the paper AABB as well — a text-only wire
                // would otherwise stay UNBOUNDED and never pick.
                for v in &projected_text {
                    let (x, y) = (v.pos[0] + v.pos_low[0], v.pos[1] + v.pos_low[1]);
                    if x.is_finite() && y.is_finite() {
                        pmnx = pmnx.min(x);
                        pmny = pmny.min(y);
                        pmxx = pmxx.max(x);
                        pmxy = pmxy.max(y);
                    }
                }

                // Project snap points + key vertices into the same paper frame,
                // keeping only those inside the viewport rect, so endpoint /
                // midpoint / centre snaps land on the visible sheet geometry
                // instead of the model's UTM coordinates.
                let snap_pts: Vec<(glam::DVec3, model::wire_model::SnapHint)> = wire
                    .snap_pts
                    .iter()
                    .filter_map(|(w, h)| {
                        let p = proj_abs(w.x, w.y, w.z);
                        (p[0].is_finite() && in_vp(p[0], p[1]))
                            .then(|| (glam::DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64), *h))
                    })
                    .collect();
                let key_vertices: Vec<[f64; 3]> = wire
                    .key_vertices
                    .iter()
                    .filter_map(|&[kx, ky, kz]| {
                        let p = proj_abs(kx, ky, kz);
                        (p[0].is_finite() && in_vp(p[0], p[1]))
                            .then(|| [p[0] as f64, p[1] as f64, p[2] as f64])
                    })
                    .collect();

                let adapted = view::render::adapt_to_bg(wire.color, self.paper_bg_color);
                let [r, g, b, a] = adapted;
                // Glyphs carry their own per-vertex colour, so dim them through
                // the same adapt + 0.80/0.85 the wire colour below gets, or the
                // text reads brighter than its own dimension lines.
                for v in &mut projected_text {
                    let [tr, tg, tb, ta] = view::render::adapt_to_bg(v.color, self.paper_bg_color);
                    v.color = [tr * 0.80, tg * 0.80, tb * 0.80, ta * 0.85];
                }
                let mut out = wire.clone();
                out.points = clipped;
                out.text_verts = projected_text;
                // Paper coordinates are small sheet units — no relative-to-eye
                // residual is needed, and keeping the model wire's points_low
                // here would add a model-scale offset to the paper points.
                out.points_low = Vec::new();
                out.snap_pts = snap_pts;
                out.key_vertices = key_vertices;
                // Tangent geometry is in model space and can't be trivially
                // re-expressed in paper coords — drop it (no tangent snap on
                // projected viewport content) rather than snap to UTM.
                out.tangent_geoms = Vec::new();
                // Same for thickness walls: `points` above were projected and
                // clipped into paper coords, and the clone's wall triangles are
                // still model-space. Left in place they would hit-test at a
                // model-scale offset — a stray catch somewhere out on the sheet.
                // Drop them: an extruded entity inside a floating viewport stays
                // selectable by its edges, which do get projected.
                out.pick_tris = Vec::new();
                out.pick_tris_low = Vec::new();
                out.aabb = if pmnx.is_finite() {
                    [pmnx, pmny, pmxx, pmxy]
                } else {
                    WireModel::UNBOUNDED_AABB
                };
                out.color = [r * 0.80, g * 0.80, b * 0.80, a * 0.85];
                out.line_weight_px = wire.line_weight_px;
                // Wire patterns stay in their base model units. Projection
                // normally scales them with the geometry; PSLTSCALE instead
                // keeps dash sizes constant in paper units (the on-screen path
                // applies the same factor through its viewport uniform).
                let dash_scale = if self.document.header.paper_space_linetype_scaling {
                    1.0
                } else {
                    scale
                };
                out.pattern_length = wire.pattern_length * dash_scale;
                out.pattern = wire.pattern.map(|v| v * dash_scale);
                projected.push(out);
            }

            // Store in cache, then extend result.
            self.paper_projected_cache
                .borrow_mut()
                .insert(vp_handle, (self.geometry_epoch, projected.clone()));
            result.extend(projected);
        }

        result
    }

    /// A content viewport's clip boundary, projected into that viewport's
    /// render-target normalized device coords. Every content viewport goes
    /// through the same stencil path: a rectangular viewport contributes its
    /// own four corners (a full-target mask, since the render rectangle already
    /// clips the edges), a viewport with a `clip_boundary_handle` contributes
    /// that entity's tessellated outline. Returns empty only when the viewport
    /// is degenerate.
    ///
    /// `uo/vo/us/vs` are the visible-sub-rect crop the content camera applies
    /// (see `crop_view_proj`); the same crop is applied here so the boundary
    /// lines up with the content even when the viewport hangs off-canvas.
    pub(super) fn viewport_clip_boundary_ndc(
        &self,
        vp_handle: Handle,
        uo: f32,
        vo: f32,
        us: f32,
        vs: f32,
    ) -> Vec<[f32; 2]> {
        use acadrust::entities::Viewport;
        let Some(EntityType::Viewport(vp)) = self.document.get_entity(vp_handle) else {
            return vec![];
        };
        let vp: &Viewport = vp;
        let pcx = vp.center.x as f32;
        let pcy = vp.center.y as f32;
        let z = vp.center.z as f32;
        let hw = (vp.width / 2.0) as f32;
        let hh = (vp.height / 2.0) as f32;
        if hw.abs() < 1e-6 || hh.abs() < 1e-6 {
            return vec![];
        }
        let poly = if vp.clip_boundary_handle.is_null() {
            // Rectangular viewport → its own four corners (paper coords). These
            // map to full-rect NDC [-1, 1], a no-op mask over a render target
            // that already clips to the rectangle, but it keeps rect and
            // non-rect viewports on one uniform stencil path.
            vec![
                [pcx - hw, pcy - hh, z],
                [pcx + hw, pcy - hh, z],
                [pcx + hw, pcy + hh, z],
                [pcx - hw, pcy + hh, z],
            ]
        } else {
            self.clip_boundary_polygon(vp.clip_boundary_handle, z)
        };
        if poly.len() < 3 {
            return vec![];
        }
        // Paper point → full-rectangle NDC ([-1, 1] across the viewport rect,
        // Y up, matching the content camera + `viewport_screen_rect`), then the
        // same crop the content camera uses to map its visible sub-rect back to
        // NDC [-1, 1].
        let us = us.max(1e-6);
        let vs = vs.max(1e-6);
        let sx = 1.0 / us;
        let sy = 1.0 / vs;
        let tx = (1.0 - 2.0 * uo - us) / us;
        let ty = -(1.0 - 2.0 * vo - vs) / vs;
        poly.iter()
            .map(|p| {
                let nx = (p[0] - pcx) / hw;
                let ny = (p[1] - pcy) / hh;
                [sx * nx + tx, sy * ny + ty]
            })
            .collect()
    }

    /// Tessellated outline of a non-rectangular viewport / XCLIP clip-boundary
    /// entity, in paper coordinates, by reusing the entity's own `to_truck`
    /// tessellation (Lines). NaN segment breaks are dropped so the result is a
    /// single ordered ring suitable for a stencil triangle-fan.
    pub(super) fn clip_boundary_polygon(&self, handle: Handle, z: f32) -> Vec<[f32; 3]> {
        use std::f64::consts::TAU;
        let Some(entity) = self
            .document
            .entities()
            .find(|e| e.common().handle == handle)
        else {
            return vec![];
        };
        // Circles and ellipses tessellate directly — their `to_truck` returns a
        // parametric TruckObject (not `Lines`), so extracting a polygon there
        // would come back empty. Everything else (splines, polylines, …) reuses
        // the entity's own `Lines` tessellation.
        const N: usize = 64;
        match entity {
            EntityType::Circle(c) => (0..N)
                .map(|i| {
                    let a = i as f64 * TAU / N as f64;
                    [
                        (c.center.x + a.cos() * c.radius) as f32,
                        (c.center.y + a.sin() * c.radius) as f32,
                        z,
                    ]
                })
                .collect(),
            EntityType::Ellipse(el) => {
                // major_axis = center → major endpoint; minor = perp(major) × ratio.
                let (mx, my) = (el.major_axis.x, el.major_axis.y);
                let r = el.minor_axis_ratio;
                (0..N)
                    .map(|i| {
                        let t = i as f64 * TAU / N as f64;
                        let px = mx * t.cos() - my * r * t.sin();
                        let py = my * t.cos() + mx * r * t.sin();
                        [(el.center.x + px) as f32, (el.center.y + py) as f32, z]
                    })
                    .collect()
            }
            _ => {
                use crate::entities::traits::EntityTypeOps;
                let Some(te) = entity.to_truck_entity(&self.document) else {
                    return vec![];
                };
                if let crate::scene::convert::acad_to_truck::TruckObject::Lines(pts) = te.object {
                    pts.into_iter()
                        .filter(|p| p[0].is_finite() && p[1].is_finite())
                        .map(|p| [p[0] as f32, p[1] as f32, z])
                        .collect()
                } else {
                    vec![]
                }
            }
        }
    }
}

// ── Paper boundary wire ────────────────────────────────────────────────────

// ── Cohen-Sutherland line clipping ───────────────────────────────────────

/// Clip a single segment (x0,y0)→(x1,y1) against the axis-aligned rectangle
/// [xmin,xmax]×[ymin,ymax].  Returns the clipped endpoints or `None` if the
/// segment is entirely outside.

fn cs_clip(
    mut x0: f32,
    mut y0: f32,
    mut x1: f32,
    mut y1: f32,
    xmin: f32,
    ymin: f32,
    xmax: f32,
    ymax: f32,
) -> Option<(f32, f32, f32, f32)> {
    const LEFT: u8 = 1;
    const RIGHT: u8 = 2;
    const BOTTOM: u8 = 4;
    const TOP: u8 = 8;

    let code = |x: f32, y: f32| -> u8 {
        let mut c = 0u8;
        if x < xmin {
            c |= LEFT;
        } else if x > xmax {
            c |= RIGHT;
        }
        if y < ymin {
            c |= BOTTOM;
        } else if y > ymax {
            c |= TOP;
        }
        c
    };

    let mut c0 = code(x0, y0);
    let mut c1 = code(x1, y1);

    loop {
        if c0 | c1 == 0 {
            return Some((x0, y0, x1, y1));
        }
        if c0 & c1 != 0 {
            return None;
        }
        let cout = if c0 != 0 { c0 } else { c1 };
        let (x, y);
        if cout & TOP != 0 {
            x = x0 + (x1 - x0) * (ymax - y0) / (y1 - y0);
            y = ymax;
        } else if cout & BOTTOM != 0 {
            x = x0 + (x1 - x0) * (ymin - y0) / (y1 - y0);
            y = ymin;
        } else if cout & RIGHT != 0 {
            y = y0 + (y1 - y0) * (xmax - x0) / (x1 - x0);
            x = xmax;
        } else {
            y = y0 + (y1 - y0) * (xmin - x0) / (x1 - x0);
            x = xmin;
        }
        if cout == c0 {
            x0 = x;
            y0 = y;
            c0 = code(x0, y0);
        } else {
            x1 = x;
            y1 = y;
            c1 = code(x1, y1);
        }
    }
}

/// Clip a projected polyline (NaN-separated segments) to the viewport rectangle.
/// Returns a new points vec with proper NaN separators at clip boundaries.
fn clip_polyline_to_rect(
    pts: &[[f32; 3]],
    xmin: f32,
    ymin: f32,
    xmax: f32,
    ymax: f32,
    z: f32,
) -> Vec<[f32; 3]> {
    const NAN3: [f32; 3] = [f32::NAN, f32::NAN, f32::NAN];
    let mut result: Vec<[f32; 3]> = Vec::new();
    let mut i = 0;

    while i < pts.len() {
        // Skip NaN separators.
        if pts[i][0].is_nan() || pts[i][1].is_nan() {
            i += 1;
            continue;
        }
        // Gather contiguous run of finite points.
        let start = i;
        while i < pts.len() && pts[i][0].is_finite() && pts[i][1].is_finite() {
            i += 1;
        }
        let seg = &pts[start..i];
        if seg.len() < 2 {
            continue;
        }

        // Clip each edge and track pen state to insert NaN on lift.
        let mut pen_down = false;
        for j in 0..seg.len() - 1 {
            let [x0, y0, _] = seg[j];
            let [x1, y1, _] = seg[j + 1];
            match cs_clip(x0, y0, x1, y1, xmin, ymin, xmax, ymax) {
                None => {
                    pen_down = false;
                }
                Some((cx0, cy0, cx1, cy1)) => {
                    if !pen_down {
                        if !result.is_empty() {
                            result.push(NAN3);
                        }
                        result.push([cx0, cy0, z]);
                        pen_down = true;
                    } else if let Some(&[lx, ly, _]) = result.last() {
                        if (lx - cx0).abs() > 1e-4 || (ly - cy0).abs() > 1e-4 {
                            result.push(NAN3);
                            result.push([cx0, cy0, z]);
                        }
                    }
                    result.push([cx1, cy1, z]);
                    // If the exit point was clipped, lift pen.
                    if (cx1 - x1).abs() > 1e-4 || (cy1 - y1).abs() > 1e-4 {
                        pen_down = false;
                    }
                }
            }
        }
    }
    // Remove trailing NaN.
    while result
        .last()
        .map(|p: &[f32; 3]| p[0].is_nan())
        .unwrap_or(false)
    {
        result.pop();
    }
    result
}
