import { _decorator, Component, EventTouch, Node, Touch, UITransform, Vec2, Vec3, view, input, Input } from 'cc';
import { globalEventBus, globalLogger } from '../common/index';
import { DESIGN_WIDTH, DESIGN_HEIGHT, PERF_TOUCH_RESPONSE_MAX_MS } from '../common/Constants';
import {
    ControlId,
    DEFAULT_LAYOUT,
    IFloatingLayout,
    ISafeAreaInsets,
    MultiTouchRouter,
    applySafeArea,
    classifyDirection,
    joystickDirection,
} from './InputModel';
import { InputEvents } from './InputEvents';

const { ccclass, property } = _decorator;

/**
 * View component for the floating control layer.
 *
 * Responsibilities (req 1.x, 20.1):
 *  - Subscribe to all touch events on a full-screen UI node.
 *  - Translate device coordinates into landscape-design coordinates.
 *  - Delegate hit-testing / dead-zone / angle classification to `InputModel`
 *    (platform-agnostic, already unit-tested under Jest).
 *  - Emit high-level input events through `globalEventBus`.
 *  - Record touch→response latency to `globalLogger` for QA (req 20.1).
 *
 * IMPORTANT: This class intentionally avoids any gameplay logic so that the
 * player controller (task 4.x) can be swapped / re-tested without touching
 * the input layer.
 */
@ccclass('FloatingControlLayer')
export class FloatingControlLayer extends Component {
    @property({ tooltip: 'The root node of the joystick visual (bg + handle).' })
    public joystickRoot: Node | null = null;

    @property({ tooltip: 'The root node of the jump button visual.' })
    public jumpRoot: Node | null = null;

    @property({ tooltip: 'The root node of the shuriken button visual.' })
    public shurikenRoot: Node | null = null;

    @property({ tooltip: 'The root node of the ninja-sword button visual.' })
    public ninjaSwordRoot: Node | null = null;

    private layout: IFloatingLayout = DEFAULT_LAYOUT;
    private router: MultiTouchRouter = new MultiTouchRouter(DEFAULT_LAYOUT);

    /**
     * Convert a touch point from the engine's UI coordinate space back into
     * the 960×540 design-coordinate space used by `InputModel`.
     *
     * Under `FIT_HEIGHT`, the engine may widen the visible area beyond 960px
     * on ultra-wide screens (19.5:9 etc.). `getUILocation()` returns values
     * in that wider space, but our layout & hit-test are anchored to 960×540.
     *
     * The mapping is:
     *   designX = (uiX - extraLeft) * 960 / visibleWidth
     *   designY = uiY * 540 / visibleHeight   (always 1:1 under FIT_HEIGHT)
     */
    private uiToDesign(uiX: number, uiY: number): { x: number; y: number } {
        const vs = view.getVisibleSize();
        const scaleX = DESIGN_WIDTH / vs.width;
        const scaleY = DESIGN_HEIGHT / vs.height;
        // Under FIT_HEIGHT the design height always equals the visible height
        // so scaleY ≈ 1, but we apply it anyway for robustness.
        return {
            x: uiX * scaleX,
            y: uiY * scaleY,
        };
    }

    protected onLoad(): void {
        console.log('[FloatingControlLayer] onLoad — node name=', this.node?.name, 'isValid=', this.node?.isValid);
        this.applyInitialLayout();
        this.bindTouchEvents();
        console.log('[FloatingControlLayer] onLoad done — touch listeners bound');
    }

    protected onDestroy(): void {
        console.log('[FloatingControlLayer] onDestroy — unbinding touch listeners');
        this.unbindTouchEvents();
        this.router.clear();
        this.processedPhases.clear();
        this.lastStartTs.clear();
    }

    /** Public API — called by `UIFlowMgr` when safe-area changes. */
    public updateSafeArea(insets: ISafeAreaInsets): void {
        this.layout = applySafeArea(DEFAULT_LAYOUT, insets);
        this.router = new MultiTouchRouter(this.layout);
        this.syncLayoutToNodes();
    }

    /** Public API — replace the layout (used by the layout-customisation flow, task 3.2). */
    public setLayout(layout: IFloatingLayout): void {
        this.layout = layout;
        this.router = new MultiTouchRouter(layout);
        this.syncLayoutToNodes();
    }

    public getLayout(): IFloatingLayout {
        return this.layout;
    }

    // ------------------------------------------------------------------
    // internals
    // ------------------------------------------------------------------

    private applyInitialLayout(): void {
        // On first frame the engine gives us a visibleSize in real pixels;
        // we derive insets from it so the landscape 960x540 baseline still
        // maps correctly into a notched screen (req 1.7).
        const size = view.getVisibleSize();
        // Heuristic: if the device is wider than 16:9 we add insets on both
        // left/right to keep controls in the safe area.
        const baselineRatio = 16 / 9;
        const actualRatio = size.width / size.height;
        let leftInset = 0;
        let rightInset = 0;
        if (actualRatio > baselineRatio) {
            const extra = (actualRatio - baselineRatio) * size.height;
            leftInset = extra / 2 / (size.width / 960);
            rightInset = leftInset;
        }
        this.updateSafeArea({ left: leftInset, right: rightInset, top: 0, bottom: 0 });
    }

    private bindTouchEvents(): void {
        // CRITICAL: Cocos Creator 3.x has a known issue where
        // `input.off(type, cb, target)` may fail to remove listeners whose
        // target was a destroyed Component. This leaves "ghost" handlers on
        // the global input dispatcher that silently break the touch event
        // chain after scene transitions. We defensively clear ALL listeners
        // for our touch events first, then re-register — guaranteeing a
        // clean state.
        try {
            (input as any).off(Input.EventType.TOUCH_START);
            (input as any).off(Input.EventType.TOUCH_MOVE);
            (input as any).off(Input.EventType.TOUCH_END);
            (input as any).off(Input.EventType.TOUCH_CANCEL);
        } catch (e) {
            console.warn('[FloatingControlLayer] defensive input.off (no target) failed:', e);
        }

        // PRIMARY channel: global input API — receives raw touch events
        // directly from the engine, bypassing node-tree dispatch entirely.
        input.on(Input.EventType.TOUCH_START, this.onTouchStart, this);
        input.on(Input.EventType.TOUCH_MOVE, this.onTouchMove, this);
        input.on(Input.EventType.TOUCH_END, this.onTouchEnd, this);
        input.on(Input.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
        console.log('[FloatingControlLayer] bindTouchEvents — global input listeners registered');

        // SECONDARY (back-compat): node-level listeners so that
        // `ev.propagationStopped` keeps any underlying gameplay sprite from
        // also reacting to the same touch.
        this.node.on(Node.EventType.TOUCH_START, this.onTouchStart, this);
        this.node.on(Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
        this.node.on(Node.EventType.TOUCH_END, this.onTouchEnd, this);
        this.node.on(Node.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
    }

    private unbindTouchEvents(): void {
        input.off(Input.EventType.TOUCH_START, this.onTouchStart, this);
        input.off(Input.EventType.TOUCH_MOVE, this.onTouchMove, this);
        input.off(Input.EventType.TOUCH_END, this.onTouchEnd, this);
        input.off(Input.EventType.TOUCH_CANCEL, this.onTouchEnd, this);

        this.node.off(Node.EventType.TOUCH_START, this.onTouchStart, this);
        this.node.off(Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
        this.node.off(Node.EventType.TOUCH_END, this.onTouchEnd, this);
        this.node.off(Node.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
    }

    /**
     * Event de-dup. Because we listen on BOTH the global `input` API and the
     * node-level `this.node.on` channel, the same touch may fire the same
     * handler twice. This set records the `"${eventKind}:${touchId}"` string
     * for touches already processed in this "phase"; the entry is cleared
     * when the corresponding END/CANCEL arrives (or START starts a new
     * phase for that id).
     */
    private readonly processedPhases = new Set<string>();

    private onTouchStart(ev: EventTouch): void {
        const touchId = this.touchId(ev);
        console.log('[FloatingControlLayer] onTouchStart — touchId=', touchId);
        const key = `start:${touchId}`;

        // Defensive: if a stale `start` marker exists for this touchId AND
        // the router no longer has an active slot for it, the marker leaked
        // from a previous touch cycle (e.g. onTouchEnd was not called on
        // both channels, or a TOUCH_CANCEL race left the set dirty).
        // Clear it so this new touch is not silently swallowed.
        if (this.processedPhases.has(key) && !this.router.isPressedById(touchId)) {
            console.log('[FloatingControlLayer] onTouchStart — clearing stale start marker for touchId=', touchId);
            this.processedPhases.delete(key);
        }

        if (this.processedPhases.has(key)) {
            console.log('[FloatingControlLayer] onTouchStart DEDUP-skip touchId=', touchId,
                'processedPhases=', JSON.stringify([...this.processedPhases]));
            return;
        }
        this.processedPhases.add(key);
        // A new TOUCH_START invalidates any stale END marker for the same id.
        this.processedPhases.delete(`end:${touchId}`);

        const t = ev.getUILocation();
        const d = this.uiToDesign(t.x, t.y);
        console.log('[FloatingControlLayer] onTouchStart uiLoc=(', t.x, ',', t.y, ') design=(', d.x.toFixed(1), ',', d.y.toFixed(1), ')');
        const start = FloatingControlLayer.now();
        const hit = this.router.begin(touchId, d.x, d.y, start);
        console.log('[FloatingControlLayer] onTouchStart hit=', hit,
            'router.activeTouchCount=', this.router.activeTouchCount);
        this.recordLatency('input/touchStart', start);
        if (!hit) {
            // Let the touch fall through to the gameplay layer (req 1.3).
            return;
        }
        ev.propagationStopped = true;
        switch (hit) {
            case ControlId.Jump:
                globalEventBus.emit(InputEvents.JumpPressed, {});
                break;
            case ControlId.Shuriken:
                globalEventBus.emit(InputEvents.ShurikenPressed, {});
                break;
            case ControlId.NinjaSword:
                globalEventBus.emit(InputEvents.NinjaSwordPressed, {});
                break;
            case ControlId.Joystick:
                this.broadcastJoystick(d.x, d.y);
                break;
            default:
                break;
        }
    }

    private onTouchMove(ev: EventTouch): void {
        const t = ev.getUILocation();
        const d = this.uiToDesign(t.x, t.y);
        const touchId = this.touchId(ev);
        const bound = this.router.move(touchId, d.x, d.y);
        if (bound === ControlId.Joystick) {
            this.broadcastJoystick(d.x, d.y);
        }
    }

    private onTouchEnd(ev: EventTouch): void {
        const touchId = this.touchId(ev);
        const key = `end:${touchId}`;
        if (this.processedPhases.has(key)) {
            console.log('[FloatingControlLayer] onTouchEnd DEDUP-skip touchId=', touchId);
            return;
        }
        this.processedPhases.add(key);
        // End of this phase — allow a future TOUCH_START with the same id.
        this.processedPhases.delete(`start:${touchId}`);

        const end = FloatingControlLayer.now();
        const bound = this.router.end(touchId);
        console.log('[FloatingControlLayer] onTouchEnd touchId=', touchId, 'bound=', bound,
            'slots remaining=', this.router.activeTouchCount);
        if (!bound) {
            // Touch that missed every control — still clean up its timestamp
            // record so the Map does not leak over long play sessions.
            this.lastStartTs.delete(touchId);
            this.processedPhases.delete(key);
            return;
        }
        switch (bound) {
            case ControlId.Jump: {
                const slotStart = this.lastStartTs.get(touchId);
                const hold = slotStart !== undefined ? end - slotStart : 0;
                globalEventBus.emit(InputEvents.JumpReleased, { holdMs: hold });
                break;
            }
            case ControlId.Shuriken:
                globalEventBus.emit(InputEvents.ShurikenReleased, {});
                break;
            case ControlId.NinjaSword:
                globalEventBus.emit(InputEvents.NinjaSwordReleased, {});
                break;
            case ControlId.Joystick:
                globalEventBus.emit(InputEvents.JoystickMove, { dx: 0, dy: 0, klass: 'none' });
                break;
            default:
                break;
        }
        this.lastStartTs.delete(touchId);
        // Allow the same phase key to be re-used on a future touch id
        // reassignment (some platforms recycle ids aggressively).
        this.processedPhases.delete(key);
    }

    private broadcastJoystick(x: number, y: number): void {
        const dir = joystickDirection(this.layout, x, y);
        const klass = classifyDirection(dir);
        globalEventBus.emit(InputEvents.JoystickMove, { dx: dir.x, dy: dir.y, klass });
    }

    /** Mirror layout geometry onto the bound visual nodes. */
    private syncLayoutToNodes(): void {
        this.placeNode(this.joystickRoot, this.layout.joystick.cx, this.layout.joystick.cy);
        this.placeNode(this.jumpRoot, this.layout.jump.cx, this.layout.jump.cy);
        this.placeNode(this.shurikenRoot, this.layout.shuriken.cx, this.layout.shuriken.cy);
        this.placeNode(this.ninjaSwordRoot, this.layout.ninjaSword.cx, this.layout.ninjaSword.cy);
    }

    private placeNode(node: Node | null, cx: number, cy: number): void {
        if (!node) return;
        // Landscape design coordinates: origin at bottom-left of 960x540.
        const worldX = cx - 480;
        const worldY = cy - 270;
        node.setPosition(new Vec3(worldX, worldY, 0));
        const ui = node.getComponent(UITransform);
        if (ui) ui.setAnchorPoint(new Vec2(0.5, 0.5));
    }

    /** Map a Cocos `Touch` object to a stable numeric id we can track. */
    private readonly lastStartTs = new Map<number, number>();
    private touchId(ev: EventTouch): number {
        const touch: Touch | null = ev.touch;
        const id = touch ? touch.getID() : 0;
        if (!this.lastStartTs.has(id)) {
            this.lastStartTs.set(id, FloatingControlLayer.now());
        }
        return id;
    }

    /** Capture input→emit latency into the perf metric store (req 20.1). */
    private recordLatency(name: string, start: number): void {
        const elapsed = FloatingControlLayer.now() - start;
        globalLogger.metric({ name, value: elapsed });
        if (elapsed > PERF_TOUCH_RESPONSE_MAX_MS) {
            globalLogger.warn(
                'Input',
                `latency ${elapsed.toFixed(1)}ms exceeds ${PERF_TOUCH_RESPONSE_MAX_MS}ms target`
            );
        }
    }

    private static now(): number {
        return typeof performance !== 'undefined' && typeof performance.now === 'function'
            ? performance.now()
            : Date.now();
    }
}
