/**
 * Input model for the floating control layer.
 *
 * This module is intentionally **free of `cc` dependencies** so that:
 *  - 45°/135° parabolic recognition (req 2.5, 20.3)
 *  - joystick dead-zone (req 1.5)
 *  - safe-area adaptation (req 1.7, 18.6)
 *  - multi-touch routing (req 1.3, 1.8)
 *
 * can all be unit-tested under Jest with deterministic coordinates.
 *
 * The Cocos Creator view layer (`FloatingControlLayer.ts`) is a thin adapter
 * that forwards `TouchEvent` data into this model and renders whatever the
 * model reports.
 */

import {
    DESIGN_WIDTH,
    DESIGN_HEIGHT,
    PARABOLIC_ANGLE_RIGHT,
    PARABOLIC_ANGLE_LEFT,
    PARABOLIC_ANGLE_TOLERANCE,
} from '../common/Constants';

/** Control IDs addressable by the HUD. */
export enum ControlId {
    Joystick = 'joystick',
    Jump = 'jump',
    Shuriken = 'shuriken',
    NinjaSword = 'ninja_sword',
}

/**
 * A rectangular region defined in **landscape design coordinates**
 * (origin at bottom-left, width=960, height=540).
 *
 *  ┌─────────────────────────────┐
 *  │                             │
 *  │          game world         │
 *  │                             │
 *  │ [joy]                 [S][K]│   ← joystick bottom-left, attacks bottom-right
 *  │  [J]                        │   ← jump above joystick-right
 *  └─────────────────────────────┘
 */
export interface IHitRect {
    /** x of the rect's center, in design pixels. */
    cx: number;
    /** y of the rect's center, in design pixels. */
    cy: number;
    /** Full width (design px). */
    w: number;
    /** Full height (design px). */
    h: number;
}

/** Landscape default layout — requirement 1.1. */
export interface IFloatingLayout {
    joystick: IHitRect;
    jump: IHitRect;
    shuriken: IHitRect;
    ninjaSword: IHitRect;
    /** Dead-zone radius inside the joystick (req 1.5). */
    joystickDeadzone: number;
    /** Default opacity (0-1). Req 1.1 specifies 0.7. */
    opacity: number;
}

export const DEFAULT_LAYOUT: IFloatingLayout = {
    // Left-third safe area: joystick and jump stacked (req 1.1)
    joystick: { cx: 120, cy: 100, w: 120, h: 120 },
    jump: { cx: 235, cy: 180, w: 90, h: 90 },

    // Right-third safe area: two attack buttons side-by-side (req 1.1)
    shuriken: { cx: DESIGN_WIDTH - 195, cy: 100, w: 90, h: 90 },
    ninjaSword: { cx: DESIGN_WIDTH - 85, cy: 100, w: 90, h: 90 },

    joystickDeadzone: 10,
    opacity: 0.7,
};

/** Direction vector, already normalised (or zero). */
export interface IDirection {
    x: number;
    y: number;
    /** Magnitude of the raw vector **before** normalisation. */
    magnitude: number;
}

export const ZERO_DIRECTION: IDirection = Object.freeze({ x: 0, y: 0, magnitude: 0 });

/**
 * Classification of a joystick direction relative to the parabolic trigger.
 *
 *  - `none` — inside dead-zone.
 *  - `horizontal` — left/right movement, vertical jump allowed.
 *  - `parabolic_right` — ~45°, triggers ↗ parabolic jump (req 2.5).
 *  - `parabolic_left`  — ~135°, triggers ↖ parabolic jump (req 2.5).
 *  - `other` — any other 2D vector.
 */
export type JoystickAngleClass =
    | 'none'
    | 'horizontal'
    | 'parabolic_right'
    | 'parabolic_left'
    | 'other';

/** Clamp `v` to [min, max]. */
export function clamp(v: number, min: number, max: number): number {
    return v < min ? min : v > max ? max : v;
}

/**
 * Returns true if `(x, y)` lies inside `rect`. Origin is bottom-left.
 * Used by both `isInside` and the touch router.
 */
export function isInsideRect(rect: IHitRect, x: number, y: number): boolean {
    const halfW = rect.w / 2;
    const halfH = rect.h / 2;
    return Math.abs(x - rect.cx) <= halfW && Math.abs(y - rect.cy) <= halfH;
}

/** Which control (if any) is hit at `(x, y)`. Priority: attack > jump > joystick. */
export function hitTest(layout: IFloatingLayout, x: number, y: number): ControlId | null {
    if (isInsideRect(layout.ninjaSword, x, y)) return ControlId.NinjaSword;
    if (isInsideRect(layout.shuriken, x, y)) return ControlId.Shuriken;
    if (isInsideRect(layout.jump, x, y)) return ControlId.Jump;
    if (isInsideRect(layout.joystick, x, y)) return ControlId.Joystick;
    return null;
}

/**
 * Compute a joystick direction vector from a touch point. Touches **outside**
 * the joystick disc still map to a direction: we use the offset from the
 * joystick centre (requirement 1.4). Inside the dead-zone the result is zero.
 */
export function joystickDirection(layout: IFloatingLayout, touchX: number, touchY: number): IDirection {
    const dx = touchX - layout.joystick.cx;
    const dy = touchY - layout.joystick.cy;
    const mag = Math.hypot(dx, dy);
    if (mag < layout.joystickDeadzone) {
        return ZERO_DIRECTION;
    }
    return { x: dx / mag, y: dy / mag, magnitude: mag };
}

/**
 * Map a direction vector into an `JoystickAngleClass` bucket.
 *
 * The canonical angles are:
 *  - 0°  → right
 *  - 90° → up
 *  - 180° → left
 *
 * Parabolic trigger windows are 45°±15° and 135°±15° (req 2.5 + tolerance
 * picked to stay within req 20.3's ≥95% recognition rate).
 */
export function classifyDirection(dir: IDirection): JoystickAngleClass {
    if (dir.magnitude === 0) return 'none';
    // atan2 returns [-PI, PI]. Convert to [0, 360).
    let deg = (Math.atan2(dir.y, dir.x) * 180) / Math.PI;
    if (deg < 0) deg += 360;
    // Pure horizontal (≤ ~10° off x-axis) treated as `horizontal`.
    if (deg <= 10 || deg >= 350 || (deg >= 170 && deg <= 190)) return 'horizontal';
    if (Math.abs(deg - PARABOLIC_ANGLE_RIGHT) <= PARABOLIC_ANGLE_TOLERANCE) return 'parabolic_right';
    if (Math.abs(deg - PARABOLIC_ANGLE_LEFT) <= PARABOLIC_ANGLE_TOLERANCE) return 'parabolic_left';
    return 'other';
}

// ---------------------------------------------------------------------------
// Safe-area adaptation — requirement 1.7, 18.6
// ---------------------------------------------------------------------------

/** Screen aspect ratios handled without letterboxing (req 1.7). */
export interface ISafeAreaInsets {
    /** Px added on the left edge to avoid notches / sensors. */
    left: number;
    right: number;
    top: number;
    bottom: number;
}

/**
 * Returns a shifted copy of `layout` that respects the given safe-area
 * insets. The joystick group slides **rightwards** by `insets.left`; the
 * attack group slides **leftwards** by `insets.right`; vertical shifts are
 * symmetric. This keeps every control inside the device safe area without
 * changing the relative geometry.
 */
export function applySafeArea(layout: IFloatingLayout, insets: ISafeAreaInsets): IFloatingLayout {
    const shiftLeftGroup = (r: IHitRect): IHitRect => ({
        ...r,
        cx: r.cx + insets.left,
        cy: clamp(r.cy + insets.bottom, r.h / 2, DESIGN_HEIGHT - insets.top - r.h / 2),
    });
    const shiftRightGroup = (r: IHitRect): IHitRect => ({
        ...r,
        cx: r.cx - insets.right,
        cy: clamp(r.cy + insets.bottom, r.h / 2, DESIGN_HEIGHT - insets.top - r.h / 2),
    });
    return {
        ...layout,
        joystick: shiftLeftGroup(layout.joystick),
        jump: shiftLeftGroup(layout.jump),
        shuriken: shiftRightGroup(layout.shuriken),
        ninjaSword: shiftRightGroup(layout.ninjaSword),
    };
}

// ---------------------------------------------------------------------------
// Multi-touch router — requirement 1.3, 1.8
// ---------------------------------------------------------------------------

/** Payload stored per active finger. */
interface TouchSlot {
    control: ControlId | null;
    x: number;
    y: number;
    /** Timestamp (ms) captured on touchstart — used for combo recognition. */
    startTs: number;
}

/**
 * Tracks all currently-down fingers and routes each to the appropriate
 * control. Events that miss every button fall through to the game-world
 * layer by reporting `control === null` (requirement 1.3).
 */
export class MultiTouchRouter {
    private readonly slots = new Map<number, TouchSlot>();

    constructor(private readonly layout: IFloatingLayout) {}

    /** Begin tracking a new finger. Returns the hit control (or null). */
    public begin(id: number, x: number, y: number, ts: number): ControlId | null {
        const control = hitTest(this.layout, x, y);
        this.slots.set(id, { control, x, y, startTs: ts });
        return control;
    }

    /** Update an in-flight finger. Returns the same control it bound to. */
    public move(id: number, x: number, y: number): ControlId | null {
        const slot = this.slots.get(id);
        if (!slot) return null;
        slot.x = x;
        slot.y = y;
        return slot.control;
    }

    /** Release a finger. Returns the control it was bound to. */
    public end(id: number): ControlId | null {
        const slot = this.slots.get(id);
        this.slots.delete(id);
        return slot?.control ?? null;
    }

    /** Returns the joystick slot (if any finger is currently driving it). */
    public joystickSlot(): TouchSlot | undefined {
        for (const s of this.slots.values()) {
            if (s.control === ControlId.Joystick) return s;
        }
        return undefined;
    }

    /** Convenience — is this control currently pressed? */
    public isPressed(control: ControlId): boolean {
        for (const s of this.slots.values()) {
            if (s.control === control) return true;
        }
        return false;
    }

    /** Returns how many simultaneous fingers are currently tracked. */
    public get activeTouchCount(): number {
        return this.slots.size;
    }

    /** Returns the earliest-pressed start timestamp among currently-active controls. */
    public earliestPressTs(controls: ControlId[]): number | undefined {
        let best: number | undefined;
        for (const s of this.slots.values()) {
            if (!s.control || !controls.includes(s.control)) continue;
            if (best === undefined || s.startTs < best) best = s.startTs;
        }
        return best;
    }

    public clear(): void {
        this.slots.clear();
    }
}
