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

/**
 * Section plane state slice
 */

import type { StateCreator } from 'zustand';
import type { SectionPlane, SectionPlaneAxis, SectionCapStyle, SectionCapHatchId, CustomSectionPlane } from '../types.js';
import { SECTION_PLANE_DEFAULTS, SECTION_CAP_DEFAULTS } from '../constants.js';
import { planeBasis, nearestCardinalAxis } from '@ifc-lite/renderer';

/**
 * Project `pickedAt` onto the current cut plane and return that point as
 * the "anchor on the live plane".
 *
 * The plane equation is `dot(p, normal) = distance`. As the user drags
 * the gizmo (or moves the slider) only `distance` changes — `pickedAt`
 * stays at the original face-pick location, which sits OFF the live
 * plane. Any visual that needs a "point on the current plane" (cap
 * polygon basis origin, 3D drag gizmo position, hatch UV anchor) must
 * use the projected point instead, otherwise it freezes at the original
 * pick location while the actual cut slides along the normal.
 *
 * Derivation: the projection of `pickedAt` onto the plane is
 * `pickedAt + (distance − dot(pickedAt, normal)) · normal`, which moves
 * `pickedAt` along the unit normal by exactly the offset required to
 * satisfy `dot(out, normal) = distance`.
 *
 * Round-trip note: when `distance == dot(pickedAt, normal)` (i.e. just
 * after a fresh face-pick) the result equals `pickedAt`, so the legacy
 * code path that fed `pickedAt` directly is preserved at pick-time.
 */
export function customPlaneCenter(plane: CustomSectionPlane): [number, number, number] {
  const { pickedAt: p, normal: n, distance: d } = plane;
  const dotPicked = p[0] * n[0] + p[1] * n[1] + p[2] * n[2];
  const k = d - dotPicked;
  return [p[0] + k * n[0], p[1] + k * n[1], p[2] + k * n[2]];
}

// ─── Persistence ─────────────────────────────────────────────────────────
// Cap appearance (hatch pattern, colours, spacing, angle, whether the cap is
// shown at all) persists across reloads via localStorage, so the user's
// preferred cut surface survives closing and re-opening the app. Axis and
// position are session-scoped because they only make sense relative to a
// loaded model. See chatSlice.ts for the same direct-localStorage pattern
// used elsewhere in the store.
const CAP_STYLE_STORAGE_KEY     = 'ifc-lite:section-cap-style';
const CAP_SHOW_STORAGE_KEY      = 'ifc-lite:section-cap-show';
const OUTLINES_SHOW_STORAGE_KEY = 'ifc-lite:section-outlines-show';

// Last-used section mode (issue #243 follow-up). When the user reopens
// the section tool we restore whichever mode they used last:
//   • 'pick'     — face-pick is rearmed (default for first-time users
//                  and anyone whose last action was a face pick).
//   • 'cardinal' — restore the previous axis + position + flipped so the
//                  cut appears exactly where they left it.
// Custom (face-picked) planes are NOT persisted: they're tied to the
// loaded model's world coordinates and would land somewhere meaningless
// on a different model. Re-arming pick mode lets the user re-cut the
// equivalent face on the new model with one click.
const SECTION_MODE_STORAGE_KEY  = 'ifc-lite:section-last-mode';

export type LastSectionMode =
  | { kind: 'pick' }
  | { kind: 'cardinal'; axis: SectionPlaneAxis; position: number; flipped: boolean };

const DEFAULT_LAST_MODE: LastSectionMode = { kind: 'pick' };

function isSectionPlaneAxis(v: unknown): v is SectionPlaneAxis {
  return v === 'down' || v === 'front' || v === 'side';
}

export function loadLastSectionMode(): LastSectionMode {
  if (typeof window === 'undefined') return DEFAULT_LAST_MODE;
  try {
    const raw = window.localStorage.getItem(SECTION_MODE_STORAGE_KEY);
    if (!raw) return DEFAULT_LAST_MODE;
    const parsed = JSON.parse(raw) as Record<string, unknown>;
    if (parsed?.kind === 'pick') return { kind: 'pick' };
    if (
      parsed?.kind === 'cardinal' &&
      isSectionPlaneAxis(parsed.axis) &&
      typeof parsed.position === 'number' && Number.isFinite(parsed.position) &&
      typeof parsed.flipped === 'boolean'
    ) {
      // Clamp position to the same [0, 100] range the slice enforces so
      // a tampered or stale value can't poison the slider on restore.
      const position = Math.min(100, Math.max(0, parsed.position));
      return { kind: 'cardinal', axis: parsed.axis, position, flipped: parsed.flipped };
    }
    return DEFAULT_LAST_MODE;
  } catch {
    // Corrupted JSON or storage exception — fall back to the default
    // pick mode silently. We don't warn here because this runs on every
    // panel mount and would spam the console for any user with bad data.
    return DEFAULT_LAST_MODE;
  }
}

function saveLastSectionMode(mode: LastSectionMode): void {
  if (typeof window === 'undefined') return;
  try {
    window.localStorage.setItem(SECTION_MODE_STORAGE_KEY, JSON.stringify(mode));
  } catch {
    // Quota exceeded / private mode — best effort, the preference just
    // doesn't survive this session.
  }
}

function clearLastSectionMode(): void {
  if (typeof window === 'undefined') return;
  try {
    window.localStorage.removeItem(SECTION_MODE_STORAGE_KEY);
  } catch { /* best-effort */ }
}

const HATCH_IDS: readonly SectionCapHatchId[] = [
  'solid', 'diagonal', 'crossHatch', 'horizontal',
  'vertical', 'concrete', 'brick', 'insulation',
] as const;

function isHatchId(v: unknown): v is SectionCapHatchId {
  return typeof v === 'string' && (HATCH_IDS as readonly string[]).includes(v);
}

function isRgba(v: unknown): v is [number, number, number, number] {
  return Array.isArray(v) && v.length === 4 && v.every((n) => typeof n === 'number' && Number.isFinite(n));
}

function loadCapStyle(): SectionCapStyle {
  const fallback: SectionCapStyle = {
    fillColor:   [...SECTION_CAP_DEFAULTS.FILL_COLOR],
    strokeColor: [...SECTION_CAP_DEFAULTS.STROKE_COLOR],
    pattern:     SECTION_CAP_DEFAULTS.PATTERN,
    spacingPx:   SECTION_CAP_DEFAULTS.SPACING_PX,
    angleRad:    SECTION_CAP_DEFAULTS.ANGLE_RAD,
    widthPx:     SECTION_CAP_DEFAULTS.WIDTH_PX,
    secondaryAngleRad: SECTION_CAP_DEFAULTS.SECONDARY_ANGLE_RAD,
  };
  if (typeof window === 'undefined') return fallback;
  try {
    const raw = window.localStorage.getItem(CAP_STYLE_STORAGE_KEY);
    if (!raw) return fallback;
    const parsed = JSON.parse(raw) as Record<string, unknown>;
    return {
      fillColor:   isRgba(parsed.fillColor)   ? parsed.fillColor   : fallback.fillColor,
      strokeColor: isRgba(parsed.strokeColor) ? parsed.strokeColor : fallback.strokeColor,
      pattern:     isHatchId(parsed.pattern)  ? parsed.pattern     : fallback.pattern,
      spacingPx:   typeof parsed.spacingPx === 'number' && Number.isFinite(parsed.spacingPx)
        ? Math.max(2, parsed.spacingPx) : fallback.spacingPx,
      angleRad:    typeof parsed.angleRad === 'number' && Number.isFinite(parsed.angleRad)
        ? parsed.angleRad : fallback.angleRad,
      widthPx:     typeof parsed.widthPx === 'number' && Number.isFinite(parsed.widthPx)
        ? Math.max(1, parsed.widthPx) : fallback.widthPx,
      secondaryAngleRad: typeof parsed.secondaryAngleRad === 'number' && Number.isFinite(parsed.secondaryAngleRad)
        ? parsed.secondaryAngleRad : fallback.secondaryAngleRad,
    };
  } catch (error) {
    console.warn('[section] failed to load cap style from localStorage', error);
    return fallback;
  }
}

function saveCapStyle(style: SectionCapStyle): void {
  if (typeof window === 'undefined') return;
  try {
    window.localStorage.setItem(CAP_STYLE_STORAGE_KEY, JSON.stringify(style));
  } catch (error) {
    // Storage quota, private mode etc. — preference just doesn't persist this
    // session; log so a missing setting is at least diagnosable in devtools.
    console.warn('[section] failed to save cap style to localStorage', error);
  }
}

function loadBoolean(key: string, fallback: boolean): boolean {
  if (typeof window === 'undefined') return fallback;
  try {
    const raw = window.localStorage.getItem(key);
    if (raw === 'true') return true;
    if (raw === 'false') return false;
  } catch (error) {
    console.warn(`[section] failed to load preference '${key}' from localStorage`, error);
  }
  return fallback;
}

function saveBoolean(key: string, value: boolean): void {
  if (typeof window === 'undefined') return;
  try {
    window.localStorage.setItem(key, String(value));
  } catch (error) {
    console.warn(`[section] failed to save preference '${key}' to localStorage`, error);
  }
}

const loadShowCap      = () => loadBoolean(CAP_SHOW_STORAGE_KEY,      SECTION_PLANE_DEFAULTS.SHOW_CAP);
const saveShowCap      = (v: boolean) => saveBoolean(CAP_SHOW_STORAGE_KEY,      v);
const loadShowOutlines = () => loadBoolean(OUTLINES_SHOW_STORAGE_KEY, SECTION_PLANE_DEFAULTS.SHOW_OUTLINES);
const saveShowOutlines = (v: boolean) => saveBoolean(OUTLINES_SHOW_STORAGE_KEY, v);

/**
 * Live "where will I cut if you click here?" preview, set by the hover
 * dwell handler in `useMouseControls.ts` while `sectionPickMode` is on.
 *
 * `normal` is camera-oriented (matches the face-pick commit policy in
 * `selectionHandlers.ts`) so the preview's arrow points in the same
 * direction the actual cut will keep, and the user sees a visually
 * continuous transition on click. `point` is the world-space hit
 * location. `faceKey` is used by the hover handler to detect "still on
 * the same face" so cursor wobble within a flat surface doesn't
 * retrigger the dwell timer or repaint the overlay.
 */
export interface SectionPickPreview {
  normal:  [number, number, number];
  point:   [number, number, number];
  faceKey: string;
}

export interface SectionSlice {
  // State
  sectionPlane: SectionPlane;
  /**
   * When true, the next click on the canvas picks a face and sets the
   * section plane through it (world-space normal + point). Cleared after
   * one pick, a missed click, or a tool change. See
   * `selectionHandlers.ts` for the consumer.
   */
  sectionPickMode: boolean;
  /**
   * Hover preview for the face-pick gesture (issue #243 follow-up).
   * Populated by the dwell handler when the cursor pauses ~200ms over a
   * surface; consumed by `SectionVisualization.tsx` to paint a
   * translucent violet quad + a tiny normal arrow on the hovered face.
   * Cleared on cursor leaving the canvas, moving to a different face,
   * disarming pick mode, or successful commit.
   */
  sectionPickPreview: SectionPickPreview | null;

  // Actions
  setSectionPlaneAxis: (axis: SectionPlaneAxis) => void;
  setSectionPlanePosition: (position: number) => void;
  toggleSectionPlane: () => void;
  setSectionPlaneEnabled: (enabled: boolean) => void;
  flipSectionPlane: () => void;
  setSectionShowCap: (show: boolean) => void;
  setSectionShowOutlines: (show: boolean) => void;
  setSectionCapStyle: (style: Partial<SectionCapStyle>) => void;
  resetSectionPlane: () => void;
  /**
   * Set the section plane from a face pick. `normal` is the face's world-
   * space unit normal; `point` is any point on the face (typically the
   * raycast hit). The derived plane equation is
   * `dot(worldPos, normal) = dot(point, normal)`.
   *
   * Also writes the nearest cardinal `axis` + `flipped` and a percentage-
   * along-that-axis `position` so legacy consumers (drawings, BCF,
   * tooltips) still see a reasonable axis-aligned approximation.
   */
  setSectionPlaneFromFace: (
    normal: [number, number, number],
    point: [number, number, number],
    bounds?: { min: [number, number, number]; max: [number, number, number] },
  ) => void;
  /** Update only the custom plane's signed distance (drag gizmo / numeric input). */
  setSectionCustomDistance: (distance: number) => void;
  /** Arm/disarm the "next click picks a face" mode. Disarming clears any active hover preview. */
  setSectionPickMode: (enabled: boolean) => void;
  /**
   * Set or clear the live face-pick hover preview. `null` hides the
   * overlay (cursor left the canvas, moved to a new face, or the pick
   * mode was disarmed). Only the dwell-aware hover handler should set
   * this — it is purely a visual hint and does not change `sectionPlane`
   * (commit happens via `setSectionPlaneFromFace` on click).
   */
  setSectionPickPreview: (preview: SectionPickPreview | null) => void;
}

const getDefaultCapStyle = (): SectionCapStyle => loadCapStyle();

const getDefaultSectionPlane = (): SectionPlane => ({
  axis: SECTION_PLANE_DEFAULTS.AXIS,
  position: SECTION_PLANE_DEFAULTS.POSITION,
  enabled: SECTION_PLANE_DEFAULTS.ENABLED,
  flipped: SECTION_PLANE_DEFAULTS.FLIPPED,
  // showCap + showOutlines + capStyle come from localStorage so the
  // user's preferred cut-surface appearance survives reloads; the axis,
  // position, and enabled fields stay session-scoped because they only
  // make sense for the currently loaded model.
  showCap:      loadShowCap(),
  showOutlines: loadShowOutlines(),
  capStyle:     getDefaultCapStyle(),
});

export const createSectionSlice: StateCreator<SectionSlice, [], [], SectionSlice> = (set) => ({
  // Initial state
  sectionPlane: getDefaultSectionPlane(),
  sectionPickMode: false,
  sectionPickPreview: null,

  // Actions
  setSectionPlaneAxis: (axis) => set((state) => {
    // Persist the cardinal choice so reopening the section tool restores
    // axis + position + flipped (issue #243 follow-up). Position and
    // flipped come from current state — picking an axis doesn't reset
    // either, it just switches which axis the slider walks along.
    saveLastSectionMode({
      kind: 'cardinal',
      axis,
      position: state.sectionPlane.position,
      flipped:  state.sectionPlane.flipped,
    });
    return {
      // Changing the axis implicitly means "I want to cut now" — enable the clip
      // so users don't get stuck in a confusing no-op preview. Also drop any
      // custom (face-picked) plane so the cardinal preset takes over cleanly.
      sectionPlane: { ...state.sectionPlane, axis, enabled: true, custom: undefined },
    };
  }),

  setSectionPlanePosition: (position) => set((state) => {
    // Clamp position to valid range [0, 100]
    const clampedPosition = Math.min(100, Math.max(0, Number(position) || 0));
    // Slider semantics differ between cardinal and custom modes:
    //   • cardinal: percentage along the axis between bounds extents.
    //   • custom: percentage along the picked normal between the bounds-
    //     diagonal extents centred on `pickedAt`. The renderer translates
    //     that to a signed `distance`; the action below just stores the
    //     percentage and updates `custom.distance` to match.
    const next: SectionPlane = { ...state.sectionPlane, position: clampedPosition, enabled: true };
    // Persist the cardinal slider position so the user gets the same cut
    // back on reopen (issue #243 follow-up). Custom-mode position drives
    // a face-anchored distance which we deliberately don't persist —
    // those coordinates are model-relative and meaningless across files.
    if (!state.sectionPlane.custom) {
      saveLastSectionMode({
        kind: 'cardinal',
        axis:     state.sectionPlane.axis,
        position: clampedPosition,
        flipped:  state.sectionPlane.flipped,
      });
    }
    if (state.sectionPlane.custom) {
      const c = state.sectionPlane.custom;
      // Re-anchor distance from percentage. The half-extent is derived
      // from the renderer-supplied bounds when we have them — at this
      // point in the slice we don't, so we use the existing distance as
      // the anchor and shift it by the percentage delta. This keeps the
      // slider responsive without the slice needing a bounds dependency.
      // The renderer cap path uses `custom.distance` verbatim regardless,
      // so the visual stays accurate.
      const dPct = (clampedPosition - state.sectionPlane.position) / 100;
      const dot = c.pickedAt[0] * c.normal[0] + c.pickedAt[1] * c.normal[1] + c.pickedAt[2] * c.normal[2];
      // 100% of slider span = ~bounds-diagonal; without bounds, fall
      // back to a generous fixed step (10 world units per 100%). The
      // SectionPanel updates this with the real bounds via
      // `setSectionCustomDistance` once they're known.
      const fallbackSpan = 10;
      next.custom = { ...c, distance: c.distance + dPct * fallbackSpan };
      // Keep `pickedAt` so future deltas remain anchored to the original
      // pick — only `distance` (and on flip, `normal`) ever change.
      void dot;
    }
    return { sectionPlane: next };
  }),

  toggleSectionPlane: () => set((state) => ({
    sectionPlane: { ...state.sectionPlane, enabled: !state.sectionPlane.enabled },
  })),

  setSectionPlaneEnabled: (enabled) => set((state) => ({
    sectionPlane: { ...state.sectionPlane, enabled },
  })),

  flipSectionPlane: () => set((state) => {
    // A plane is geometrically defined by `(normal, distance)`. Which
    // half-space is kept is a separate choice expressed by `flipped`.
    // The renderer's clip shader applies `flipped` independently
    // (`side = flipped ? -1 : 1`, then `distToPlane * side`), so toggling
    // the boolean alone is sufficient to swap the visible half-space —
    // for both cardinal and custom planes. Mutating `custom.normal` /
    // `custom.distance` here as well would double-cancel the shader's
    // own flip (negate-and-negate-again leaves the same half-space
    // clipped) and the flip button would have no visible effect.
    const flipped = !state.sectionPlane.flipped;
    // Persist the flipped state alongside axis + position for cardinal
    // mode (issue #243 follow-up). Custom-mode flips aren't persisted
    // because the whole custom plane (anchored at a model point) isn't.
    if (!state.sectionPlane.custom) {
      saveLastSectionMode({
        kind: 'cardinal',
        axis:     state.sectionPlane.axis,
        position: state.sectionPlane.position,
        flipped,
      });
    }
    return { sectionPlane: { ...state.sectionPlane, flipped } };
  }),

  setSectionShowCap: (showCap) => set((state) => {
    saveShowCap(showCap);
    return { sectionPlane: { ...state.sectionPlane, showCap } };
  }),

  setSectionShowOutlines: (showOutlines) => set((state) => {
    saveShowOutlines(showOutlines);
    return { sectionPlane: { ...state.sectionPlane, showOutlines } };
  }),

  setSectionCapStyle: (style) => set((state) => {
    const capStyle: SectionCapStyle = { ...state.sectionPlane.capStyle, ...style };
    saveCapStyle(capStyle);
    return { sectionPlane: { ...state.sectionPlane, capStyle } };
  }),

  resetSectionPlane: () => set(() => {
    // Reset clears persisted cap style too — users asking for defaults expect
    // the defaults to stick on the next reload. Same goes for the
    // last-used-mode preference (issue #243 follow-up): a reset should
    // bring everyone back to the default pick mode on next reopen.
    try {
      if (typeof window !== 'undefined') {
        window.localStorage.removeItem(CAP_STYLE_STORAGE_KEY);
        window.localStorage.removeItem(CAP_SHOW_STORAGE_KEY);
        window.localStorage.removeItem(OUTLINES_SHOW_STORAGE_KEY);
      }
    } catch (error) {
      console.warn('[section] failed to clear persisted cap preferences', error);
    }
    clearLastSectionMode();
    return { sectionPlane: getDefaultSectionPlane(), sectionPickMode: false, sectionPickPreview: null };
  }),

  setSectionPlaneFromFace: (normal, point, bounds) => set((state) => {
    const nx = normal[0]; const ny = normal[1]; const nz = normal[2];
    const len = Math.hypot(nx, ny, nz);
    if (!Number.isFinite(len) || len < 1e-6) {
      // Degenerate normal — disarm pick mode but don't poison the
      // renderer with NaNs. Also clear any in-flight hover preview so
      // the violet quad doesn't linger after a bogus pick attempt.
      console.warn('[section] face-pick received a degenerate normal; ignoring');
      return { sectionPickMode: false, sectionPickPreview: null };
    }
    const unit: [number, number, number] = [nx / len, ny / len, nz / len];
    const distance = point[0] * unit[0] + point[1] * unit[1] + point[2] * unit[2];
    const basis = planeBasis(unit);
    const cardinal = nearestCardinalAxis(unit);

    // Re-compute `position` along the chosen cardinal so legacy axis-aligned
    // consumers (drawings export, BCF) get a percentage that lines up with
    // the picked plane rather than whatever slider value was set before.
    // Without `bounds` we keep the previous value (P2 CR comment on PR
    // #581) — the SectionPanel passes bounds when available.
    let position = state.sectionPlane.position;
    if (bounds) {
      const axisIdx = cardinal.axis === 'side' ? 0 : cardinal.axis === 'down' ? 1 : 2;
      const axisMin = bounds.min[axisIdx];
      const axisMax = bounds.max[axisIdx];
      const range = axisMax - axisMin;
      if (range > 1e-6) {
        const along = point[axisIdx];
        position = Math.min(100, Math.max(0,
          ((along - axisMin) / range) * 100,
        ));
      }
    }

    const custom: CustomSectionPlane = {
      normal:    unit,
      distance,
      pickedAt:  [point[0], point[1], point[2]],
      tangent:   basis.tangent,
      bitangent: basis.bitangent,
    };

    // Last-used mode is "pick" — reopening the panel rearms face-pick
    // rather than restoring a cardinal cut. We deliberately don't store
    // the custom plane itself (model-relative coords).
    saveLastSectionMode({ kind: 'pick' });

    return {
      sectionPlane: {
        ...state.sectionPlane,
        axis:    cardinal.axis,
        flipped: cardinal.flipped,
        position,
        enabled: true,
        custom,
      },
      sectionPickMode: false,
      // Commit consumes the preview — the violet quad transitions
      // visually into the actual cap on the next render. Clearing here
      // (rather than waiting for the hover handler) avoids a frame of
      // double-render where both preview and cap paint the same face.
      sectionPickPreview: null,
    };
  }),

  setSectionCustomDistance: (distance) => set((state) => {
    if (!state.sectionPlane.custom || !Number.isFinite(distance)) {
      return state;
    }
    return {
      sectionPlane: {
        ...state.sectionPlane,
        custom: { ...state.sectionPlane.custom, distance },
      },
    };
  }),

  setSectionPickMode: (enabled) => set(() => (
    // Disarming pick mode also drops any hovering preview overlay so
    // it doesn't linger after the user toggles off (Esc, second toggle
    // press, tool change). Re-arming starts fresh.
    enabled
      ? { sectionPickMode: true }
      : { sectionPickMode: false, sectionPickPreview: null }
  )),

  setSectionPickPreview: (preview) => set((state) => {
    // Setting a preview while pick mode is OFF would put the violet
    // quad on screen with no way to commit it — guard against that so
    // a stale hover event firing after disarm doesn't reintroduce the
    // overlay.
    if (preview !== null && !state.sectionPickMode) {
      return state;
    }
    return { sectionPickPreview: preview };
  }),
});
