/*
File: swGhost.js
Date: 2026-02-26
Author: klp
Workspace: SketchWaveTNT2026-02-25-Stg5
Purpose: SWGhost class for SketchWaveJS

Encapsulates the full ghost silhouette: a rounded head disk, a rectangular body,
and a configurable number of toe bumps along the bottom edge — all managed as a
single draggable unit anchored to a single SWPoint (headCenter).

Constructor parameters follow the established SW class conventions:
  - headCenter  (SWPoint)  - position anchor in user coordinates; the y-axis top
                             of the ghost. All other parts are computed relative
                             to this point.
  - radius      (number)   - head radius in user units (head diameter = 2 * radius)
  - bodyHeight  (number)   - height of the rectangular body in user units
  - numToes     (number)   - number of toe-bump disks along the bottom (0–8; 0 = flat bottom)
  - color       (SWColor)  - fill color applied to all ghost parts
  - strokeColor (SWColor)  - border color; pass null / undefined for no stroke

Control properties (set after construction):
  - shouldShowStroke (boolean) — toggle border on all parts each frame; default true

Drag interaction:
  - isDraggable            — enable/disable drag (default true)
  - handleMousePressed(mx, my, grid [,tolerance]) → bool
  - handleMouseDragged(mx, my, grid)              → bool
  - handleMouseReleased()
  - isDragging (read-only getter)

Drawing:
  - drawOnGrid(grid)  — recomputes child positions then draws each frame
                         Draw order: toes → body → head (correct layering)

Position:
  - setPosition(x, y) — moves the head anchor and rebuilds children
  - headCenter        — read-only getter for the anchor SWPoint

Animation:
  - stretching(sinusoid, t) — drives bodyHeight with an SWSinusoid each frame;
                             analogous to SWRectangle.breathe(). The head stays
                             fixed; the body and toes shift to reflect the new
                             height. Call from draw() before drawOnGrid().
                             Formula: bodyHeight = _baseBodyHeight + sinusoid.getValue(t)

=== Architecture notes ===
All child parts (body rectangle, toe centers, toe disks) are rebuilt via _build()
whenever the position changes. This is intentionally called only on drag events,
not every frame, so CPU cost is proportional to user interaction rather than
frame rate.

SWRectangle stores pre-computed vertices at construction, so movement requires
a full rebuild. SWDisk stores its center by reference (SWPoint), but since the
toe centers are fresh SWPoint objects per build pass, those are also rebuilt.
The head SWDisk re-uses the same headCenter SWPoint reference each build so it
is naturally in sync with the anchor at zero cost.

Eye options (Stage 3+):
  - eyeColors   (SWColor[2]) - [scleraFill, scleraBorder] passed to SWEyeball
  - pupilColors (SWColor[2]) - [pupilFill, irisStroke]   passed to SWEyeball
  Both default to null — when either is null, no eyes are rendered.

  Eye geometry is randomized once at construction:
    eyeSpanFactor = random(0.7, 0.95) — (leftEdge_L_eye → rightEdge_R_eye) / headDiameter
    eyeGapFactor  = random(0, 0.1)    — gap between eyes / headDiameter
    eyeRadius     = (span - gap) / 4  (derived)
  Randomized values are fixed for the lifetime of the ghost instance;
  reassign _eyeSpanFactor / _eyeGapFactor and call _build() to re-roll.

=== Notes ===
- Assumes p5.js, SWColor, SWPoint, SWDisk, SWRectangle, SWEyeball, and SWGrid are loaded.
- Must be constructed AFTER colorMode is set and initializeSWColors() has been
  called (i.e., inside p5.js setup() or later).
- drawOnGrid() should be called each frame from draw().
- Consistent API with SWDisk, SWEyeball, SWLine, SWPoint, SWTriangle.

TODO:
- Add animation: wobble(), patrol()
- lookAt(targetX, targetY) delegating to both SWEyeball instances
*/

console.log("[swGhost.js] SWGhost class loaded.");

class SWGhost {

    /**
     * @param {SWPoint}   headCenter    - Position anchor (user coordinates)
     * @param {number}    radius        - Head radius in user units
     * @param {number}    bodyHeight    - Body rectangle height in user units
     * @param {number}    numToes       - Number of toe-bump disks (0–8); 0 = flat bottom
     * @param {SWColor}   color         - Fill color for all ghost parts
     * @param {SWColor}   [strokeColor] - Border color; null/undefined = no stroke
     * @param {SWColor[]} [eyeColors]   - [scleraFill, scleraBorder]; null = no eyes
     * @param {SWColor[]} [pupilColors] - [pupilFill, irisStroke]; null = no eyes
     */
    constructor(headCenter, radius, bodyHeight, numToes, color, strokeColor = undefined,
                eyeColors = null, pupilColors = null) {

        // ── Public properties ────────────────────────────────────────
        this.radius          = radius;
        this.bodyHeight      = bodyHeight;
        this._baseBodyHeight = bodyHeight;  // rest height for stretching()
        this.numToes         = numToes;
        this.color           = color;
        this.strokeColor     = strokeColor;
        this.shouldShowStroke = true;   // set false to hide all borders
        this.isDraggable     = true;

        // ── Eye colors ───────────────────────────────────────────────
        // Both must be non-null arrays to render eyes.
        this.eyeColors   = eyeColors;   // [scleraFill, scleraBorder]
        this.pupilColors = pupilColors; // [pupilFill,  irisStroke]

        // ── Eye geometry — randomized once at construction ────────────
        // Expressed as fractions of headDiameter (2 * radius).
        //   span = leftEdge(leftEye) → rightEdge(rightEye) = spanFactor * headDiameter
        //   gap  = inner gap between eyes                  = gapFactor  * headDiameter
        //   eyeR = (span - gap) / 4
        this._eyeSpanFactor = random(0.7, 0.95);  // total eye span / head diameter
        this._eyeGapFactor  = random(0.0, 0.10);  // inter-eye gap  / head diameter

        // ── Anchor ───────────────────────────────────────────────────
        // headCenter is the one SWPoint everything derives from.
        // We do NOT copy it — we hold the reference so the caller can
        // inspect or move the ghost via the same object if needed.
        this._headCenter = headCenter;

        // ── Internal parts (created by _build) ───────────────────────
        this._ghostHead = null;     // SWDisk
        this._ghostBody = null;     // SWRectangle
        this._ghostToes = [];       // SWDisk[]
        this._leftEye   = null;     // SWEyeball
        this._rightEye  = null;     // SWEyeball

        // ── Persistent iris color & thickness ──────────────────────────────────
        // Stored separately so _build() can re-apply them each time new
        // SWEyeball instances are created (e.g. every frame when animating).
        this._irisColor     = null;
        this._irisThickness = 6;

        // ── Drag state ───────────────────────────────────────────────
        this._isDragging  = false;
        this._dragOffsetX = 0;      // user-unit offset at drag start
        this._dragOffsetY = 0;

        this._build();

    }//end constructor

    // ============================================================================
    // PRIVATE: Build / Rebuild Child Components
    // ============================================================================

    /**
     * Constructs (or reconstructs) all internal SWDisk, SWRectangle, and SWPoint
     * objects from the current headCenter position.
     *
     * Called once in the constructor, then again any time the ghost moves.
     * @private
     */
    _build() {
        const hx = this._headCenter.x;
        const hy = this._headCenter.y;

        // ── Head ──────────────────────────────────────────────────────
        // Re-uses the _headCenter SWPoint directly as the disk center.
        // No copy needed — the disk will always reflect the anchor point.
        this._ghostHead = new SWDisk(
            this._headCenter,
            this.radius,
            2,
            this.color,
            this.strokeColor
        );
        this._ghostHead.shouldShowCenter = false;

        // ── Body ──────────────────────────────────────────────────────
        // Top edge of the body coincides with the head's equator (center y).
        // Body center y = headCenter.y - bodyHeight / 2
        const bodyCenterY = hy - this.bodyHeight / 2;
        const bodyCenter  = new SWPoint(hx, bodyCenterY, undefined, 4, this.color);
        this._ghostBody   = new SWRectangle(
            bodyCenter,
            this.radius * 2,    // width = head diameter
            this.bodyHeight,
            this.color,
            { strokeColor: this.strokeColor, strokeWeight: 2,
              showVertices: false, showCenter: false }
        );

        // ── Toes ──────────────────────────────────────────────────────
        // Divide body width equally among numToes so they pack gap-free.
        // When numToes is 0 skip all toe geometry (no division by zero).
        const bodyWidth = this.radius * 2;
        this._ghostToes = [];

        if (this.numToes > 0) {
            const toeDiameter = bodyWidth / this.numToes;
            const toeRadius   = toeDiameter / 2;
            // Bottom edge of body in user coords  (screen: lower y = larger y)
            // bodyBottomY = headCenter.y - bodyHeight
            const bodyBottomY = hy - this.bodyHeight;
            const bodyLeftX   = hx - bodyWidth / 2;

            for (let i = 0; i < this.numToes; i++) {
                const toeCenterX = bodyLeftX + toeRadius + i * toeDiameter;
                const toeCenter  = new SWPoint(toeCenterX, bodyBottomY, undefined, 4, this.color);
                const toe = new SWDisk(toeCenter, toeRadius, 2, this.color, this.strokeColor);
                toe.shouldShowCenter = false;
                this._ghostToes.push(toe);
            }
        }

        // ── Eyes ──────────────────────────────────────────────────────
        // Only built when both eyeColors and pupilColors arrays are provided.
        // Eye geometry is derived from the randomized span/gap factors, which
        // are set once in the constructor and never change.
        //
        // Derivation:
        //   span = spanFactor * headDiameter  (left edge L → right edge R)
        //   gap  = gapFactor  * headDiameter  (space between the two eyes)
        //   eyeR = (span - gap) / 4           (radius of each eye)
        //   ctc  = (span + gap) / 2           (center-to-center distance)
        if (this.eyeColors && this.pupilColors) {

            // Preserve current pupil offsets so eyes don't snap to center on drag
            const prevLeftOffset  = this._leftEye  ? this._leftEye.pupilOffset  : null;
            const prevRightOffset = this._rightEye ? this._rightEye.pupilOffset : null;

            // Preserve Perlin noise state so lookingAround() wanders continuously
            // through rebuilds (which happen every frame during sinusoid animation).
            // Without this, new SWEyeball instances would reset _noiseT to 0 and
            // re-randomize _noiseOffsetX/Y every frame, making the eyes jump chaotically.
            const prevLeftNoise  = this._leftEye  ? {
                speed: this._leftEye.noiseSpeed,
                t:     this._leftEye._noiseT,
                ox:    this._leftEye._noiseOffsetX,
                oy:    this._leftEye._noiseOffsetY
            } : null;
            const prevRightNoise = this._rightEye ? {
                speed: this._rightEye.noiseSpeed,
                t:     this._rightEye._noiseT,
                ox:    this._rightEye._noiseOffsetX,
                oy:    this._rightEye._noiseOffsetY
            } : null;

            const headDiameter = this.radius * 2;
            const span = this._eyeSpanFactor * headDiameter;
            const gap  = this._eyeGapFactor  * headDiameter;
            const eyeR = (span - gap) / 4;
            const ctc  = (span + gap) / 2;  // center-to-center distance

            const leftEyeCenter  = new SWPoint(hx - ctc / 2, hy, undefined, 4, this.eyeColors[0]);
            const rightEyeCenter = new SWPoint(hx + ctc / 2, hy, undefined, 4, this.eyeColors[0]);

            const baseThickness = 2;
            const pupilFactor   = 0.4;
            const irisThickness = this._irisThickness;

            this._leftEye  = new SWEyeball(leftEyeCenter,  eyeR, baseThickness,
                                           this.eyeColors, pupilFactor, irisThickness, this.pupilColors);
            this._rightEye = new SWEyeball(rightEyeCenter, eyeR, baseThickness,
                                           this.eyeColors, pupilFactor, irisThickness, this.pupilColors);

            // Restore pupil positions after rebuild so drag doesn't reset gaze
            if (prevLeftOffset)
                this._leftEye.setPupilOffset(prevLeftOffset.radius, prevLeftOffset.angleDeg);
            if (prevRightOffset)
                this._rightEye.setPupilOffset(prevRightOffset.radius, prevRightOffset.angleDeg);

            // Restore Perlin noise state so lookingAround() continues smoothly
            if (prevLeftNoise) {
                this._leftEye.noiseSpeed      = prevLeftNoise.speed;
                this._leftEye._noiseT         = prevLeftNoise.t;
                this._leftEye._noiseOffsetX   = prevLeftNoise.ox;
                this._leftEye._noiseOffsetY   = prevLeftNoise.oy;
            }
            if (prevRightNoise) {
                this._rightEye.noiseSpeed     = prevRightNoise.speed;
                this._rightEye._noiseT        = prevRightNoise.t;
                this._rightEye._noiseOffsetX  = prevRightNoise.ox;
                this._rightEye._noiseOffsetY  = prevRightNoise.oy;
            }

            // Re-apply persistent iris color so a color-picker change survives
            // every subsequent _build() call (which happens each animated frame).
            if (this._irisColor) {
                this._leftEye.setIrisColor(this._irisColor);
                this._rightEye.setIrisColor(this._irisColor);
            }

        } else {
            this._leftEye  = null;
            this._rightEye = null;
        }

    }//end _build

    // ============================================================================
    // POSITION
    // ============================================================================

    /**
     * Moves the ghost so that its head is centered at (x, y) and rebuilds all
     * child components.
     * @param {number} x
     * @param {number} y
     */
    setPosition(x, y) {
        this._headCenter.setPosition(x, y);
        this._build();
    }//end setPosition

    /** Read-only access to the head anchor SWPoint. */
    get headCenter() { return this._headCenter; }

    /**
     * Sets the iris ring color on both eyes and remembers it so the color
     * survives every subsequent _build() call (which fires on every setPosition).
     * @param {SWColor} color
     */
    setIrisColor(color) {
        this._irisColor = color;
        if (this._leftEye)  this._leftEye.setIrisColor(color);
        if (this._rightEye) this._rightEye.setIrisColor(color);
    }//end setIrisColor

    /**
     * Sets the iris ring stroke weight on both eyes and remembers it so the
     * value survives every subsequent _build() call (each animated frame).
     * @param {number} val  stroke weight in screen pixels (e.g. 1–8)
     */
    setIrisThickness(val) {
        this._irisThickness = parseFloat(val);
        if (this._leftEye)  this._leftEye.irisThickness  = this._irisThickness;
        if (this._rightEye) this._rightEye.irisThickness = this._irisThickness;
    }//end setIrisThickness

    /**
     * Changes the fill color of the ghost body (head, body, toes) and immediately
     * rebuilds all child parts so the new color takes effect this frame.
     * @param {SWColor} color
     */
    setGhostColor(color) {
        this.color = color;
        this._build();
    }//end setGhostColor

    /**
     * Changes the number of toe-bump disks along the bottom of the ghost body
     * and immediately rebuilds child parts.
     * @param {number} n  0–8
     */
    setNumToes(n) {
        this.numToes = Math.max(0, Math.min(8, parseInt(n)));
        this._build();
    }//end setNumToes

    /**
     * Stretches the ghost vertically each frame by driving bodyHeight with a
     * sinusoid — analogous to SWRectangle.breathe().
     *
     * The head stays fixed at headCenter; the body rectangle and toe bumps shift
     * downward so the ghost grows and shrinks rhythmically.
     *
     * Formula:  bodyHeight = _baseBodyHeight + sinusoid.getValue(t)
     *   • _baseBodyHeight — rest height stored at construction time
     *   • sinusoid.A      — stretch amplitude in user units
     *   • sinusoid.C      — DC offset; use 0 for symmetric oscillation
     *   • t               — elapsed time in seconds (pass millis()/1000)
     *
     * Call each frame from draw(), before drawOnGrid().
     * Compatible with simultaneous hover animation — both can be active at once.
     *
     * @param {SWSinusoid} sinusoid
     * @param {number}     t  elapsed time in seconds
     */
    stretching(sinusoid, t) {
        this.bodyHeight = Math.max(0.1, this._baseBodyHeight + sinusoid.getValue(t));
        this._build();
    }//end stretching

    // ============================================================================
    // HIT TESTING
    // ============================================================================

    /**
     * Returns true if the screen-space coordinate (screenX, screenY) falls within
     * the ghost's bounding box (head top to toe bottom, full body width).
     * Used internally by handleMousePressed().
     *
     * @param {number} screenX
     * @param {number} screenY
     * @param {SWGrid} grid
     * @returns {boolean}
     * @private
     */
    _containsScreenPoint(screenX, screenY, grid) {
        const hx = this._headCenter.x;
        const hy = this._headCenter.y;

        // Bounding box in user coords
        // When numToes is 0 there are no toe bumps, so toeRadius is 0.
        const toeRadius = this.numToes > 0
            ? (this.radius * 2) / this.numToes / 2
            : 0;

        const left   = hx - this.radius;
        const right  = hx + this.radius;
        const top    = hy + this.radius;           // user-space top (largest y)
        const bottom = hy - this.bodyHeight - toeRadius; // user-space bottom

        // Convert to screen coords (userToScreen flips y)
        const { x: sx1, y: sy1 } = grid.userToScreen(left,  top);
        const { x: sx2, y: sy2 } = grid.userToScreen(right, bottom);

        // sy1 < sy2 in screen space (top user Y maps to smaller screen Y)
        const screenLeft   = Math.min(sx1, sx2);
        const screenRight  = Math.max(sx1, sx2);
        const screenTop    = Math.min(sy1, sy2);
        const screenBottom = Math.max(sy1, sy2);

        return (screenX >= screenLeft  && screenX <= screenRight &&
                screenY >= screenTop   && screenY <= screenBottom);
    }//end _containsScreenPoint

    // ============================================================================
    // DRAWING
    // ============================================================================

    /**
     * Applies current stroke visibility, then draws all ghost parts in the correct
     * layer order: toes → body → head.
     *
     * Call this each frame from draw().
     * @param {SWGrid} grid
     */
    drawOnGrid(grid) {
        // Resolve active stroke for this frame
        const activeStroke = (this.shouldShowStroke && this.strokeColor) ? this.strokeColor : null;
        this._ghostHead.strokeColor = activeStroke;
        this._ghostBody.strokeColor = activeStroke;
        for (const toe of this._ghostToes) toe.strokeColor = activeStroke;

        // Draw order is critical:
        // 1. Toes first — body will paint over their top halves
        // 2. Body second — covers top of toes, merges visually with head
        // 3. Head last  — always on top
        // 4. Eyes on top of head
        for (const toe of this._ghostToes) toe.drawOnGrid(grid);
        this._ghostBody.drawOnGrid(grid);
        this._ghostHead.drawOnGrid(grid);
        if (this._leftEye)  this._leftEye.drawOnGrid(grid);
        if (this._rightEye) this._rightEye.drawOnGrid(grid);

    }//end drawOnGrid

    // ============================================================================
    // INTERACTION: Drag
    // ============================================================================

    /**
     * Call from the p5.js mousePressed() function.
     * Checks whether the click is inside the ghost and starts a drag if so.
     *
     * Returns true if this ghost consumed the event — the caller should then
     * skip other click-based actions (e.g. toggling grid visibility).
     *
     * @param {number} mx               - p5.js mouseX (screen pixels)
     * @param {number} my               - p5.js mouseY (screen pixels)
     * @param {SWGrid} grid             - the active SWGrid
     * @param {number} [tolerance=0]    - extra pixel margin for hit detection
     * @returns {boolean} true if drag started
     */
    handleMousePressed(mx, my, grid, tolerance = 0) {
        // Eye interaction takes priority: a pupil click should not start a ghost drag.
        if (this._leftEye  && this._leftEye.handleMousePressed(mx, my, grid))  return true;
        if (this._rightEye && this._rightEye.handleMousePressed(mx, my, grid)) return true;

        if (!this.isDraggable) return false;
        if (!this._containsScreenPoint(mx, my, grid)) return false;

        const userPos = grid.screenToUser(mx, my);
        this._isDragging  = true;
        this._dragOffsetX = userPos.x - this._headCenter.x;
        this._dragOffsetY = userPos.y - this._headCenter.y;
        return true;
    }//end handleMousePressed

    /**
     * Call from the p5.js mouseDragged() function.
     * Updates the ghost position while preserving the original click offset so
     * the ghost doesn't jump.
     *
     * Returns true while this ghost is being dragged.
     *
     * @param {number} mx   - p5.js mouseX (screen pixels)
     * @param {number} my   - p5.js mouseY (screen pixels)
     * @param {SWGrid} grid - the active SWGrid
     * @returns {boolean} true if this ghost is being dragged
     */
    handleMouseDragged(mx, my, grid) {
        // Forward to eyes first; if an eye pupil is being dragged, skip ghost move.
        let eyeConsumed = false;
        if (this._leftEye  && this._leftEye.handleMouseDragged(mx, my, grid))  eyeConsumed = true;
        if (this._rightEye && this._rightEye.handleMouseDragged(mx, my, grid)) eyeConsumed = true;
        if (eyeConsumed) return true;

        if (!this._isDragging) return false;
        const userPos = grid.screenToUser(mx, my);
        this.setPosition(
            userPos.x - this._dragOffsetX,
            userPos.y - this._dragOffsetY
        );
        return true;
    }//end handleMouseDragged

    /**
     * Call from the p5.js mouseReleased() function.
     * Ends the drag operation.
     */
    handleMouseReleased() {
        this._isDragging = false;
        if (this._leftEye)  this._leftEye.handleMouseReleased();
        if (this._rightEye) this._rightEye.handleMouseReleased();
    }//end handleMouseReleased

    /** True while the user is actively dragging the ghost. @type {boolean} */
    get isDragging() { return this._isDragging; }

    // ============================================================================
    // DIAGNOSTICS
    // ============================================================================

    /** @returns {string} */
    toString() {
        const eyeStr = (this._leftEye && this._rightEye)
            ? `, eyes: r=${((this.radius * 2 * this._eyeSpanFactor - this.radius * 2 * this._eyeGapFactor) / 4).toFixed(2)}`
            : ', no eyes';
        return `SWGhost @ (${this._headCenter.x.toFixed(2)}, ${this._headCenter.y.toFixed(2)}), ` +
               `r=${this.radius}, bodyH=${this.bodyHeight}, toes=${this.numToes}${eyeStr}`;
    }//end toString

}//end SWGhost class
