/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */

/**
 * useDrawingGeneration - Custom hook for 2D drawing generation logic
 *
 * Extracts the drawing generation pipeline from Section2DPanel, including:
 * - Section cut generation via Drawing2DGenerator
 * - Symbolic representation parsing and caching
 * - Hybrid drawing creation (symbolic + section cut)
 * - Bounding box alignment for symbolic lines
 * - Auto-generation effects (panel open, overlay enable, geometry change)
 * - Section plane change detection with overlap protection
 */

import { useCallback, useEffect, useRef, useState } from 'react';
import {
  Drawing2DGenerator,
  createSectionConfig,
  currentFloorBands,
  storeyFloorsFromMeshes,
  type Drawing2D,
  type DrawingLine,
  type SectionConfig,
  type ProfileEntry,
  type MeshOutline2D,
} from '@ifc-lite/drawing-2d';
import { GeometryProcessor, type GeometryResult } from '@ifc-lite/geometry';
import type { SpatialHierarchy } from '@ifc-lite/data';
import * as IfcWasm from '@ifc-lite/wasm';
import { customPlaneCenter } from '@/store';

// The winding-robust Rust `meshOutline2d` binding (issue #979) is gitignored →
// CI-built, so reference it defensively: against an older wasm bundle it's
// undefined and projection falls back to the TS mesh silhouette. The wasm
// module is already initialised (the model loaded through it), so the free
// function can be called without a GeometryProcessor instance.
interface MeshOutlineHandle {
  readonly axisMin: number;
  readonly axisMax: number;
  readonly contourCount: number;
  contour(index: number): Float32Array | undefined;
  free(): void;
}
type MeshOutline2dFn = (
  positions: Float32Array,
  indices: Uint32Array,
  axis: number,
  flipped: boolean,
) => MeshOutlineHandle | undefined;
const meshOutline2dFn = (IfcWasm as unknown as { meshOutline2d?: MeshOutline2dFn }).meshOutline2d;
const AXIS_CODE: Record<'x' | 'y' | 'z', number> = { x: 0, y: 1, z: 2 };

// Axis conversion from semantic (down/front/side) to geometric (x/y/z)
export const AXIS_MAP: Record<'down' | 'front' | 'side', 'x' | 'y' | 'z'> = {
  down: 'y',
  front: 'z',
  side: 'x',
};

// Depth of the slab IN FRONT of the section plane (in shifted-world
// metres) within which IFC annotation/grid primitives are kept. Beyond
// the slab they're culled — matches a typical plan-view "view depth"
// where dimensions for the next storey shouldn't bleed through. The
// shifted-bounds coordinate system the centroids and `position` both
// live in is already in metres (WASM applies `unit_scale` upstream).
export const ANNOTATION_VIEW_DEPTH = 1.2;

// View depth BEHIND a vertical (front/side) section cut within which
// construction projection is drawn, as a fraction of the model extent along the
// cut axis (issue #979 follow-up). A vertical section has no "storey" to scope
// to, so it projects a bounded slab behind the cut — near geometry solid,
// occluded/far dashed (hidden-line pass) — and culls the cut-away front half
// and anything past this depth. Half the model depth is a sensible default;
// tune here if sections feel too deep or too shallow.
export const SECTION_VIEW_DEPTH_FRACTION = 0.5;

interface UseDrawingGenerationParams {
  geometryResult: GeometryResult | null | undefined;
  // `spatialHierarchy` (optional — absent on cache-reopened models) backs the
  // current-floor projection scoping (issue #979 follow-up). The runtime
  // already passes the full DataStore from `useIfc()`, so this is a pure type
  // widen, not new prop threading.
  ifcDataStore: { source: Uint8Array; spatialHierarchy?: SpatialHierarchy } | null;
  /**
   * Section plane state. `custom` is the optional face-pick override
   * (issue #243); when set the cutter cuts on that arbitrary plane and
   * the cap basis flows from `custom.tangent`/`bitangent` so the cap
   * silhouette lands precisely on the tilted plane.
   */
  sectionPlane: {
    axis: 'down' | 'front' | 'side';
    position: number;
    flipped: boolean;
    custom?: {
      normal:    [number, number, number];
      distance:  number;
      pickedAt:  [number, number, number];
      tangent:   [number, number, number];
      bitangent: [number, number, number];
    };
  };
  displayOptions: { showHiddenLines: boolean; useSymbolicRepresentations: boolean; show3DOverlay: boolean; scale: number; showConstructionProjection: boolean };
  combinedHiddenIds: Set<number>;
  combinedIsolatedIds: Set<number> | null;
  computedIsolatedIds?: Set<number> | null;
  models: Map<string, { id: string; visible: boolean; idOffset?: number }>;
  panelVisible: boolean;
  drawing: Drawing2D | null;
  // Store actions
  setDrawing: (d: Drawing2D | null) => void;
  setDrawingStatus: (s: 'idle' | 'generating' | 'ready' | 'error') => void;
  setDrawingProgress: (p: number, phase: string) => void;
  setDrawingError: (e: string | null) => void;
}

interface UseDrawingGenerationResult {
  generateDrawing: (isRegenerate?: boolean) => Promise<void>;
  doRegenerate: () => Promise<void>;
  isRegenerating: boolean;
}

export function useDrawingGeneration({
  geometryResult,
  ifcDataStore,
  sectionPlane,
  displayOptions,
  combinedHiddenIds,
  combinedIsolatedIds,
  computedIsolatedIds,
  models,
  panelVisible,
  drawing,
  setDrawing,
  setDrawingStatus,
  setDrawingProgress,
  setDrawingError,
}: UseDrawingGenerationParams): UseDrawingGenerationResult {
  // Track if this is a regeneration (vs initial generation)
  const isRegeneratingRef = useRef(false);

  // Symbolic lines carry the parent primitive's world-space centroid so the
  // 2D Section filter below can cull them against the active cut plane —
  // cardinal axis OR a face-picked custom plane. The drawing-2d package's
  // DrawingLine has no per-line position slot; attaching the centroid as
  // extra fields keeps the change local since the canvas ignores anything
  // beyond DrawingLine's declared fields.
  //
  // Coordinate space matches the section cutter's input (shifted bounds):
  // - worldX: read from the polyline's 2D x (already RTC-shifted by WASM)
  // - worldZ: -(polyline 2D y) — WASM negates Z into the 2D y axis to
  //   match section-cut output handedness, so flip back here
  // - worldY: from the WASM `worldY` accessor (vertical elevation)
  type SymbolicDrawingLine = DrawingLine & {
    worldX?: number;
    worldY?: number;
    worldZ?: number;
  };

  // Cache for symbolic representations - these don't change with section position
  // Only re-parse when model or display options change
  const symbolicCacheRef = useRef<{
    lines: SymbolicDrawingLine[];
    entities: Set<number>;
    sourceId: string | null;
    useSymbolic: boolean;
  } | null>(null);

  // Cache for extracted extruded-solid profiles (issue #979 construction
  // projection). Like symbolic reps these are section-position-independent, so
  // they're parsed once per model and reused across section moves. Every typed
  // array is copied off the WASM heap (`.slice()`) and the WASM handles freed
  // deterministically before caching — caching a live view would dangle once
  // the shared dlmalloc heap grows/reuses (AGENTS.md §7).
  const profileCacheRef = useRef<{
    profiles: ProfileEntry[];
    sourceId: string | null;
  } | null>(null);

  // Cache for per-storey floor levels used to scope construction projection to
  // the current floor (issue #979 follow-up). Derived from mesh-Y, so it only
  // changes when the model/visibility set changes — keyed on the same
  // `modelCacheKey` as the profile cache.
  const storeyFloorsCacheRef = useRef<{
    floors: number[];
    sourceId: string | null;
  } | null>(null);

  // Generate drawing when panel opens
  const generateDrawing = useCallback(async (isRegenerate = false) => {
    if (!geometryResult?.meshes || geometryResult.meshes.length === 0) {
      // Clear the drawing when no geometry is available (e.g., all models hidden)
      setDrawing(null);
      setDrawingStatus('idle');
      setDrawingError('No visible geometry');
      return;
    }

    // Only show full loading overlay for initial generation, not regeneration
    if (!isRegenerate) {
      setDrawingStatus('generating');
      setDrawingProgress(0, 'Initializing...');
    }
    isRegeneratingRef.current = isRegenerate;

    // Parse symbolic representations if enabled (for hybrid mode)
    // OPTIMIZATION: Cache symbolic data - it doesn't change with section position
    let symbolicLines: SymbolicDrawingLine[] = [];
    let entitiesWithSymbols = new Set<number>();

    // For multi-model: create cache key from model count and visible model IDs
    // For single-model: use source byteLength as before
    const modelCacheKey = models.size > 0
      ? `${models.size}-${[...models.values()].filter(m => m.visible).map(m => m.id).sort().join('|')}`
      : (ifcDataStore?.source ? String(ifcDataStore.source.byteLength) : null);

    const useSymbolic = displayOptions.useSymbolicRepresentations && !!ifcDataStore?.source;

    // Check if we can use cached symbolic data
    const cache = symbolicCacheRef.current;
    const cacheValid = cache &&
      cache.sourceId === modelCacheKey &&
      cache.useSymbolic === useSymbolic;

    if (useSymbolic) {
      if (cacheValid) {
        // Use cached data - FAST PATH
        symbolicLines = cache.lines;
        entitiesWithSymbols = cache.entities;
      } else {
        // Need to parse - only on first load or when model changes
        try {
          if (!isRegenerate) {
            setDrawingProgress(5, 'Parsing symbolic representations...');
          }

          const processor = new GeometryProcessor();
          try {
            await processor.init();

            // SymbolicRepresentationCollection and each getPolyline/getCircle
            // item are wasm-bindgen handles owning WASM memory — free them
            // deterministically (AGENTS.md §7). Leaking them to GC lets the
            // FinalizationRegistry free them later against an already-grown/
            // reused shared dlmalloc heap, corrupting the allocator free-list.
            const symbolicCollection = processor.parseSymbolicRepresentations(ifcDataStore!.source);
            // For single-model (legacy) mode, model index is always 0
            // Multi-model symbolic parsing would require iterating over each model separately
            const symbolicModelIndex = 0;

            if (symbolicCollection) {
              try {
                if (!symbolicCollection.isEmpty) {
              // Process polylines
              for (let i = 0; i < symbolicCollection.polylineCount; i++) {
                const poly = symbolicCollection.getPolyline(i);
                if (!poly) continue;
                try {

                entitiesWithSymbols.add(poly.expressId);
                // poly.points is consumed synchronously within this iteration
                // (centroid sum + segment pushes read scalar values out of it);
                // the array itself is never stored, so no copy is needed.
                const points = poly.points;
                const pointCount = poly.pointCount;
                // WASM exposes `worldY` on every symbolic primitive — the
                // elevation of its parent placement (Z-up IFC, world-Y here).
                // The .d.ts shipped with the @ifc-lite/wasm package lags
                // behind the Rust source; read defensively so a stale build
                // returns undefined instead of throwing.
                const polyWorldY = (poly as unknown as { worldY?: number }).worldY;
                // Centroid in shifted world coords — derived from the 2D
                // points the WASM extractor already emits in section-cut
                // space. point.x = world X (RTC-shifted); point.y =
                // -world Z (negated to match cut-output handedness), so
                // flip the sign back to recover world Z. Computed once
                // per source polyline and shared across its segments.
                let sumX = 0;
                let sumY = 0;
                for (let p = 0; p < pointCount; p++) {
                  sumX += points[p * 2];
                  sumY += points[p * 2 + 1];
                }
                const polyWorldX = pointCount > 0 ? sumX / pointCount : undefined;
                const polyWorldZ = pointCount > 0 ? -sumY / pointCount : undefined;

                for (let j = 0; j < pointCount - 1; j++) {
                  symbolicLines.push({
                    line: {
                      start: { x: points[j * 2], y: points[j * 2 + 1] },
                      end: { x: points[(j + 1) * 2], y: points[(j + 1) * 2 + 1] }
                    },
                    category: 'silhouette',
                    visibility: 'visible',
                    entityId: poly.expressId,
                    ifcType: poly.ifcType,
                    modelIndex: symbolicModelIndex,
                    depth: 0,
                    worldX: polyWorldX,
                    worldY: polyWorldY,
                    worldZ: polyWorldZ,
                  });
                }

                if (poly.isClosed && pointCount > 2) {
                  symbolicLines.push({
                    line: {
                      start: { x: points[(pointCount - 1) * 2], y: points[(pointCount - 1) * 2 + 1] },
                      end: { x: points[0], y: points[1] }
                    },
                    category: 'silhouette',
                    visibility: 'visible',
                    entityId: poly.expressId,
                    ifcType: poly.ifcType,
                    modelIndex: symbolicModelIndex,
                    depth: 0,
                    worldX: polyWorldX,
                    worldY: polyWorldY,
                    worldZ: polyWorldZ,
                  });
                }
                } finally {
                  poly.free();
                }
              }

              // Process circles/arcs
              for (let i = 0; i < symbolicCollection.circleCount; i++) {
                const circle = symbolicCollection.getCircle(i);
                if (!circle) continue;
                try {

                entitiesWithSymbols.add(circle.expressId);
                const numSegments = circle.isFullCircle ? 32 : 16;
                const circleWorldY = (circle as unknown as { worldY?: number }).worldY;
                // Centre in shifted world coords. circle.centerX is
                // already RTC-shifted X; circle.centerY carries the
                // negated Z (see polyline note above) — flip to recover.
                const circleWorldX = circle.centerX;
                const circleWorldZ = -circle.centerY;

                for (let j = 0; j < numSegments; j++) {
                  const t1 = j / numSegments;
                  const t2 = (j + 1) / numSegments;
                  const a1 = circle.startAngle + t1 * (circle.endAngle - circle.startAngle);
                  const a2 = circle.startAngle + t2 * (circle.endAngle - circle.startAngle);

                  symbolicLines.push({
                    line: {
                      start: {
                        x: circle.centerX + circle.radius * Math.cos(a1),
                        y: circle.centerY + circle.radius * Math.sin(a1),
                      },
                      end: {
                        x: circle.centerX + circle.radius * Math.cos(a2),
                        y: circle.centerY + circle.radius * Math.sin(a2),
                      },
                    },
                    category: 'silhouette',
                    visibility: 'visible',
                    entityId: circle.expressId,
                    ifcType: circle.ifcType,
                    modelIndex: symbolicModelIndex,
                    depth: 0,
                    worldX: circleWorldX,
                    worldY: circleWorldY,
                    worldZ: circleWorldZ,
                  });
                }
                } finally {
                  circle.free();
                }
              }
                }
              } finally {
                symbolicCollection.free();
              }
            }
          } finally {
            processor.dispose();
          }

          // Cache the parsed data
          symbolicCacheRef.current = {
            lines: symbolicLines,
            entities: entitiesWithSymbols,
            sourceId: modelCacheKey,
            useSymbolic,
          };
        } catch (error) {
          console.warn('Symbolic parsing failed:', error);
          symbolicLines = [];
          entitiesWithSymbols = new Set<number>();
        }
      }
    } else {
      // Clear cache if symbolic is disabled
      if (cache && cache.useSymbolic) {
        symbolicCacheRef.current = null;
      }
    }

    // Construction projection runs on any CARDINAL cut (plan 'down' + vertical
    // 'front'/'side'), but NOT a face-picked custom plane (the band classifier
    // and outline binding are cardinal-only). Plan and section use different
    // boundaries: plan scopes to the current storey; a vertical section has no
    // "storey", so it projects a bounded view depth behind the cut (see the
    // band computation below). The UI gates the toggle to the same set; the
    // persisted flag can survive a switch to a custom plane, so gate here too.
    const projectionSupported = !sectionPlane.custom;
    const projectionOn = projectionSupported && displayOptions.showConstructionProjection;

    // ── Construction projection profiles (issue #979) ────────────────────────
    // Extract extruded-area-solid profiles for the clean projection path. Only
    // for PLAN cuts: the profile projector draws a solid's base footprint, which
    // is the plan representation but collapses to a base edge on a vertical
    // section — so front/side cuts use the mesh-silhouette/outline path instead
    // (profiles stay empty → every mesh silhouettes). Cached per model since
    // they don't move with the section. Single-model (modelIndex 0) for now,
    // mirroring the symbolic path's federation limitation.
    const profilesNeeded = projectionOn && sectionPlane.axis === 'down';
    let profiles: ProfileEntry[] = [];
    if (profilesNeeded && ifcDataStore?.source) {
      const pcache = profileCacheRef.current;
      if (pcache && pcache.sourceId === modelCacheKey) {
        profiles = pcache.profiles;
      } else {
        if (!isRegenerate) {
          setDrawingProgress(10, 'Extracting profiles...');
        }
        try {
          const processor = new GeometryProcessor();
          try {
            await processor.init();
            // ProfileCollection + each ProfileEntryJs are WASM-bindgen handles
            // owning WASM memory. Copy every typed array off the heap with
            // `.slice()` and free each handle deterministically before caching
            // (AGENTS.md §7 — leaking to GC corrupts the shared dlmalloc heap).
            const collection = processor.extractProfiles(ifcDataStore.source, 0);
            if (collection) {
              try {
                // Profiles come back in UNSHIFTED WebGL world space, but the
                // meshes and the section position live in the render frame
                // (issue #945 RTC / large-coordinate shift). Subtract the same
                // shift so projection lines land on the cut geometry for
                // georeferenced models — a no-op for small-coordinate models
                // (AC20). The WASM mesh path subtracts the RTC offset in IFC
                // Z-up then converts to Y-up via (x,y,z)→(x,z,−y), so the Y-up
                // shift is (rtc.x, rtc.z, −rtc.y); the TS path instead
                // subtracts `originShift`, already in Y-up.
                const ci = geometryResult.coordinateInfo;
                const rtc = ci.wasmRtcOffset;
                const shift = rtc
                  ? { x: rtc.x, y: rtc.z, z: -rtc.y }
                  : ci.originShift;
                const len = collection.length;
                for (let i = 0; i < len; i++) {
                  const entry = collection.get(i);
                  if (!entry) continue;
                  try {
                    const transform = entry.transform.slice();
                    transform[12] -= shift.x;
                    transform[13] -= shift.y;
                    transform[14] -= shift.z;
                    profiles.push({
                      expressId: entry.expressId,
                      ifcType: entry.ifcType,
                      outerPoints: entry.outerPoints.slice(),
                      holeCounts: entry.holeCounts.slice(),
                      holePoints: entry.holePoints.slice(),
                      transform,
                      extrusionDir: entry.extrusionDir.slice(),
                      extrusionDepth: entry.extrusionDepth,
                      modelIndex: 0,
                    });
                  } finally {
                    entry.free();
                  }
                }
              } finally {
                collection.free();
              }
            }
            profileCacheRef.current = { profiles, sourceId: modelCacheKey };
          } finally {
            processor.dispose();
          }
        } catch (error) {
          // Degrade gracefully: the drawing still renders without projection.
          console.warn('Profile extraction failed:', error);
          profiles = [];
        }
      }
    } else if (!projectionOn && profileCacheRef.current) {
      // Projection fully off: drop the cache so a re-enable re-extracts cleanly.
      // A plan↔section switch (projection still on) keeps the cache so flipping
      // back to a plan reuses the extracted profiles.
      profileCacheRef.current = null;
    }

    let generator: Drawing2DGenerator | null = null;
    try {
      generator = new Drawing2DGenerator();
      await generator.initialize();

      // Convert semantic axis to geometric
      const axis = AXIS_MAP[sectionPlane.axis];

      // Calculate section position from percentage using coordinateInfo bounds
      const bounds = geometryResult.coordinateInfo.shiftedBounds;

      const axisMin = bounds.min[axis];
      const axisMax = bounds.max[axis];
      const position = axisMin + (sectionPlane.position / 100) * (axisMax - axisMin);

      // Calculate max depth as half the model extent
      const maxDepth = (axisMax - axisMin) * 0.5;

      // Construction-projection bands (issue #979 + current-floor follow-up).
      // Project geometry on each side of the cut and let the band classifier
      // split it (below → solid, above → dashed). `fullExtent` (the whole model
      // height) is the baseline; for a multi-storey model on a plan cut the
      // bands are instead clamped to the storey the cut sits in, so other
      // floors don't bleed onto the plan (e.g. a roof two levels up — the
      // reported bug). Flip-invariant: the classifier applies the flip sign
      // itself. Floor at 1mm so a degenerate zero-extent model (or a storey
      // collapsed to a single slab) doesn't yield 0-width bands that cull every
      // element sitting on the plane.
      const fullExtent = Math.max(axisMax - axisMin, 1e-3);
      let belowDepth = fullExtent;
      let aboveDepth = fullExtent;

      // Auto-scope to the current floor only when it's safe and meaningful:
      // a plan ('down') cut with projection on, a single model (storey ids are
      // LOCAL express ids — federation would mismatch global mesh ids), no
      // active manual isolation or storey selection (those already scope the
      // set and the user's explicit choice wins), spatial-hierarchy data
      // present (absent on cache-reopened models), and at most ONE building.
      // A single IFC can hold several IfcBuildings with staggered storey
      // elevations; flattening all their storey minima into one band mis-scopes
      // (a cut on building B's ground floor capped by building A's upper
      // storey), so multi-building models fall back to full extent too.
      // Otherwise keep the shipped full-extent behavior so single-storey /
      // cache-loaded / federated / multi-building models don't regress.
      const sh = ifcDataStore?.spatialHierarchy;
      const canScopeFloor =
        projectionOn &&
        sectionPlane.axis === 'down' &&
        !sectionPlane.custom &&
        models.size <= 1 &&
        combinedIsolatedIds === null &&
        !(computedIsolatedIds && computedIsolatedIds.size > 0) &&
        sh !== undefined &&
        sh.byBuilding.size <= 1;
      if (canScopeFloor && sh) {
        const cached = storeyFloorsCacheRef.current;
        const floors =
          cached && cached.sourceId === modelCacheKey
            ? cached.floors
            : storeyFloorsFromMeshes(geometryResult.meshes, sh.elementToStorey);
        if (!cached || cached.sourceId !== modelCacheKey) {
          storeyFloorsCacheRef.current = { floors, sourceId: modelCacheKey };
        }
        // Need ≥2 storeys to scope: with 0/1 storey there is no "other floor"
        // to exclude, and full extent keeps an overhead roof projecting.
        if (floors.length >= 2) {
          // `currentFloorBands` returns GEOMETRIC depths — `below` toward the
          // floor, `above` toward the ceiling. The band classifier reads them
          // in FLIP-ADJUSTED depth space (d<0 = `below` slot), so on a flipped
          // plan cut (looking up — a reflected-ceiling-plan style view) the
          // floor/ceiling map to the opposite slots and the magnitudes must be
          // swapped. The shipped full-extent bands were symmetric so this never
          // mattered before; the asymmetric storey bands make flip significant.
          const bands = currentFloorBands(floors, position, axisMin, axisMax);
          belowDepth = sectionPlane.flipped ? bands.above : bands.below;
          aboveDepth = sectionPlane.flipped ? bands.below : bands.above;
        }
      }

      // Vertical section (front/side): storeys don't bound it. Project a
      // bounded view depth BEHIND the cut and cull the cut-away front half +
      // anything past that depth. "Behind" is always the `below` (d<0) band:
      // the band classifier's flip and the view direction's flip cancel, so
      // this is flip-invariant (no swap needed). Near geometry draws solid;
      // the hidden-line pass dashes occluded/far parts. (Profiles aren't
      // extracted off-plan, so this geometry comes from the mesh silhouette.)
      if (projectionOn && !sectionPlane.custom && sectionPlane.axis !== 'down') {
        belowDepth = Math.max((axisMax - axisMin) * SECTION_VIEW_DEPTH_FRACTION, 1e-3);
        aboveDepth = 1e-3; // cull the half in front of the cut
      }

      // Adjust progress to account for symbolic parsing phase (0-20%)
      const progressOffset = symbolicLines.length > 0 ? 20 : 0;
      const progressScale = symbolicLines.length > 0 ? 0.8 : 1;
      const progressCallback = (stage: string, prog: number) => {
        setDrawingProgress(progressOffset + prog * 100 * progressScale, stage);
      };

      // Create section config
      const config: SectionConfig = createSectionConfig(axis, position, {
        projectionDepth: maxDepth,
        projectionBelowDepth: belowDepth,
        projectionAboveDepth: aboveDepth,
        includeHiddenLines: displayOptions.showHiddenLines,
        scale: displayOptions.scale,
      });

      // Override the flipped setting
      config.plane.flipped = sectionPlane.flipped;

      // Face-pick custom plane (issue #243): hand the cutter the explicit
      // basis so its 2D output sits in the same coordinate system the cap
      // shader will lift back to 3D — without this the polygon and the
      // shader-clipped silhouette would disagree on every non-cardinal
      // pick (PR #581's bug).
      if (sectionPlane.custom) {
        const c = sectionPlane.custom;
        // Use the LIVE plane anchor (pickedAt projected onto the current
        // plane), not pickedAt itself. As the user drags the gizmo only
        // `distance` changes — pickedAt sits off the live plane, and
        // using it as the basis origin makes the round-trip lift drop
        // the normal-component, freezing the cap polygons at the
        // original pick location while the geometry clip slides. Using
        // the projected center keeps the basis origin ON the live plane
        // so the cutter's 2D points lift back to the actual cut surface.
        const origin = customPlaneCenter(c);
        config.plane.customPlane = {
          normal:    { x: c.normal[0],   y: c.normal[1],   z: c.normal[2]   },
          distance:  c.distance,
          origin:    { x: origin[0],     y: origin[1],     z: origin[2]     },
          tangent:   { x: c.tangent[0],  y: c.tangent[1],  z: c.tangent[2]  },
          bitangent: { x: c.bitangent[0], y: c.bitangent[1], z: c.bitangent[2] },
        };
      }

      // Filter meshes by visibility (respect 3D hiding/isolation)
      let meshesToProcess = geometryResult.meshes;

      // Filter out hidden entities (using combined multi-model set)
      if (combinedHiddenIds.size > 0) {
        meshesToProcess = meshesToProcess.filter(
          mesh => !combinedHiddenIds.has(mesh.expressId)
        );
      }

      // Filter by isolation (if active, using combined multi-model set)
      if (combinedIsolatedIds !== null) {
        meshesToProcess = meshesToProcess.filter(
          mesh => combinedIsolatedIds.has(mesh.expressId)
        );
      }

      // Also filter by computedIsolatedIds (storey selection)
      if (computedIsolatedIds !== null && computedIsolatedIds !== undefined && computedIsolatedIds.size > 0) {
        const isolatedSet = computedIsolatedIds;
        meshesToProcess = meshesToProcess.filter(
          mesh => isolatedSet.has(mesh.expressId)
        );
      }

      // If all meshes were filtered out by visibility, clear the drawing
      if (meshesToProcess.length === 0) {
        setDrawing(null);
        setDrawingStatus('idle');
        setDrawingError(null);
        return;
      }

      // Construction projection (issue #979): when enabled, project geometry
      // beyond the cut. The clean profile path handles extruded solids; the
      // silhouette path (includeEdges) covers non-extruded geometry — roofs,
      // stairs, site — that has no profile. The below/above band split drives
      // solid vs dashed; hidden-line removal (below `includeHiddenLines`) is an
      // additional occlusion pass the user controls via "show hidden lines".

      // Apply the SAME hiding/isolation filters to the profiles as to the
      // meshes, so projection respects 3D hiding and storey isolation —
      // otherwise other storeys' profiles project through the plan and the
      // dedup keys (built from profiles) would suppress silhouettes for
      // entities that aren't actually drawn.
      let projectionProfiles = profiles;
      if (projectionOn && profiles.length > 0) {
        if (combinedHiddenIds.size > 0) {
          projectionProfiles = projectionProfiles.filter((p) => !combinedHiddenIds.has(p.expressId));
        }
        if (combinedIsolatedIds !== null) {
          projectionProfiles = projectionProfiles.filter((p) => combinedIsolatedIds.has(p.expressId));
        }
        if (computedIsolatedIds !== null && computedIsolatedIds !== undefined && computedIsolatedIds.size > 0) {
          const isolatedSet = computedIsolatedIds;
          projectionProfiles = projectionProfiles.filter((p) => isolatedSet.has(p.expressId));
        }
      }

      // Winding-robust outline provider for non-extruded geometry (roofs,
      // stairs, site). Calls the Rust meshOutline2d binding per mesh; each call
      // copies the contour data off the WASM heap and frees the handle inline.
      // Undefined when projection is off or the binding isn't in this wasm
      // build → the generator falls back to the TS mesh silhouette.
      const outlineProvider =
        projectionOn && typeof meshOutline2dFn === 'function'
          ? (mesh: { positions: Float32Array; indices: Uint32Array }, axis: 'x' | 'y' | 'z', flipped: boolean): MeshOutline2D | null => {
              try {
                const handle = meshOutline2dFn(mesh.positions, mesh.indices, AXIS_CODE[axis], flipped);
                if (!handle) return null;
                try {
                  const contours: Float32Array[] = [];
                  for (let i = 0; i < handle.contourCount; i++) {
                    const ring = handle.contour(i);
                    if (ring) contours.push(ring.slice()); // copy off the WASM heap
                  }
                  if (contours.length === 0) return null;
                  return { contours, axisMin: handle.axisMin, axisMax: handle.axisMax };
                } finally {
                  handle.free();
                }
              } catch {
                return null; // binding unavailable/failed → silhouette fallback
              }
            }
          : undefined;

      const result = await generator.generate(
        meshesToProcess,
        config,
        {
          // Respect the "show hidden lines" toggle: occlusion can downgrade
          // visible (below-cut) projection lines to dashed. Overhead lines stay
          // dashed regardless (the generator passes them through unchanged).
          includeHiddenLines: projectionOn ? displayOptions.showHiddenLines : false,
          includeProjection: projectionOn,
          includeEdges: projectionOn,
          mergeLines: true,
          outlineProvider,
          onProgress: progressCallback,
        },
        projectionOn ? projectionProfiles : undefined,
      );

      // If we have symbolic representations, create a hybrid drawing
      if (symbolicLines.length > 0 && entitiesWithSymbols.size > 0) {
        // Get entity IDs that actually appear in the section cut (these are being cut by the plane)
        const cutEntityIds = new Set<number>();
        for (const line of result.lines) {
          if (line.entityId !== undefined) {
            cutEntityIds.add(line.entityId);
          }
        }
        // Also check cut polygons for entity IDs
        for (const poly of result.cutPolygons ?? []) {
          if ((poly as { entityId?: number }).entityId !== undefined) {
            cutEntityIds.add((poly as { entityId?: number }).entityId!);
          }
        }

        // When the user toggles `sectionPlane.flipped` on a cardinal axis,
        // the cutter negates the 2D U axis (see `projectTo2D` in
        // @ifc-lite/drawing-2d/math.ts and `data[6] = flipU` in the GPU
        // cutter). Symbolic primitives come out of WASM in the cutter's
        // UNFLIPPED basis — for the plan ('y') case `(line.x = worldX − rtc,
        // line.y = −worldY + rtc)` — so on a flipped section the cut
        // polygons land at −X while the symbolic lines stay at +X. The
        // result the user reported: annotations sitting NEXT TO the model
        // as if they were mirrored across the model's centre, instead of
        // staying with the cut. Mirror symbolic X here to match the cutter
        // for cardinal flipped sections. Custom face-pick planes use
        // `projectTo2DBasis` (no U flip), so leave them untouched —
        // symbolic alignment on an arbitrary basis is a separate problem
        // and out of scope for this fix.
        const mirrorSymbolicX = sectionPlane.flipped && !sectionPlane.custom;
        const orientedSymbolicLines: SymbolicDrawingLine[] = mirrorSymbolicX
          ? symbolicLines.map((line) => ({
              ...line,
              line: {
                start: { x: -line.line.start.x, y: line.line.start.y },
                end:   { x: -line.line.end.x,   y: line.line.end.y   },
              },
            }))
          : symbolicLines;

        // Cull annotations to a thin view-depth slab IN FRONT of the cut.
        //
        // IfcAnnotation / IfcGridAxis polylines (dimensions, room tags, grid
        // bubbles) live at a single elevation but have no body geometry —
        // the `cutEntityIds.has(line.entityId)` filter below never matches
        // them, so without this they render regardless of where the
        // section sits.
        //
        // Reduce every cut mode (cardinal X/Y/Z + face-pick custom plane)
        // to a single half-space test against a unit normal + signed
        // distance. For cardinal axes the normal is the basis vector and
        // distance is `position` (already in shifted-bounds coords, the
        // same space the symbolic centroids land in). For custom planes
        // the WASM cutter already uses `normal`/`distance` verbatim, so
        // re-use both here for consistency with the cap.
        //
        // The kept window is `−ANNOTATION_VIEW_DEPTH ≤ signedDist ≤ 0` on
        // the −normal side — the side BELOW a down-looking camera, where
        // IFC dimensions live (authored at the storey's floor elevation,
        // not at the cut height). Flipped sections look at the same world
        // from the opposite side, so the slab mirrors to
        // `0 ≤ signedDist ≤ ANNOTATION_VIEW_DEPTH`.
        //
        // Anything on the wrong side of the cut, or farther than the view
        // depth on the right side, is dropped — without the upper bound,
        // dimensions from every storey beyond the cut stacked on top of
        // each other because the half-space alone is unbounded along the
        // camera axis.
        //
        // Annotations missing a recoverable centroid (older WASM build,
        // or a degenerate polyline) are kept — over-rendering is preferable
        // to silently dropping authored dimensions when the runtime can't
        // classify them.
        const cullNormal: [number, number, number] = sectionPlane.custom
          ? sectionPlane.custom.normal
          : axis === 'x' ? [1, 0, 0]
          : axis === 'y' ? [0, 1, 0]
          : [0, 0, 1];
        const cullDistance = sectionPlane.custom ? sectionPlane.custom.distance : position;
        const annotationCulled = orientedSymbolicLines.filter((line) => {
          const isAnnotationLike = line.ifcType === 'IfcAnnotation' || line.ifcType === 'IfcGridAxis';
          if (!isAnnotationLike) return true;
          const wx = line.worldX;
          const wy = line.worldY;
          const wz = line.worldZ;
          if (wx === undefined || wy === undefined || wz === undefined) return true;
          const signedDist =
            wx * cullNormal[0] +
            wy * cullNormal[1] +
            wz * cullNormal[2] -
            cullDistance;
          if (sectionPlane.flipped) {
            return signedDist >= 0 && signedDist <= ANNOTATION_VIEW_DEPTH;
          }
          return signedDist <= 0 && signedDist >= -ANNOTATION_VIEW_DEPTH;
        });

        // Only include symbolic lines for entities that are ACTUALLY being cut
        // This filters out symbols from other floors/levels not intersected by the section plane
        const relevantSymbolicLines = annotationCulled.filter(line =>
          line.entityId !== undefined && cutEntityIds.has(line.entityId)
        );

        // Get the set of entities that have both symbols AND are being cut
        const entitiesWithRelevantSymbols = new Set<number>();
        for (const line of relevantSymbolicLines) {
          if (line.entityId !== undefined) {
            entitiesWithRelevantSymbols.add(line.entityId);
          }
        }

        // Align symbolic geometry with section cut geometry using bounding box matching
        // Plan representations often have different local origins than Body representations
        // So we compute per-entity transforms to align Plan bbox center with section cut bbox center

        // Build per-entity bounding boxes for section cut
        const sectionCutBounds = new Map<number, { minX: number; minY: number; maxX: number; maxY: number }>();
        const updateBounds = (entityId: number, x: number, y: number) => {
          const bounds = sectionCutBounds.get(entityId) ?? { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
          bounds.minX = Math.min(bounds.minX, x);
          bounds.minY = Math.min(bounds.minY, y);
          bounds.maxX = Math.max(bounds.maxX, x);
          bounds.maxY = Math.max(bounds.maxY, y);
          sectionCutBounds.set(entityId, bounds);
        };
        for (const line of result.lines) {
          if (line.entityId === undefined) continue;
          updateBounds(line.entityId, line.line.start.x, line.line.start.y);
          updateBounds(line.entityId, line.line.end.x, line.line.end.y);
        }
        // Include cut polygon vertices in bounds computation
        for (const poly of result.cutPolygons ?? []) {
          const entityId = (poly as { entityId?: number }).entityId;
          if (entityId === undefined) continue;
          for (const pt of poly.polygon.outer) {
            updateBounds(entityId, pt.x, pt.y);
          }
          for (const hole of poly.polygon.holes) {
            for (const pt of hole) {
              updateBounds(entityId, pt.x, pt.y);
            }
          }
        }

        // Build per-entity bounding boxes for symbolic
        const symbolicBounds = new Map<number, { minX: number; minY: number; maxX: number; maxY: number }>();
        for (const line of relevantSymbolicLines) {
          if (line.entityId === undefined) continue;
          const bounds = symbolicBounds.get(line.entityId) ?? { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
          bounds.minX = Math.min(bounds.minX, line.line.start.x, line.line.end.x);
          bounds.minY = Math.min(bounds.minY, line.line.start.y, line.line.end.y);
          bounds.maxX = Math.max(bounds.maxX, line.line.start.x, line.line.end.x);
          bounds.maxY = Math.max(bounds.maxY, line.line.start.y, line.line.end.y);
          symbolicBounds.set(line.entityId, bounds);
        }

        // Compute per-entity alignment transforms (center-to-center offset)
        const alignmentOffsets = new Map<number, { dx: number; dy: number }>();
        for (const entityId of entitiesWithRelevantSymbols) {
          const scBounds = sectionCutBounds.get(entityId);
          const symBounds = symbolicBounds.get(entityId);
          if (scBounds && symBounds) {
            const scCenterX = (scBounds.minX + scBounds.maxX) / 2;
            const scCenterY = (scBounds.minY + scBounds.maxY) / 2;
            const symCenterX = (symBounds.minX + symBounds.maxX) / 2;
            const symCenterY = (symBounds.minY + symBounds.maxY) / 2;
            alignmentOffsets.set(entityId, {
              dx: scCenterX - symCenterX,
              dy: scCenterY - symCenterY,
            });
          }
        }

        // Apply alignment offsets to symbolic lines
        const alignedSymbolicLines = relevantSymbolicLines.map(line => {
          const offset = line.entityId !== undefined ? alignmentOffsets.get(line.entityId) : undefined;
          if (offset) {
            return {
              ...line,
              line: {
                start: { x: line.line.start.x + offset.dx, y: line.line.start.y + offset.dy },
                end: { x: line.line.end.x + offset.dx, y: line.line.end.y + offset.dy },
              },
            };
          }
          return line;
        });

        // Filter out section cut lines for entities that have relevant symbolic representations
        const filteredLines = result.lines.filter((line: DrawingLine) =>
          line.entityId === undefined || !entitiesWithRelevantSymbols.has(line.entityId)
        );

        // Also filter cut polygons for entities with relevant symbols
        const filteredCutPolygons = result.cutPolygons?.filter((poly: { entityId?: number }) =>
          poly.entityId === undefined || !entitiesWithRelevantSymbols.has(poly.entityId)
        ) ?? [];

        // Combine filtered section cuts with aligned symbolic lines
        const combinedLines = [...filteredLines, ...alignedSymbolicLines];

        // Recalculate bounds with combined lines and polygons
        let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
        for (const line of combinedLines) {
          minX = Math.min(minX, line.line.start.x, line.line.end.x);
          minY = Math.min(minY, line.line.start.y, line.line.end.y);
          maxX = Math.max(maxX, line.line.start.x, line.line.end.x);
          maxY = Math.max(maxY, line.line.start.y, line.line.end.y);
        }
        // Include polygon vertices in bounds
        for (const poly of filteredCutPolygons) {
          for (const pt of poly.polygon.outer) {
            minX = Math.min(minX, pt.x);
            minY = Math.min(minY, pt.y);
            maxX = Math.max(maxX, pt.x);
            maxY = Math.max(maxY, pt.y);
          }
          for (const hole of poly.polygon.holes) {
            for (const pt of hole) {
              minX = Math.min(minX, pt.x);
              minY = Math.min(minY, pt.y);
              maxX = Math.max(maxX, pt.x);
              maxY = Math.max(maxY, pt.y);
            }
          }
        }

        // Create hybrid drawing
        const hybridDrawing: Drawing2D = {
          ...result,
          lines: combinedLines,
          cutPolygons: filteredCutPolygons,
          bounds: {
            min: { x: isFinite(minX) ? minX : result.bounds.min.x, y: isFinite(minY) ? minY : result.bounds.min.y },
            max: { x: isFinite(maxX) ? maxX : result.bounds.max.x, y: isFinite(maxY) ? maxY : result.bounds.max.y },
          },
          stats: {
            ...result.stats,
            cutLineCount: combinedLines.length,
          },
        };

        setDrawing(hybridDrawing);
      } else {
        setDrawing(result);
      }

      // Always set status to ready (whether initial generation or regeneration)
      setDrawingStatus('ready');
      isRegeneratingRef.current = false;
    } catch (error) {
      console.error('Drawing generation failed:', error);
      setDrawingError(error instanceof Error ? error.message : 'Generation failed');
    } finally {
      // Always cleanup generator to prevent resource leaks
      generator?.dispose();
    }
  }, [
    geometryResult,
    ifcDataStore,
    sectionPlane,
    displayOptions,
    combinedHiddenIds,
    combinedIsolatedIds,
    computedIsolatedIds,
    models,
    setDrawing,
    setDrawingStatus,
    setDrawingProgress,
    setDrawingError,
  ]);

  // Track panel visibility and geometry for detecting changes
  const prevPanelVisibleRef = useRef(false);
  const prevOverlayEnabledRef = useRef(false);
  const prevMeshCountRef = useRef(0);

  // Auto-generate when panel opens (or 3D overlay is enabled) and no drawing exists
  // Also regenerate when geometry changes significantly (e.g., models hidden/shown)
  useEffect(() => {
    const wasVisible = prevPanelVisibleRef.current;
    const wasOverlayEnabled = prevOverlayEnabledRef.current;
    const prevMeshCount = prevMeshCountRef.current;
    const currentMeshCount = geometryResult?.meshes?.length ?? 0;
    const hasGeometry = currentMeshCount > 0;

    // Track panel visibility separately from overlay
    const panelJustOpened = panelVisible && !wasVisible;
    const overlayJustEnabled = displayOptions.show3DOverlay && !wasOverlayEnabled;
    const isNowActive = panelVisible || displayOptions.show3DOverlay;
    const geometryChanged = currentMeshCount !== prevMeshCount;

    // Always update refs
    prevPanelVisibleRef.current = panelVisible;
    prevOverlayEnabledRef.current = displayOptions.show3DOverlay;
    prevMeshCountRef.current = currentMeshCount;

    if (isNowActive) {
      if (!hasGeometry) {
        // No geometry available - clear the drawing
        if (drawing) {
          setDrawing(null);
          setDrawingStatus('idle');
        }
      } else if (panelJustOpened || overlayJustEnabled || !drawing || geometryChanged) {
        // Generate if:
        // 1. Panel just opened, OR
        // 2. Overlay just enabled, OR
        // 3. No drawing exists, OR
        // 4. Geometry changed significantly (models hidden/shown)
        generateDrawing();
      }
    }
  }, [panelVisible, displayOptions.show3DOverlay, drawing, geometryResult, generateDrawing, setDrawing, setDrawingStatus]);

  // Auto-regenerate when section plane changes
  // Strategy: INSTANT - no debounce, but prevent overlapping computations
  // The generation time itself acts as natural batching for fast slider movements
  //
  // For face-picked custom planes (issue #243), `customKey` collapses the
  // plane's normal+distance into a string we can compare cheaply — without
  // it dragging the gizmo wouldn't trigger regeneration because the
  // cardinal axis/position/flipped triple stays the same.
  const customKey = (sp: { custom?: { normal: [number, number, number]; distance: number } }) =>
    sp.custom ? `${sp.custom.normal.join(',')}|${sp.custom.distance}` : '';
  const sectionRef = useRef({
    axis: sectionPlane.axis,
    position: sectionPlane.position,
    flipped: sectionPlane.flipped,
    customKey: customKey(sectionPlane),
  });
  const isGeneratingRef = useRef(false);
  const latestSectionRef = useRef({
    axis: sectionPlane.axis,
    position: sectionPlane.position,
    flipped: sectionPlane.flipped,
    customKey: customKey(sectionPlane),
  });
  const [isRegenerating, setIsRegenerating] = useState(false);

  // Stable regenerate function that handles overlapping calls
  const doRegenerate = useCallback(async () => {
    if (isGeneratingRef.current) {
      // Already generating - the latest position is already tracked in latestSectionRef
      // When current generation finishes, it will check if another is needed
      return;
    }

    isGeneratingRef.current = true;
    setIsRegenerating(true);

    // Capture position at start of generation
    const targetSection = { ...latestSectionRef.current };

    try {
      await generateDrawing(true);
    } finally {
      isGeneratingRef.current = false;
      setIsRegenerating(false);

      // Check if section changed while we were generating
      const current = latestSectionRef.current;
      if (
        current.axis !== targetSection.axis ||
        current.position !== targetSection.position ||
        current.flipped !== targetSection.flipped ||
        current.customKey !== targetSection.customKey
      ) {
        // Position changed during generation - regenerate immediately with latest
        // Use microtask to avoid blocking
        queueMicrotask(() => doRegenerate());
      }
    }
  }, [generateDrawing]);

  const customKeyValue = customKey(sectionPlane);
  useEffect(() => {
    // Always update latest section ref (even if generating)
    latestSectionRef.current = {
      axis: sectionPlane.axis,
      position: sectionPlane.position,
      flipped: sectionPlane.flipped,
      customKey: customKeyValue,
    };

    // Check if section plane actually changed from last processed
    const prev = sectionRef.current;
    if (
      prev.axis === sectionPlane.axis &&
      prev.position === sectionPlane.position &&
      prev.flipped === sectionPlane.flipped &&
      prev.customKey === customKeyValue
    ) {
      return;
    }

    // Update processed ref
    sectionRef.current = {
      axis: sectionPlane.axis,
      position: sectionPlane.position,
      flipped: sectionPlane.flipped,
      customKey: customKeyValue,
    };

    // If panel is visible OR 3D overlay is enabled, and we have geometry, regenerate INSTANTLY
    if ((panelVisible || displayOptions.show3DOverlay) && geometryResult?.meshes) {
      // Start immediately - no debounce
      // doRegenerate handles preventing overlaps and will auto-regenerate with latest when done
      doRegenerate();
    }
  }, [panelVisible, displayOptions.show3DOverlay, sectionPlane.axis, sectionPlane.position, sectionPlane.flipped, customKeyValue, geometryResult, combinedHiddenIds, combinedIsolatedIds, computedIsolatedIds, doRegenerate]);

  return {
    generateDrawing,
    doRegenerate,
    isRegenerating,
  };
}

export default useDrawingGeneration;
