/* 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/. */

/**
 * Lazy extraction of IfcAnnotation 2D curves for the section-plane overlay.
 *
 * The WASM `parseSymbolicRepresentations` already emits polylines and arcs in
 * the same 2D coordinate space the Section2DPanel feeds to
 * `Section2DOverlayRenderer`. We only ever need the data when the IFC
 * Annotation toggle is on AND a section plane is active, so the parse runs
 * lazily and is cached per model source.
 */

import { useEffect, useMemo, useRef, useState } from 'react';
import { GeometryProcessor } from '@ifc-lite/geometry';
import type { DrawingLine2D } from '@ifc-lite/renderer';
import { decodeIfcString } from '@ifc-lite/encoding';
import { useViewerStore } from '@/store';
import { useShallow } from 'zustand/react/shallow';
import type { IfcDataStore } from '@ifc-lite/parser';
import { hasEntityType } from './has-entity-type.js';

/** Lines belonging to a single storey, ready to feed into the section overlay. */
export interface AnnotationsForStorey {
  storeyId: number;
  /** Authored `IfcBuildingStorey.Elevation`. `null` means the storey carried
   *  no elevation in the parsed metadata — distinguishing that from a real
   *  ground-floor at 0.0 matters because `resolveBucketY` only wants to swap
   *  in the fallback in the missing case, not for legitimate ground floors. */
  storeyElevation: number | null;
  lines: DrawingLine2D[];
  texts: AnnotationText2D[];
  fills: AnnotationFill2D[];
}

/**
 * A single text label in renderer 2D space (XZ on the section plane).
 *
 * `dirX / dirY` encodes the baseline direction (already mirrored to match the
 * Y-negated 2D coord system that lines and circles use). `height` is in world
 * units. `alignment` is the raw IFC `BoxAlignment` string ("bottom-left",
 * "center", …) — the renderer interprets it.
 */
export interface AnnotationText2D {
  x: number;
  y: number;
  dirX: number;
  dirY: number;
  height: number;
  content: string;
  alignment: string;
  /**
   * For multi-line text literals (e.g. CJK descriptions with `\X\0A`
   * newlines), one IfcTextLiteralWithExtent expands into one AnnotationText2D
   * per line. `lineYOffset` is added to the storey-elevation world-Y at 3D
   * conversion so successive lines stack downward (negative Y) below the
   * shared anchor. Optional — single-line literals leave it undefined.
   */
  lineYOffset?: number;
  /**
   * When true, the renderer rebuilds the glyph quad in screen-aligned
   * (cameraRight, cameraUp) basis so the text always faces the camera.
   * Set for every annotation literal (grid bubbles, dimension callouts,
   * leader labels) so they stay legible in any view — flat-in-plane text
   * collapses to a sliver at oblique angles (issue #812). Defaults to false.
   */
  billboard?: boolean;
  /** sRGB straight-alpha tint (0..1). Defaults to renderer near-black. */
  color?: [number, number, number, number];
  /** Per-instance target cap height in screen pixels. 0/undef = renderer default. */
  targetPx?: number;
}

/**
 * A single filled region in renderer 2D space. Outer ring + holes flattened
 * into one `points` array; `holesOffsets` marks where each hole starts (in
 * vertex indices, not floats). Empty `holesOffsets` = simple polygon.
 *
 * `hatching` is present when the IFC style chain resolved to an
 * IfcFillAreaStyleHatching. When absent the fill is solid (color only).
 */
export interface AnnotationFill2D {
  points: Float32Array;
  holesOffsets: Uint32Array;
  color: [number, number, number, number];
  hatching?: {
    spacing: number;
    angle: number;
    angleSecondary: number | null;
    lineWidth: number;
  };
}

/** Cached parse result keyed by source identity.
 *
 * IfcAnnotation and IfcGridAxis primitives are stored in PARALLEL bucket
 * collections (issue #862). They share the same parse pass and the same
 * storey-resolution logic, but the renderer treats them differently:
 *
 *   - Annotation buckets always lift every storey (memory
 *     `feedback_3d_annotation_overlay_no_section_filter.md`: the user
 *     expects every storey's dimensions to be visible in 3D).
 *   - Grid buckets get optional section-plane filtering and an
 *     independent visibility toggle, so dense-grid models can hide
 *     grids per storey without losing dimensions.
 */
interface ParseResult {
  // IfcAnnotation buckets
  byStorey: Map<number, AnnotationsForStorey>;
  loose: DrawingLine2D[];
  looseTexts: AnnotationText2D[];
  looseFills: AnnotationFill2D[];

  // IfcGridAxis buckets (issue #862)
  gridByStorey: Map<number, AnnotationsForStorey>;
  gridLoose: DrawingLine2D[];
  gridLooseTexts: AnnotationText2D[];
  gridLooseFills: AnnotationFill2D[];
}

const CIRCLE_SEGMENTS_FULL = 32;
const CIRCLE_SEGMENTS_ARC = 16;

/**
 * Convert a polyline (Float32Array of [x,y,x,y,…]) into start/end segments.
 * Exported for unit testing.
 */
export function polylineToSegments(
  points: Float32Array,
  pointCount: number,
  isClosed: boolean,
  out: DrawingLine2D[],
): void {
  for (let j = 0; j < pointCount - 1; j++) {
    out.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: 'annotation',
    });
  }
  if (isClosed && pointCount > 2) {
    out.push({
      line: {
        start: { x: points[(pointCount - 1) * 2], y: points[(pointCount - 1) * 2 + 1] },
        end:   { x: points[0], y: points[1] },
      },
      category: 'annotation',
    });
  }
}

/**
 * Tessellate a circle/arc into chord segments.
 * Exported for unit testing.
 */
export function circleToSegments(
  centerX: number,
  centerY: number,
  radius: number,
  startAngle: number,
  endAngle: number,
  isFullCircle: boolean,
  out: DrawingLine2D[],
): void {
  const numSegments = isFullCircle ? CIRCLE_SEGMENTS_FULL : CIRCLE_SEGMENTS_ARC;
  for (let j = 0; j < numSegments; j++) {
    const t1 = j / numSegments;
    const t2 = (j + 1) / numSegments;
    const a1 = startAngle + t1 * (endAngle - startAngle);
    const a2 = startAngle + t2 * (endAngle - startAngle);
    out.push({
      line: {
        start: { x: centerX + radius * Math.cos(a1), y: centerY + radius * Math.sin(a1) },
        end:   { x: centerX + radius * Math.cos(a2), y: centerY + radius * Math.sin(a2) },
      },
      category: 'annotation',
    });
  }
}

/** Make a stable cache key for one parsed source.
 *
 * Uses byteLength + a sample of the actual bytes (head, middle, tail) so two
 * different IFC sources can't alias even when they happen to share an exact
 * size — a real risk in federated views with multiple loaded models, and the
 * symptom is that the second model's annotations get hidden because the parse
 * effect skips it as "already cached". Sampling 96 bytes is cheap, doesn't
 * read the whole file, and is collision-resistant in practice. The buffer
 * identity is also folded in so the same content loaded twice from two
 * different ArrayBuffers (rare but possible) keeps distinct entries.
 */
function sourceKey(store: IfcDataStore | null | undefined): string | null {
  const source = store?.source;
  if (!source || source.byteLength === 0) return null;
  const len = source.byteLength;
  const sampleLen = Math.min(32, len);
  const head = source.subarray(0, sampleLen);
  const tail = source.subarray(len - sampleLen, len);
  const midOffset = Math.max(0, Math.floor(len / 2) - Math.floor(sampleLen / 2));
  const mid = source.subarray(midOffset, Math.min(midOffset + sampleLen, len));
  // Fold each window into a 32-bit FNV-1a; cheap and collision-resistant for
  // 96 bytes of structurally distinct IFC headers/body/footer.
  const hashOne = (bytes: Uint8Array): string => {
    let h = 0x811c9dc5;
    for (let i = 0; i < bytes.length; i++) {
      h ^= bytes[i];
      h = Math.imul(h, 0x01000193);
    }
    return (h >>> 0).toString(16);
  };
  return `b${len}-${hashOne(head)}-${hashOne(mid)}-${hashOne(tail)}`;
}

/** Set `localStorage.IFC_ANNOTATIONS_DEBUG = '1'` in the browser to log
 *  per-store parse counts + lift vertex counts to the console. Off by
 *  default; useful when triaging "no annotations visible" reports. */
const debugEnabled = (): boolean => {
  if (typeof window === 'undefined') return false;
  try { return window.localStorage?.getItem('IFC_ANNOTATIONS_DEBUG') === '1'; }
  catch { return false; }
};

async function parseAnnotations(
  store: IfcDataStore,
): Promise<ParseResult> {
  const result: ParseResult = {
    byStorey: new Map(),
    loose: [],
    looseTexts: [],
    looseFills: [],
    gridByStorey: new Map(),
    gridLoose: [],
    gridLooseTexts: [],
    gridLooseFills: [],
  };
  const source = store.source;
  if (!source || source.byteLength === 0) {
    if (debugEnabled()) console.log('[annotations] skip: missing/empty source');
    return result;
  }
  // Skip the full-source WASM scan only when the model has neither IfcAnnotation
  // nor IfcGridAxis — this parse path ALSO feeds the grid buckets (gridByStorey /
  // gridLoose*), so gating on IfcAnnotation alone would drop grid-only models.
  // The scan copies the entire IFC source into the WASM heap on the main thread,
  // so skipping it when there is nothing to find still matters.
  if (!hasEntityType(store, 'IfcAnnotation', 'IfcGridAxis')) {
    if (debugEnabled()) console.log('[annotations] skip: no IfcAnnotation/IfcGridAxis entities');
    return result;
  }

  const hierarchy = store.spatialHierarchy;
  const elementToStorey = hierarchy?.elementToStorey;
  const storeyElevations = hierarchy?.storeyElevations;

  const processor = new GeometryProcessor();
  try {
    await processor.init();
    // SymbolicRepresentationCollection and each getPolyline/getCircle/getText/
    // getFill 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 collection = processor.parseSymbolicRepresentations(source);
    if (debugEnabled()) {
      console.log(
        `[annotations] parsed ${source.byteLength} bytes →`,
        collection
          ? `${collection.polylineCount} polylines, ${collection.circleCount} circles, ${collection.textCount} texts, ${collection.fillCount} fills`
          : 'null',
      );
    }
    if (!collection) return result;
    try {
    if (collection.isEmpty) return result;

    // Resolve a bucket by elevation rather than by storey id.
    //
    // The legacy path used `elementToStorey` exclusively — which breaks for
    // 3DEXPERIENCE / IfcPlusPlus exports whose `IfcRelAggregates` leaves
    // storeys orphaned so `SpatialHierarchyBuilder` reports "No storeys
    // found". Those files still encode the elevation on each item's
    // geometry (the IfcCartesianPoint.Z), which the WASM extractor now
    // surfaces as `primitive.worldY`. Bucketing by Y means every annotation
    // lands at the right floor regardless of whether the spatial hierarchy
    // could be built.
    //
    // Priority: explicit primitive worldY → fall back to storey-table
    // elevation → null (loose bucket, renders at fallbackY).
    //
    // Bucket keys are millimetre-rounded Y so two storeys 1mm apart still
    // collapse to one bucket — that's the precision Revit etc. round to.
    const ensureBucket = (
      expressId: number,
      primitiveWorldY: number,
      ifcType: string,
    ): AnnotationsForStorey | null => {
      let effectiveY: number | null = null;
      if (Number.isFinite(primitiveWorldY) && primitiveWorldY !== 0) {
        effectiveY = primitiveWorldY;
      } else {
        const storeyId = elementToStorey?.get(expressId);
        if (storeyId !== undefined) {
          const elev = storeyElevations?.get(storeyId);
          if (typeof elev === 'number' && Number.isFinite(elev)) effectiveY = elev;
        }
      }
      if (effectiveY === null) return null;
      const key = Math.round(effectiveY * 1000);
      // Issue #862: IfcGridAxis primitives land in a parallel bucket
      // collection so the renderer can section-clip + visibility-toggle
      // them independently of IfcAnnotation (text/dimension symbols).
      const storeyMap = ifcType === 'IfcGridAxis' ? result.gridByStorey : result.byStorey;
      let bucket = storeyMap.get(key);
      if (!bucket) {
        bucket = {
          storeyId: key,
          storeyElevation: effectiveY,
          lines: [],
          texts: [],
          fills: [],
        };
        storeyMap.set(key, bucket);
      }
      return bucket;
    };

    for (let i = 0; i < collection.polylineCount; i++) {
      const poly = collection.getPolyline(i);
      if (!poly) continue;
      try {
        if (poly.ifcType !== 'IfcAnnotation' && poly.ifcType !== 'IfcGridAxis') continue;
        const bucket = ensureBucket(poly.expressId, poly.worldY, poly.ifcType);
        const looseTarget = poly.ifcType === 'IfcGridAxis' ? result.gridLoose : result.loose;
        const out = bucket ? bucket.lines : looseTarget;
        // poly.points is consumed synchronously here (not stored), so no copy needed.
        polylineToSegments(poly.points, poly.pointCount, poly.isClosed, out);
      } finally {
        poly.free();
      }
    }

    for (let i = 0; i < collection.circleCount; i++) {
      const circle = collection.getCircle(i);
      if (!circle) continue;
      try {
        if (circle.ifcType !== 'IfcAnnotation' && circle.ifcType !== 'IfcGridAxis') continue;
        const bucket = ensureBucket(circle.expressId, circle.worldY, circle.ifcType);
        const looseTarget = circle.ifcType === 'IfcGridAxis' ? result.gridLoose : result.loose;
        const out = bucket ? bucket.lines : looseTarget;
        circleToSegments(
          circle.centerX,
          circle.centerY,
          circle.radius,
          circle.startAngle,
          circle.endAngle,
          circle.isFullCircle,
          out,
        );
      } finally {
        circle.free();
      }
    }

    for (let i = 0; i < collection.textCount; i++) {
      const text = collection.getText(i);
      if (!text) continue;
      try {
      if (text.ifcType !== 'IfcAnnotation' && text.ifcType !== 'IfcGridAxis') continue;
      // Skip empty literals so the renderer doesn't waste an instance slot.
      // Decode STEP escapes — `\X2\NNNN\X0\` (UTF-16 hex code units) and
      // `\X\NN` (Latin-1 hex byte). The Rust parser intentionally passes
      // the literal through verbatim; this is where the JS encoding
      // package gets applied. Without it, non-ASCII annotation labels
      // (e.g. CJK content) render as raw escape sequences in the atlas.
      const decoded = decodeIfcString(text.content);
      if (decoded.length === 0) continue;

      // Multi-line split: IfcTextLiteralWithExtent.SizeInY is the LAYOUT BOX
      // height, not the glyph cap height. The Rust extractor multiplies
      // SizeInY × 0.7 to recover a single-line cap; for multi-line literals
      // we further divide by line count and stack lines downward in world-Y.
      // Source: IFC4 spec — IfcPlanarExtent describes the bounding box of
      // the typeset string; one literal per line is the conventional
      // rendering model (matches BIMvision / Solibri / Revit).
      const lines = decoded.split(/\r?\n/).filter((l) => l.length > 0);
      if (lines.length === 0) continue;
      const perLineHeight = lines.length > 1 ? text.height / lines.length : text.height;
      // Industry-standard line-spacing (CSS line-height ≈ 1.2). Picks up
      // a little air between rows so descenders don't kiss the next cap.
      const lineSpacing = perLineHeight * 1.2;
      const bucket = ensureBucket(text.expressId, text.worldY, text.ifcType);
      const looseTextTarget = text.ifcType === 'IfcGridAxis' ? result.gridLooseTexts : result.looseTexts;
      // All annotation text — grid bubbles, dimension callouts, leader labels —
      // billboards to the camera so it stays legible in any view orientation
      // (top-down, eye-level, oblique). The shader rebuilds the quad in the
      // screen-aligned basis at render time. Authored orientation is intentionally
      // dropped: at oblique viewing angles, flat-in-plane text becomes a smeared
      // sliver of pixels (issue #812). Anchor + alignment are preserved, so each
      // label still sits at its authored insertion point.
      // Read per-instance style metadata. WASM emits these for grid
      // bubble parts (● fill / ○ outline / tag) and reserves them for
      // future IfcTextStyle resolution on regular annotation text.
      const colorA = text.colorA;
      const hasColor = colorA > 0;
      const textColor: [number, number, number, number] | undefined = hasColor
        ? [text.colorR, text.colorG, text.colorB, colorA]
        : undefined;
      const targetPx = text.targetPx > 0 ? text.targetPx : undefined;
      for (let li = 0; li < lines.length; li++) {
        const t2d: AnnotationText2D = {
          x: text.x,
          y: text.y,
          dirX: text.dirX,
          dirY: text.dirY,
          height: perLineHeight,
          content: lines[li],
          alignment: text.alignment,
          lineYOffset: -li * lineSpacing,
          billboard: true,
          color: textColor,
          targetPx,
        };
        (bucket ? bucket.texts : looseTextTarget).push(t2d);
      }
      } finally {
        text.free();
      }
    }

    for (let i = 0; i < collection.fillCount; i++) {
      const fill = collection.getFill(i);
      if (!fill) continue;
      try {
        if (fill.ifcType !== 'IfcAnnotation' && fill.ifcType !== 'IfcGridAxis') continue;
        // fill.points / fill.holesOffsets are getter results that may be views
        // into WASM memory; they're STORED into f2d (outlive this iteration),
        // so copy them before the handle is freed below. Element types match
        // the AnnotationFill2D fields (Float32Array / Uint32Array).
        const points = new Float32Array(fill.points);
        if (points.length < 6) continue; // <3 vertices = no polygon
        const holesOffsets = new Uint32Array(fill.holesOffsets);
        const f2d: AnnotationFill2D = {
          points,
          holesOffsets,
          color: [fill.fillR, fill.fillG, fill.fillB, fill.fillA],
          hatching: fill.hasHatching
            ? {
                spacing: fill.hatchSpacing,
                angle: fill.hatchAngle,
                angleSecondary: Number.isNaN(fill.hatchAngleSecondary) ? null : fill.hatchAngleSecondary,
                lineWidth: fill.hatchLineWidth,
              }
            : undefined,
        };
        const bucket = ensureBucket(fill.expressId, fill.worldY, fill.ifcType);
        const looseFillTarget = fill.ifcType === 'IfcGridAxis' ? result.gridLooseFills : result.looseFills;
        (bucket ? bucket.fills : looseFillTarget).push(f2d);
      } finally {
        fill.free();
      }
    }
    } finally {
      collection.free();
    }
  } finally {
    processor.dispose();
  }

  return result;
}

/**
 * Lift 2D annotation lines (renderer XZ space) to a flat Float32Array of
 * 3D line-list vertices `[x1, y, z1, x2, y, z2, …]`. The Y coordinate is
 * the annotation's storey elevation in world space, so the resulting
 * lines render at the right floor when drawn through the renderer's
 * world-space line pipeline.
 *
 * Exported for unit testing.
 */
export function liftTo3DLineList(
  lines: DrawingLine2D[],
  y: number,
  out: number[],
): void {
  for (const line of lines) {
    out.push(line.line.start.x, y, line.line.start.y);
    out.push(line.line.end.x,   y, line.line.end.y);
  }
}

/**
 * Returns IFC annotation segments as a single Float32Array of pre-lifted 3D
 * line-list vertices in world space, ready to feed
 * `renderer.uploadAnnotationLines3D`.
 *
 * Each annotation is lifted to its containing storey's elevation. Annotations
 * with no resolvable storey fall back to `fallbackY` (typically the mid-Y of
 * the scene bounds) so the overlay stays visible even when the IFC file's
 * spatial hierarchy doesn't link annotations to a storey — common when the
 * authoring tool encodes the storey Z directly on the placement point
 * instead of on `IfcBuildingStorey.Elevation`.
 *
 * When `enabled` is false (toggle off, no models, etc.) the hook does no
 * parse work and returns a stable empty Float32Array. Parsing is lazy —
 * the WASM `parseSymbolicRepresentations` call only runs after the toggle
 * is turned on, and the result is cached per model source.
 */
const EMPTY_F32 = new Float32Array(0);

// ─── Shared parse cache ─────────────────────────────────────────────────────
// Parsing the whole file's symbolic representations is not cheap (full WASM
// walk over every product's representations). Cache results module-globally
// so the line / text / fill hooks share one parse per model source instead
// of triggering it once per hook.
const PARSE_CACHE = new Map<string, ParseResult>();
const PARSE_INFLIGHT = new Map<string, Promise<void>>();

/** Subscribers that want to re-render when a new parse result lands. */
type CacheListener = () => void;
const CACHE_LISTENERS = new Set<CacheListener>();
function notifyCacheChange(): void {
  for (const fn of CACHE_LISTENERS) fn();
}

function ensureParseFor(stores: IfcDataStore[]): void {
  for (const store of stores) {
    const key = sourceKey(store);
    if (!key) continue;
    if (PARSE_CACHE.has(key)) continue;
    if (PARSE_INFLIGHT.has(key)) continue;

    const promise = (async () => {
      try {
        const result = await parseAnnotations(store);
        PARSE_CACHE.set(key, result);
        notifyCacheChange();
      } catch (error) {
        // eslint-disable-next-line no-console
        console.warn('[useSymbolicAnnotations] parse failed:', error);
      } finally {
        PARSE_INFLIGHT.delete(key);
      }
    })();
    PARSE_INFLIGHT.set(key, promise);
  }
}

/** Read the active store set from the viewer store. Federation-aware. */
function useActiveStores(): IfcDataStore[] {
  const { models, ifcDataStore } = useViewerStore(
    useShallow((s) => ({ models: s.models, ifcDataStore: s.ifcDataStore })),
  );
  return useMemo(() => {
    const out: IfcDataStore[] = [];
    if (models.size > 0) {
      for (const [, m] of models) if (m.ifcDataStore) out.push(m.ifcDataStore);
    } else if (ifcDataStore) {
      out.push(ifcDataStore);
    }
    return out;
  }, [models, ifcDataStore]);
}

/** Trigger parse for the active stores when `enabled`, tick on completion. */
function useAnnotationParseTrigger(enabled: boolean, stores: IfcDataStore[]): number {
  const [version, setVersion] = useState(0);

  useEffect(() => {
    if (!enabled) return undefined;
    ensureParseFor(stores);
    const listener: CacheListener = () => setVersion((v) => v + 1);
    CACHE_LISTENERS.add(listener);
    return () => {
      CACHE_LISTENERS.delete(listener);
    };
  }, [enabled, stores]);

  return version;
}

/** Resolve the world-space Y for a storey bucket.
 *
 * `null` elevation means the storey carried no value in the parsed metadata
 * (rare but happens in older authoring tools that leave
 * `IfcBuildingStorey.Elevation` blank and bake the Z into the placements);
 * fall back to the caller's `fallbackY` (typically the model's mid-Y). A
 * real ground floor at 0.0 keeps its authored 0 instead of being remapped.
 */
function resolveBucketY(elevation: number | null, fallbackY: number): number {
  return elevation === null ? fallbackY : elevation;
}

/** Section-clip parameters for grid lines (issue #862). Grids ARE clipped
 *  by the active section plane; IfcAnnotation overlays are NOT (per the
 *  feedback_3d_annotation_overlay_no_section_filter memory). When
 *  `enabled === false` no clipping happens — grids lift to every storey
 *  same as annotations.
 */
export interface SectionClipForGrid {
  enabled: boolean;
  /** World coord on the cut axis (e.g. world-Y for axis='down'). */
  posWorld: number;
  /** Half-thickness of the visible band around the cut, world units. */
  viewDepth: number;
  /** Cut axis. Only `'down'` performs vertical clipping; other axes pass through unfiltered (grid lines are vertical and don't project meaningfully onto elevation cuts). */
  axis: 'down' | 'front' | 'side';
}

export function useSymbolicAnnotations(params: {
  /** Enable IfcAnnotation lift (the existing default behaviour). */
  enabled: boolean;
  /**
   * Enable IfcGrid lift. Independent of `enabled` so a user can hide
   * annotations while keeping grids, or vice versa (issue #862).
   * Defaults to `enabled` so existing call sites that don't set it
   * keep the legacy combined behaviour.
   */
  gridEnabled?: boolean;
  /** Section clipping for grids only — see [`SectionClipForGrid`]. */
  gridSectionClip?: SectionClipForGrid;
  /** World Y to use for annotations with no resolvable storey. Defaults to 0. */
  fallbackY?: number;
}): Float32Array {
  const { enabled, gridEnabled, gridSectionClip, fallbackY = 0 } = params;
  const effectiveGridEnabled = gridEnabled ?? enabled;
  const stores = useActiveStores();
  // Trigger parse if EITHER subset is enabled — the parse pass is shared.
  const version = useAnnotationParseTrigger(enabled || effectiveGridEnabled, stores);
  const clipEnabled = !!gridSectionClip && gridSectionClip.enabled && gridSectionClip.axis === 'down';
  const clipPos = clipEnabled ? gridSectionClip!.posWorld : 0;
  const clipDepth = clipEnabled ? gridSectionClip!.viewDepth : 0;

  return useMemo(() => {
    if (!enabled && !effectiveGridEnabled) return EMPTY_F32;
    void version; // depend on parse-completion ticks

    const verts: number[] = [];
    let storeIdx = 0;
    for (const store of stores) {
      const key = sourceKey(store);
      if (!key) { storeIdx++; continue; }
      const cached = PARSE_CACHE.get(key);
      if (!cached) {
        if (debugEnabled()) console.log(`[annotations] store ${storeIdx}: parse not yet ready for key=${key}`);
        storeIdx++;
        continue;
      }
      if (debugEnabled()) {
        console.log(
          `[annotations] store ${storeIdx}: annotation buckets=${cached.byStorey.size}+${cached.loose.length}loose, grid buckets=${cached.gridByStorey.size}+${cached.gridLoose.length}loose (annot=${enabled}, grid=${effectiveGridEnabled}, clip=${clipEnabled})`,
        );
      }

      if (enabled) {
        for (const bucket of cached.byStorey.values()) {
          liftTo3DLineList(bucket.lines, resolveBucketY(bucket.storeyElevation, fallbackY), verts);
        }
        liftTo3DLineList(cached.loose, fallbackY, verts);
      }

      if (effectiveGridEnabled) {
        // Issue #862: section-clip grid buckets only — IfcAnnotation
        // intentionally bypasses this per the feedback memory ("the
        // user expects every storey's dimensions/grid bubbles to lift
        // into the viewport when [the annotation toggle is] on, even
        // while a section cut is active").
        if (clipEnabled) {
          const lo = clipPos - clipDepth;
          const hi = clipPos + clipDepth;
          for (const bucket of cached.gridByStorey.values()) {
            const y = resolveBucketY(bucket.storeyElevation, fallbackY);
            if (y < lo || y > hi) continue;
            liftTo3DLineList(bucket.lines, y, verts);
          }
          if (fallbackY >= lo && fallbackY <= hi) {
            liftTo3DLineList(cached.gridLoose, fallbackY, verts);
          }
        } else {
          for (const bucket of cached.gridByStorey.values()) {
            liftTo3DLineList(bucket.lines, resolveBucketY(bucket.storeyElevation, fallbackY), verts);
          }
          liftTo3DLineList(cached.gridLoose, fallbackY, verts);
        }
      }
      storeIdx++;
    }

    if (debugEnabled()) console.log(`[annotations] total 3D line vertices: ${verts.length / 3} from ${stores.length} stores`);
    if (verts.length === 0) return EMPTY_F32;
    return new Float32Array(verts);
  }, [enabled, effectiveGridEnabled, clipEnabled, clipPos, clipDepth, stores, version, fallbackY]);
}

/**
 * A text annotation lifted into 3D world space.
 *
 * `worldPos[1]` is the storey Y the annotation belongs to (or `fallbackY` for
 * orphans). `dirX / dirZ` is the baseline direction in 3D (already mirrored
 * from the IFC frame to match the section overlay's coordinate handedness).
 * `height` is in world units.
 */
export interface AnnotationText3D {
  worldPos: [number, number, number];
  dirX: number;
  dirZ: number;
  height: number;
  content: string;
  alignment: string;
  /** True when the glyph quad should rebuild in camera-aligned basis (grid tags). */
  billboard?: boolean;
  /** sRGB straight-alpha tint, 0..1. */
  color?: [number, number, number, number];
  /** Per-instance target cap height in screen pixels. */
  targetPx?: number;
}

/**
 * A filled region lifted into 3D world space. `points` is a flat
 * `[x, z, x, z, …]` ring buffer (Y is constant = `worldY`). Holes are tracked
 * via `holesOffsets` (vertex indices into `points`); the renderer triangulates.
 */
export interface AnnotationFill3D {
  points: Float32Array;
  holesOffsets: Uint32Array;
  worldY: number;
  color: [number, number, number, number];
  hatching?: AnnotationFill2D['hatching'];
}

/** Cheap stable empty arrays for the no-data path. */
const EMPTY_TEXTS: readonly AnnotationText3D[] = Object.freeze([]);
const EMPTY_FILLS: readonly AnnotationFill3D[] = Object.freeze([]);

/**
 * Hook for the 2D Section panel: filters the shared parse cache to
 * annotations whose world position falls inside the section's view-range
 * on the cut axis, returning data in the Drawing2D coordinate frame.
 *
 * For `axis='down'` (floor plan), the parser's 2D coords already match
 * the drawing-2d coord frame directly (x = world x, y = world z, with
 * worldY = the cut axis). For elevation views (`axis='front'`,
 * `axis='side'`), this hook returns empty: most authored IFC annotations
 * are floor-plan symbols (dimensions, leaders, room labels) and don't
 * project meaningfully onto a vertical drawing without a separate
 * reorientation pass. Wiring those up cleanly is a follow-up.
 *
 * The section position is in world units (already converted from the
 * 0-100% slider via `axisMin + (position / 100) * (axisMax - axisMin)`
 * by the caller — Section2DPanel computes the same value to feed the
 * drawing generator).
 */
export interface DrawingAnnotationData {
  lines: DrawingLine2D[];
  texts: AnnotationText2D[];
  fills: AnnotationFill2D[];
}

const EMPTY_DRAWING_ANNOTATIONS: DrawingAnnotationData = {
  lines: [],
  texts: [],
  fills: [],
};

export function useSymbolicAnnotationsForDrawing(params: {
  enabled: boolean;
  axis: 'down' | 'front' | 'side';
  /** Section plane world-coord position along the cut axis. */
  sectionPosWorld: number;
  /** View depth in world units (typically half the model extent on the cut axis). */
  viewDepth: number;
  flipped: boolean;
  /** Fallback world Y for annotations with no resolvable storey. */
  fallbackY?: number;
}): DrawingAnnotationData {
  const { enabled, axis, sectionPosWorld, viewDepth, flipped, fallbackY = 0 } = params;
  const stores = useActiveStores();
  const version = useAnnotationParseTrigger(enabled, stores);

  return useMemo(() => {
    if (!enabled) return EMPTY_DRAWING_ANNOTATIONS;
    // Only floor plans (axis='down') are supported on this pass. Annotations
    // for elevations/sections need a coord-reorientation pass that is not
    // worth building until there's a real authored elevation symbol to test
    // against. Returning empty quietly keeps the toggle a no-op there.
    if (axis !== 'down') return EMPTY_DRAWING_ANNOTATIONS;
    void version;

    // Section view range in world Y.
    //
    // For a floor-plan cut at axis='down' the camera looks DOWN through the
    // cut. "In front of the camera" is therefore the side BELOW the cut —
    // where the floor and authored dimensions sit (IFC convention places
    // dimension annotations at the storey's floor elevation, not at the
    // cut height). The user's complaint: with the slab on the +normal
    // side, you had to scrub the section DOWN into the floor before
    // anything showed, and then the dimensions appeared one storey BELOW
    // the cut. Mirror that — keep the slab on the −normal side for the
    // unflipped down section, and flip it for the reflected-ceiling case.
    //
    // Note this DIVERGES from `profile-projector.isInProjectionRange`,
    // which projects above the cut by default. Annotations live with the
    // storey floor, the projection lives with the upper-storey volume —
    // they're naturally on opposite sides of the cut plane.
    //
    // Tolerance lets annotations authored exactly on the cut plane (e.g.
    // a storey at Z=0 with a section right at the storey datum) survive.
    const TOL = 1e-3;
    const rangeMin = (flipped ? sectionPosWorld : sectionPosWorld - viewDepth) - TOL;
    const rangeMax = (flipped ? sectionPosWorld + viewDepth : sectionPosWorld) + TOL;

    const lines: DrawingLine2D[] = [];
    const texts: AnnotationText2D[] = [];
    const fills: AnnotationFill2D[] = [];

    // The drawing-2d cutter negates the 2D U axis on flipped cardinal cuts
    // (see `projectTo2D` in @ifc-lite/drawing-2d/math.ts and `flipU` in the
    // GPU cutter). Annotation primitives come out of WASM in the cutter's
    // UNFLIPPED basis, so on a flipped section they'd sit beside the model
    // (mirrored across X=0) instead of on top of it — exactly the
    // "dimensions floating to the right of the floor plan" symptom. Mirror
    // X for lines/texts/fills here so they line up with the section cut
    // output drawn underneath. Y stays put (the cutter only flips U).
    const pushLine = flipped
      ? (ln: DrawingLine2D) => lines.push({
          line: {
            start: { x: -ln.line.start.x, y: ln.line.start.y },
            end:   { x: -ln.line.end.x,   y: ln.line.end.y   },
          },
          category: ln.category,
        })
      : (ln: DrawingLine2D) => lines.push(ln);
    const pushText = flipped
      ? (t: AnnotationText2D) => texts.push({ ...t, x: -t.x, dirX: -t.dirX })
      : (t: AnnotationText2D) => texts.push(t);
    const pushFill = flipped
      ? (f: AnnotationFill2D) => {
          const src = f.points;
          const dst = new Float32Array(src.length);
          for (let i = 0; i < src.length; i += 2) {
            dst[i]     = -src[i];
            dst[i + 1] =  src[i + 1];
          }
          fills.push({ ...f, points: dst });
        }
      : (f: AnnotationFill2D) => fills.push(f);

    for (const store of stores) {
      const key = sourceKey(store);
      if (!key) continue;
      const cached = PARSE_CACHE.get(key);
      if (!cached) continue;

      // Drawing-2D pulls BOTH annotation and grid buckets (issue #862
      // split them at parse time so the 3D viewport can clip them
      // separately — the 2D Section panel still wants the combined
      // overlay).
      const collectBucket = (bucket: AnnotationsForStorey) => {
        const bucketY = resolveBucketY(bucket.storeyElevation, fallbackY);
        if (bucketY < rangeMin || bucketY > rangeMax) return;
        for (const ln of bucket.lines) pushLine(ln);
        for (const t of bucket.texts) pushText(t);
        for (const f of bucket.fills) pushFill(f);
      };
      for (const bucket of cached.byStorey.values()) collectBucket(bucket);
      for (const bucket of cached.gridByStorey.values()) collectBucket(bucket);

      // Loose annotations have no resolvable storey — include them if the
      // fallback Y lands in the view range. That keeps malformed exports
      // (e.g. 3DEXPERIENCE files with orphaned storeys) usable when the
      // user is looking at the storey the fallback resolves to.
      if (fallbackY >= rangeMin && fallbackY <= rangeMax) {
        for (const ln of cached.loose) pushLine(ln);
        for (const t of cached.looseTexts) pushText(t);
        for (const f of cached.looseFills) pushFill(f);
        for (const ln of cached.gridLoose) pushLine(ln);
        for (const t of cached.gridLooseTexts) pushText(t);
        for (const f of cached.gridLooseFills) pushFill(f);
      }
    }

    if (lines.length === 0 && texts.length === 0 && fills.length === 0) {
      return EMPTY_DRAWING_ANNOTATIONS;
    }
    return { lines, texts, fills };
  }, [enabled, axis, sectionPosWorld, viewDepth, flipped, fallbackY, stores, version]);
}

/**
 * Hook for the WebGPU text + fill pipelines. Returns 3D-lifted texts and
 * fills for every active model. Shares the parse cache with
 * `useSymbolicAnnotations` so toggling on text+fill rendering after the
 * line overlay is already up costs no extra parse work.
 */
export function useSymbolicAnnotationsRichData(params: {
  enabled: boolean;
  /** Lift grid-bubble texts + fills. Independent of `enabled` (issue #862).
   *  Defaults to `enabled` for legacy callers. */
  gridEnabled?: boolean;
  /** Section clipping for grid texts/fills only — same semantics as
   *  [`useSymbolicAnnotations`]. */
  gridSectionClip?: SectionClipForGrid;
  fallbackY?: number;
}): { texts: readonly AnnotationText3D[]; fills: readonly AnnotationFill3D[] } {
  const { enabled, gridEnabled, gridSectionClip, fallbackY = 0 } = params;
  const effectiveGridEnabled = gridEnabled ?? enabled;
  const stores = useActiveStores();
  const version = useAnnotationParseTrigger(enabled || effectiveGridEnabled, stores);
  const clipEnabled = !!gridSectionClip && gridSectionClip.enabled && gridSectionClip.axis === 'down';
  const clipPos = clipEnabled ? gridSectionClip!.posWorld : 0;
  const clipDepth = clipEnabled ? gridSectionClip!.viewDepth : 0;

  return useMemo(() => {
    if (!enabled && !effectiveGridEnabled) return { texts: EMPTY_TEXTS, fills: EMPTY_FILLS };
    void version;

    const texts: AnnotationText3D[] = [];
    const fills: AnnotationFill3D[] = [];

    for (const store of stores) {
      const key = sourceKey(store);
      if (!key) continue;
      const cached = PARSE_CACHE.get(key);
      if (!cached) continue;

      const pushText = (t: AnnotationText2D, y: number) => {
        // lineYOffset stacks multi-line text downward in world-Y. Glyph
        // upAxis is world-Y (see SymbolicTextPipeline), so subtracting
        // here puts line 1 below line 0 on screen for any side/oblique
        // 3D view of the floor plan.
        texts.push({
          worldPos: [t.x, y + (t.lineYOffset ?? 0), t.y],
          dirX: t.dirX,
          dirZ: t.dirY,
          height: t.height,
          content: t.content,
          alignment: t.alignment,
          billboard: t.billboard,
          color: t.color,
          targetPx: t.targetPx,
        });
      };
      const pushFill = (f: AnnotationFill2D, y: number) => {
        fills.push({
          points: f.points,
          holesOffsets: f.holesOffsets,
          worldY: y,
          color: f.color,
          hatching: f.hatching,
        });
      };

      if (enabled) {
        for (const bucket of cached.byStorey.values()) {
          const y = resolveBucketY(bucket.storeyElevation, fallbackY);
          for (const t of bucket.texts) pushText(t, y);
          for (const f of bucket.fills) pushFill(f, y);
        }
        for (const t of cached.looseTexts) pushText(t, fallbackY);
        for (const f of cached.looseFills) pushFill(f, fallbackY);
      }

      if (effectiveGridEnabled) {
        if (clipEnabled) {
          const lo = clipPos - clipDepth;
          const hi = clipPos + clipDepth;
          for (const bucket of cached.gridByStorey.values()) {
            const y = resolveBucketY(bucket.storeyElevation, fallbackY);
            if (y < lo || y > hi) continue;
            for (const t of bucket.texts) pushText(t, y);
            for (const f of bucket.fills) pushFill(f, y);
          }
          if (fallbackY >= lo && fallbackY <= hi) {
            for (const t of cached.gridLooseTexts) pushText(t, fallbackY);
            for (const f of cached.gridLooseFills) pushFill(f, fallbackY);
          }
        } else {
          for (const bucket of cached.gridByStorey.values()) {
            const y = resolveBucketY(bucket.storeyElevation, fallbackY);
            for (const t of bucket.texts) pushText(t, y);
            for (const f of bucket.fills) pushFill(f, y);
          }
          for (const t of cached.gridLooseTexts) pushText(t, fallbackY);
          for (const f of cached.gridLooseFills) pushFill(f, fallbackY);
        }
      }
    }

    return {
      texts: texts.length ? texts : EMPTY_TEXTS,
      fills: fills.length ? fills : EMPTY_FILLS,
    };
  }, [enabled, effectiveGridEnabled, clipEnabled, clipPos, clipDepth, stores, version, fallbackY]);
}
