import { _decorator, Component, director, Color, Graphics, Label, Node, Sprite, UITransform, Vec3 } from 'cc';
import { ConfigMgr } from '../data/ConfigMgr';
import { CCJsonLoader } from './CCJsonLoader';
import { LevelMgr } from '../logic/LevelMgr';
import {
    ensureCanvasSize,
    createLabel,
    createSprite,
    createSpriteSheetFrame,
} from './MainMenuEntry';
import {
    DESIGN_WIDTH,
    DESIGN_HEIGHT,
    PlayerColorState,
} from '../common/Constants';
import { globalEventBus, globalTimeMgr } from '../common/index';
import { PlayerMotionModel, HorizontalInput, IPlatform } from '../logic/PlayerMotionModel';
import { JumpController } from '../logic/JumpController';
import { PlayerStateMachine } from '../logic/PlayerStateMachine';
import { AttackController, IJumpStateProvider, IAttackDispatchEvent } from '../logic/AttackController';
import { EnemyAIBase, EnemyManager, QingRenAI, ChiRenAI, HeiRenAI, YaoFangAI, IEnemyAction, ICullingRect, ReinforcementScheduler, IReinforcementSpawn } from '../logic/EnemyAI';
import { CameraScroller, cameraFromLevel, PARALLAX_LAYERS, ParallaxLayer } from '../logic/CameraScroller';
import { DamageSystem } from '../logic/DamageSystem';
import { InputEvents } from '../ui/InputEvents';
import { JoystickAngleClass, DEFAULT_LAYOUT } from '../ui/InputModel';
import { FloatingControlLayer } from '../ui/FloatingControlLayer';
import { EnemyType, WeaponType, IReinforcementRule } from '../data/Interfaces';

const { ccclass, property } = _decorator;

// ---------------------------------------------------------------------------
// Coordinate conventions
// ---------------------------------------------------------------------------
// PlayerMotionModel uses +y-up (physics).  Cocos Creator uses +y-down
// (render).  The design resolution origin is at the centre of the 960×540
// canvas.  We convert: cocosY = -physicsY  (relative to canvas centre).
// ---------------------------------------------------------------------------

/** Ground platform Y in physics coords (+y up). */
const GROUND_Y = 110;
/** Hero spawn X in physics coords. */
const HERO_SPAWN_X = 120;

/** Visual scale applied to the hero sprite (16×32 → 48×96). */
const HERO_SCALE = 3;
/** Visual scale applied to enemy sprites (16×16 → 48×48). */
const ENEMY_SCALE = 3;

/**
 * Map an EnemyType to its sprite-sheet resource path and frame dimensions.
 */
function enemySpriteInfo(type: EnemyType): { res: string; fw: number; fh: number } {
    switch (type) {
        case EnemyType.QingRen:  return { res: 'textures/enemies/qing_ren', fw: 16, fh: 16 };
        case EnemyType.ChiRen:   return { res: 'textures/enemies/chi_ren',  fw: 16, fh: 16 };
        case EnemyType.HeiRen:   return { res: 'textures/enemies/hei_ren',  fw: 20, fh: 24 };
        case EnemyType.YaoFang:  return { res: 'textures/enemies/yao_fang', fw: 18, fh: 20 };
        default:                 return { res: 'textures/enemies/qing_ren', fw: 16, fh: 16 };
    }
}

/** Map an EnemyType enum to the concrete AI class. */
function createEnemyAI(type: EnemyType, cfg: { moveSpeed: number; attackIntervalSec: number; size: { w: number; h: number } }, spawnX: number, spawnY: number): EnemyAIBase {
    const fullCfg = { id: type, displayName: type, size: cfg.size, moveSpeed: cfg.moveSpeed, attackIntervalSec: cfg.attackIntervalSec, attacks: [] as never[], hp: 1 };
    switch (type) {
        case EnemyType.ChiRen:   return new ChiRenAI(fullCfg, spawnX, spawnY);
        case EnemyType.HeiRen:   return new HeiRenAI(fullCfg, spawnX, spawnY);
        case EnemyType.YaoFang:  return new YaoFangAI(fullCfg, spawnX, spawnY);
        case EnemyType.QingRen:
        default:                 return new QingRenAI(fullCfg, spawnX, spawnY);
    }
}

// ---------------------------------------------------------------------------
// JumpStateProvider — bridges JumpController ↔ PlayerMotionModel
// ---------------------------------------------------------------------------
class JumpStateProvider implements IJumpStateProvider {
    private _lastJumpPressTs: number | undefined;

    public setLastJumpPressTs(ts: number | undefined): void {
        this._lastJumpPressTs = ts;
    }

    public lastJumpPressTs(): number | undefined {
        return this._lastJumpPressTs;
    }

    constructor(private readonly motion: PlayerMotionModel) {}

    public isGrounded(): boolean {
        return this.motion.isGrounded;
    }
}

/**
 * Generic Level scene entry (task 7.1-7.2 hookup).
 *
 * Now fully wired: input events → logic models → scene node positions,
 * so both the player and enemies move in real-time.
 */
@ccclass('LevelEntry')
export class LevelEntry extends Component {
    @property({ tooltip: '本关的 levelId (与 configs/levels.json 对应)，如 1-1 / 1-2 / 1-3 / 1-4 / 1-5' })
    public levelId: string = '1-1';

    @property({ tooltip: '胜利后跳转的场景，留空则按 1-1 → 1-2 → ... → 1-5 → Boss 自动推导' })
    public nextSceneName: string = '';

    @property({ tooltip: '是否自动创建关卡 HUD (顶部显示 Level / 倒计时)' })
    public autoBuildUI: boolean = true;

    // -- logic models -------------------------------------------------------
    private mgr!: LevelMgr;
    private cfg!: ConfigMgr;
    private motion!: PlayerMotionModel;
    private jumpCtrl!: JumpController;
    private psm!: PlayerStateMachine;
    private attackCtrl!: AttackController;
    private jumpStateProvider!: JumpStateProvider;
    private enemyMgr!: EnemyManager;
    private camera!: CameraScroller;
    private dmgSystem!: DamageSystem;
    private reinforcementScheduler!: ReinforcementScheduler;
    private levelElapsedSec = 0;

    // -- view nodes ---------------------------------------------------------
    private hudNode: Node | null = null;
    private heroNode: Node | null = null;
    /** Enemy AI → visual node mapping. */
    private enemyNodes = new Map<EnemyAIBase, Node>();
    /** Parallax background nodes keyed by layer name. */
    private bgNodes = new Map<string, Node>();
    /** Projectile visual nodes (shuriken, fireball, etc.). */
    private projectileNodes: Node[] = [];

    // -- input state --------------------------------------------------------
    private currentJoystickKlass: JoystickAngleClass = 'none';
    private currentHorizontalInput: HorizontalInput = 0;

    // -- event handler refs (for cleanup) -----------------------------------
    private boundHandlers: Array<[string, (payload: any) => void]> = [];
    private _deathHandled = false;
    /** Reference to the FloatingControlLayer container node — must remain the
     * top-most sibling so dynamically-added sprites (projectiles, slashes,
     * re-created hero) never occlude touch input. */
    private ctrlLayerNode: Node | null = null;

    protected async onLoad(): Promise<void> {
        // 1. Load configs
        this.cfg = new ConfigMgr(new CCJsonLoader());
        await this.cfg.load();
        const levelCfg = this.cfg.level(this.levelId);

        // 0. Reset global singletons that survive across scene loads.
        //    Placed AFTER await so update()-driven accumulation during the
        //    async gap does not negate the reset.
        globalTimeMgr.reset();

        this.mgr = new LevelMgr(levelCfg);

        // 2. Build platforms (simple ground + level-specific platforms)
        const platforms = this.buildPlatforms();

        // 3. Create logic models
        // AABB.y is the **centre** of the box; feet must align with the
        // ground surface (topY = GROUND_Y), so centre = GROUND_Y + h/2.
        const HERO_H = 32;
        this.motion = new PlayerMotionModel({
            aabb: { x: HERO_SPAWN_X, y: GROUND_Y + HERO_H / 2, w: 16, h: HERO_H },
            platforms,
            initialColorState: PlayerColorState.Red,
            levelLengthPx: levelCfg.levelLengthPx,
        });
        this.psm = new PlayerStateMachine(1);
        this.jumpStateProvider = new JumpStateProvider(this.motion);
        this.jumpCtrl = new JumpController(this.motion);
        this.attackCtrl = new AttackController(this.jumpStateProvider);
        this.dmgSystem = new DamageSystem(this.psm);
        this.camera = cameraFromLevel(levelCfg);
        this.enemyMgr = new EnemyManager();

        // 4. Spawn enemies from level config
        for (const spawn of levelCfg.enemySpawns) {
            const enemyCfg = this.cfg.enemy(spawn.type);
            // Enemy pos.y = centre; feet must sit on ground (topY = GROUND_Y).
            const spawnY = GROUND_Y + enemyCfg.size.h / 2;
            const count = spawn.count ?? 1;
            for (let i = 0; i < count; i++) {
                const ai = createEnemyAI(spawn.type, enemyCfg, spawn.atPx + i * 60, spawnY);
                this.enemyMgr.spawn(ai);
            }
        }

        // 4b. Initialize reinforcement scheduler from level config
        this.reinforcementScheduler = new ReinforcementScheduler(levelCfg.reinforcements ?? []);

        // 5. Build UI & sprites
        if (this.autoBuildUI) this.buildDefaultUI();

        // 6. Add FloatingControlLayer
        this.addControlLayer();

        // 7. Subscribe to input events
        this.subscribeInputEvents();
    }

    protected update(dt: number): void {
        // 1. Update time
        globalTimeMgr.update(dt);
        const scaledDt = globalTimeMgr.scaledDelta(dt);
        if (scaledDt <= 0) return; // paused

        // Guard: onLoad is async — models may not be ready yet.
        if (!this.attackCtrl || !this.psm) return;

        // 11. Early-out: if the player died in a previous frame, skip all
        //     gameplay logic (motion, attacks, enemies) and go straight to
        //     the death → scene-transition path.
        if (this.psm.isDead) {
            if (!this._deathHandled) {
                const deathStatus = this.mgr.tick(scaledDt);
                this.refreshHud();
                if (deathStatus === 'player_dead') {
                    this._deathHandled = true;
                    this.attackCtrl.reset();
                    this.unsubscribeInputEvents();
                    director.loadScene('Settlement');
                }
                // If deathStatus is still 'running', onPlayerDied() was just
                // called in the previous frame's step-11 but LevelMgr hasn't
                // propagated the state yet. _deathHandled stays false so we
                // retry next frame.
            }
            return;
        }

        const nowMs = globalTimeMgr.realTime * 1000;

        // 2. Tick player motion FIRST so that isGrounded is up-to-date
        //    when jumpCtrl.tick reads it (fixes "cannot jump after landing"
        //    bug — jumpCtrl was reading stale isGrounded from previous frame).
        this.motion.setHorizontalInput(this.currentHorizontalInput);
        this.motion.update(scaledDt);
        this.jumpCtrl.tick(nowMs);

        // 3. Tick player state machine (i-frames)
        this.psm.tick(scaledDt);

        // 4. Tick attack controller
        const attacks = this.attackCtrl.tick(nowMs, this.psm.color);

        // 5. Tick enemies
        const playerSense = {
            x: this.motion.aabb.x,
            y: this.motion.aabb.y,
            isGrounded: this.motion.isGrounded,
        };
        const cull = this.camera.cullRect();
        const CULLING_MARGIN = 100; // allow enemies just outside screen to still update AI (reinforcements)
        const enemyActions = this.enemyMgr.update(scaledDt, nowMs, playerSense, cull as ICullingRect, CULLING_MARGIN);

        // 6. Process enemy actions (spawn projectiles, etc.)
        this.processEnemyActions(enemyActions);

        // 6b. Tick reinforcement scheduler — spawn enemies from screen edges
        this.levelElapsedSec += scaledDt;
        const newReinforcements = this.reinforcementScheduler.tick(
            scaledDt,
            this.levelElapsedSec,
            cull as ICullingRect,
            GROUND_Y,
            (type: EnemyType) => this.cfg.enemy(type),
        );
        for (const r of newReinforcements) {
            this.enemyMgr.spawn(r.enemy);
            // Create visual node for the reinforcement enemy
            const info = enemySpriteInfo(r.enemy.type);
            const ePos = this.physicsToCocos(r.enemy.pos.x, r.enemy.pos.y);
            const node = createSpriteSheetFrame(
                this.node,
                info.res,
                0,
                info.fw, info.fh,
                ePos.x, ePos.y,
                info.fw * ENEMY_SCALE, info.fh * ENEMY_SCALE,
                { name: `Reinforce_${r.enemy.type}`, flipX: r.edge === 'right' },
            );
            this.enemyNodes.set(r.enemy, node);
            this.promoteCtrlLayerToTop();
        }

        // 7. Process player attacks (spawn projectiles)
        this.processPlayerAttacks(attacks);

        // 8. Camera follow
        this.camera.followPlayer(this.motion.aabb.x, this.motion.aabb.y);

        // 9. Tick level manager
        const status = this.mgr.tick(scaledDt);

        // 10. Sync all visual nodes
        this.syncHeroNode();
        this.syncEnemyNodes();
        this.syncParallax();
        this.syncProjectiles(scaledDt);
        this.refreshHud();

        // Keep the FloatingControlLayer as the top-most sibling so touch
        // events on attack/jump buttons are never shadowed by dynamically
        // spawned sprites (projectiles / slash FX / re-created hero).
        this.promoteCtrlLayerToTop();

        // 11. Check game-over conditions
        if (this.psm.isDead) {
            this.mgr.onPlayerDied();
        }
        if (status === 'victory') {
            this.unsubscribeInputEvents();
            director.loadScene(this.nextSceneName || this.deriveNextScene());
        } else if (status === 'timeout' || status === 'player_dead') {
            this.unsubscribeInputEvents();
            director.loadScene('Settlement');
        }
    }

    protected onDestroy(): void {
        this.attackCtrl?.reset();
        this.unsubscribeInputEvents();
    }

    // ======================================================================
    // Platform construction
    // ======================================================================

    private buildPlatforms(): IPlatform[] {
        const platforms: IPlatform[] = [];
        // Ground platform — spans the entire level length
        const levelCfg = this.cfg.level(this.levelId);
        platforms.push({
            topY: GROUND_Y,
            leftX: 0,
            rightX: levelCfg.levelLengthPx,
        });
        // TODO: Add elevated platforms per level layout when available.
        return platforms;
    }

    // ======================================================================
    // UI construction
    // ======================================================================

    private buildDefaultUI(): void {
        ensureCanvasSize(this.node);

        // Parallax background (4 layers).
        const theme = this.pickThemeFolder();
        for (const layer of PARALLAX_LAYERS) {
            const node = createSprite(
                this.node,
                `textures/scenes/${theme}/${layer}`,
                0, 0,
                DESIGN_WIDTH, DESIGN_HEIGHT,
                { name: `BG_${layer}` },
            );
            this.bgNodes.set(layer, node);
        }

        // Hero sprite (first idle frame of kage_red, 16×32 upscaled ×3).
        const heroPos = this.physicsToCocos(this.motion.aabb.x, this.motion.aabb.y);
        this.heroNode = createSpriteSheetFrame(
            this.node,
            'textures/characters/kage_red',
            0,
            16, 32,
            heroPos.x, heroPos.y,
            16 * HERO_SCALE, 32 * HERO_SCALE,
            { name: 'Hero' },
        );

        // Enemy sprites — one per spawned enemy.
        for (const enemy of this.enemyMgr.all) {
            const info = enemySpriteInfo(enemy.type);
            const ePos = this.physicsToCocos(enemy.pos.x, enemy.pos.y);
            const node = createSpriteSheetFrame(
                this.node,
                info.res,
                0,
                info.fw, info.fh,
                ePos.x, ePos.y,
                info.fw * ENEMY_SCALE, info.fh * ENEMY_SCALE,
                { name: `Enemy_${enemy.type}`, flipX: true },
            );
            this.enemyNodes.set(enemy, node);
        }

        // HUD text.
        createLabel(this.node, `Level ${this.levelId}`, 0, DESIGN_HEIGHT / 2 - 50, 28, Color.WHITE);
        this.hudNode = createLabel(
            this.node,
            'Time: --',
            0,
            DESIGN_HEIGHT / 2 - 90,
            22,
            new Color(255, 220, 120, 255),
        );
    }

    /** Add the FloatingControlLayer component on a full-screen UI child node. */
    private addControlLayer(): void {
        console.log('[LevelEntry] addControlLayer start — this.node=', this.node?.name, 'isValid=', this.node?.isValid);
        const ctrlNode = new Node('FloatingControlLayer');
        ctrlNode.layer = this.node.layer;
        this.node.addChild(ctrlNode);
        this.ctrlLayerNode = ctrlNode;
        const ut = ctrlNode.addComponent(UITransform);
        ut.setContentSize(DESIGN_WIDTH, DESIGN_HEIGHT);
        ctrlNode.setPosition(new Vec3(0, 0, 0));

        // Create visual sub-nodes for joystick and buttons.
        const fcl = ctrlNode.addComponent(FloatingControlLayer);
        console.log('[LevelEntry] addControlLayer — FloatingControlLayer component added. fcl=', !!fcl, 'ctrlNode.isValid=', ctrlNode.isValid);
        const layout = DEFAULT_LAYOUT;

        // Joystick: background disc + handle.
        fcl.joystickRoot = this.createJoystickVisual(ctrlNode, layout.joystick);
        // Jump button.
        fcl.jumpRoot = this.createButtonVisual(ctrlNode, layout.jump, 'J', new Color(80, 200, 80, 180));
        // Shuriken button.
        fcl.shurikenRoot = this.createButtonVisual(ctrlNode, layout.shuriken, 'S', new Color(80, 120, 220, 180));
        // Ninja-sword button.
        fcl.ninjaSwordRoot = this.createButtonVisual(ctrlNode, layout.ninjaSword, 'K', new Color(220, 80, 80, 180));
        console.log('[LevelEntry] addControlLayer done — visuals built');
    }

    /**
     * Ensure the FloatingControlLayer is always the top-most sibling of
     * `this.node`. Dynamically-spawned sprites (projectiles / slash FX /
     * re-created hero sprite) are appended to the end of the sibling list
     * by default, pushing the ctrl layer down the Z-order. When that
     * happens, touch events on attack/jump buttons can be intercepted by
     * those sprites' UITransform bounding boxes — producing the
     * "buttons occasionally unresponsive" symptom.
     *
     * Called in `update()` every frame as a cheap safety net.
     */
    private promoteCtrlLayerToTop(): void {
        const ctrl = this.ctrlLayerNode as any;
        if (!ctrl || !ctrl.isValid) return;
        const parent = ctrl.parent;
        if (!parent) return;
        const lastIndex = parent.children.length - 1;
        if (ctrl.getSiblingIndex() !== lastIndex) {
            ctrl.setSiblingIndex(lastIndex);
        }
    }

    /** Create a joystick visual (semi-transparent disc + smaller handle). */
    private createJoystickVisual(parent: Node, rect: { cx: number; cy: number; w: number; h: number }): Node {
        const root = new Node('JoystickRoot');
        root.layer = parent.layer;
        parent.addChild(root);
        const rootUt = root.addComponent(UITransform);
        rootUt.setContentSize(rect.w, rect.h);
        root.setPosition(new Vec3(rect.cx - DESIGN_WIDTH / 2, rect.cy - DESIGN_HEIGHT / 2, 0));

        // Background disc.
        const bg = new Node('JoystickBg');
        bg.layer = parent.layer;
        root.addChild(bg);
        const bgUt = bg.addComponent(UITransform);
        const bgRadius = rect.w / 2;
        bgUt.setContentSize(rect.w, rect.h);
        const bgGfx = bg.addComponent(Graphics);
        bgGfx.fillColor = new Color(255, 255, 255, 50);
        bgGfx.strokeColor = new Color(255, 255, 255, 100);
        bgGfx.circle(0, 0, bgRadius);
        bgGfx.fill();
        bgGfx.stroke();

        // Handle (smaller circle, offset to show direction).
        const handle = new Node('JoystickHandle');
        handle.layer = parent.layer;
        root.addChild(handle);
        const handleRadius = bgRadius * 0.4;
        const handleUt = handle.addComponent(UITransform);
        handleUt.setContentSize(handleRadius * 2, handleRadius * 2);
        const handleGfx = handle.addComponent(Graphics);
        handleGfx.fillColor = new Color(255, 255, 255, 140);
        handleGfx.strokeColor = new Color(255, 255, 255, 200);
        handleGfx.circle(0, 0, handleRadius);
        handleGfx.fill();
        handleGfx.stroke();

        return root;
    }

    /** Create a simple circular button with a text label. */
    private createButtonVisual(
        parent: Node,
        rect: { cx: number; cy: number; w: number; h: number },
        label: string,
        tint: Color,
    ): Node {
        const root = new Node(`Btn_${label}`);
        root.layer = parent.layer;
        parent.addChild(root);
        const radius = Math.min(rect.w, rect.h) / 2;
        const rootUt = root.addComponent(UITransform);
        rootUt.setContentSize(rect.w, rect.h);
        root.setPosition(new Vec3(rect.cx - DESIGN_WIDTH / 2, rect.cy - DESIGN_HEIGHT / 2, 0));

        // Circle background.
        const bg = new Node('Bg');
        bg.layer = parent.layer;
        root.addChild(bg);
        const bgUt = bg.addComponent(UITransform);
        bgUt.setContentSize(rect.w, rect.h);
        const bgGfx = bg.addComponent(Graphics);
        bgGfx.fillColor = tint;
        bgGfx.strokeColor = new Color(255, 255, 255, 120);
        bgGfx.circle(0, 0, radius);
        bgGfx.fill();
        bgGfx.stroke();

        // Text label.
        const lblNode = new Node('Label');
        lblNode.layer = parent.layer;
        root.addChild(lblNode);
        const lblUt = lblNode.addComponent(UITransform);
        lblUt.setContentSize(rect.w, rect.h);
        const lbl = lblNode.addComponent(Label);
        lbl.string = label;
        lbl.fontSize = 28;
        lbl.lineHeight = 28;
        lbl.color = new Color(255, 255, 255, 220);

        return root;
    }

    // ======================================================================
    // Input event subscription
    // ======================================================================

    private subscribeInputEvents(): void {
        // Defensive: clear any stale handlers left by a previous LevelEntry
        // instance that was destroyed before unsubscribeInputEvents() ran.
        // Only clear the events that LevelEntry registers — not ButtonVisualChanged
        // or other events that might be registered by other components.
        const eventsToClear = [
            InputEvents.JoystickMove,
            InputEvents.JumpPressed,
            InputEvents.JumpReleased,
            InputEvents.ShurikenPressed,
            InputEvents.ShurikenReleased,
            InputEvents.NinjaSwordPressed,
            InputEvents.NinjaSwordReleased,
        ];
        for (const event of eventsToClear) {
            globalEventBus.off(event);
        }

        const onJoystickMove = (payload: { dx: number; dy: number; klass: JoystickAngleClass }) => {
            console.log('[LevelEntry] JoystickMove:', payload.klass);
            this.currentJoystickKlass = payload.klass;
            if (payload.klass === 'none') {
                this.currentHorizontalInput = 0;
            } else if (payload.dx >= 0) {
                this.currentHorizontalInput = 1;
            } else {
                this.currentHorizontalInput = -1;
            }
        };

        const onJumpPressed = () => {
            const nowMs = globalTimeMgr.realTime * 1000;
            console.log('[LevelEntry] JumpPressed at nowMs=', nowMs, 'realTime=', globalTimeMgr.realTime);
            this.jumpStateProvider.setLastJumpPressTs(nowMs);
            this.jumpCtrl.pressJump(nowMs);
        };

        const onJumpReleased = (payload: { holdMs: number }) => {
            const nowMs = globalTimeMgr.realTime * 1000;
            console.log('[LevelEntry] JumpReleased holdMs=', payload.holdMs);
            this.jumpCtrl.releaseJump(nowMs, this.currentJoystickKlass, this.psm.color);
        };

        const onShurikenPressed = () => {
            const nowMs = globalTimeMgr.realTime * 1000;
            console.log('[LevelEntry] ShurikenPressed at nowMs=', nowMs, 'active=', this.attackCtrl.getActive(), 'pressed=', this.attackCtrl.isPressed(WeaponType.Shuriken));
            this.attackCtrl.press(WeaponType.Shuriken, nowMs);
        };

        const onShurikenReleased = () => {
            console.log('[LevelEntry] ShurikenReleased');
            this.attackCtrl.release(WeaponType.Shuriken);
        };

        const onNinjaSwordPressed = () => {
            const nowMs = globalTimeMgr.realTime * 1000;
            console.log('[LevelEntry] NinjaSwordPressed at nowMs=', nowMs);
            this.attackCtrl.press(WeaponType.NinjaSword, nowMs);
        };

        const onNinjaSwordReleased = () => {
            console.log('[LevelEntry] NinjaSwordReleased');
            this.attackCtrl.release(WeaponType.NinjaSword);
        };

        const handlers: Array<[string, (payload: any) => void]> = [
            [InputEvents.JoystickMove, onJoystickMove],
            [InputEvents.JumpPressed, onJumpPressed],
            [InputEvents.JumpReleased, onJumpReleased],
            [InputEvents.ShurikenPressed, onShurikenPressed],
            [InputEvents.ShurikenReleased, onShurikenReleased],
            [InputEvents.NinjaSwordPressed, onNinjaSwordPressed],
            [InputEvents.NinjaSwordReleased, onNinjaSwordReleased],
        ];

        for (const [event, handler] of handlers) {
            globalEventBus.on(event, handler);
            this.boundHandlers.push([event, handler]);
        }
        console.log('[LevelEntry] subscribeInputEvents done — registered', handlers.length, 'handlers. Listener counts:',
            Object.values(InputEvents).map(e => `${e}=${globalEventBus.listenerCount(e)}`).join(', '));
    }

    private unsubscribeInputEvents(): void {
        for (const [event, handler] of this.boundHandlers) {
            globalEventBus.off(event, handler);
        }
        this.boundHandlers.length = 0;
    }

    // ======================================================================
    // Visual sync — physics coords → Cocos node positions
    // ======================================================================

    /** Convert physics world coords (+y up) to Cocos node position (centred at 0,0). */
    private physicsToCocos(physX: number, physY: number): Vec3 {
        return new Vec3(
            physX - DESIGN_WIDTH / 2 - this.camera.offsetX,
            physY - DESIGN_HEIGHT / 2 - this.camera.offsetY,
            0,
        );
    }

    private syncHeroNode(): void {
        if (!this.heroNode || !this.heroNode.isValid) return;
        const pos = this.physicsToCocos(this.motion.aabb.x, this.motion.aabb.y);
        this.heroNode.setPosition(pos);

        // Flip sprite based on horizontal velocity.
        if (this.motion.vx > 0) {
            this.heroNode.setScale(Math.abs(this.heroNode.scale.x), this.heroNode.scale.y, this.heroNode.scale.z);
        } else if (this.motion.vx < 0) {
            this.heroNode.setScale(-Math.abs(this.heroNode.scale.x), this.heroNode.scale.y, this.heroNode.scale.z);
        }

        // Update color-state texture.
        this.updateHeroTexture();
    }

    private _lastColorState: PlayerColorState = PlayerColorState.Red;
    private updateHeroTexture(): void {
        if (this.psm.color === this._lastColorState) return;
        this._lastColorState = this.psm.color;
        if (!this.heroNode || !this.heroNode.isValid) return;

        const texMap: Record<PlayerColorState, string> = {
            [PlayerColorState.Red]:    'textures/characters/kage_red',
            [PlayerColorState.Green]:  'textures/characters/kage_green',
            [PlayerColorState.Yellow]: 'textures/characters/kage_yellow',
        };
        // Recreate the hero node with the new texture.
        // Use the physics model position directly (avoids CC 3.x Node.position getter issues).
        const cocosPos = this.physicsToCocos(this.motion.aabb.x, this.motion.aabb.y);
        const facingRight = this.motion.vx >= 0;
        this.heroNode.destroy();
        this.heroNode = createSpriteSheetFrame(
            this.node,
            texMap[this.psm.color],
            0,
            16, 32,
            cocosPos.x, cocosPos.y,
            16 * HERO_SCALE, 32 * HERO_SCALE,
            { name: 'Hero' },
        );
        if (!facingRight) {
            this.heroNode.setScale(-HERO_SCALE, HERO_SCALE, HERO_SCALE);
        }
        // Newly-added hero sprite would otherwise sit on top of the ctrl
        // layer and shadow touch input — promote ctrl layer back to top.
        this.promoteCtrlLayerToTop();
    }

    private syncEnemyNodes(): void {
        for (const [enemy, node] of this.enemyNodes) {
            if (!node.isValid || !enemy.alive) {
                if (node.isValid) {
                    node.active = false;
                }
                continue;
            }
            const pos = this.physicsToCocos(enemy.pos.x, enemy.pos.y);
            node.setPosition(pos);
        }
    }

    private syncParallax(): void {
        for (const layer of PARALLAX_LAYERS) {
            const node = this.bgNodes.get(layer);
            if (!node || !node.isValid) continue;
            const offset = this.camera.offsetForLayer(layer);
            // Parallax scroll: camera moves right → bg shifts left; camera moves up → bg shifts down.
            node.setPosition(new Vec3(-offset.x, -offset.y, 0));
        }
    }

    // ======================================================================
    // Projectile handling (visual only — spawn & move)
    // ======================================================================

    private processEnemyActions(actions: IEnemyAction[]): void {
        for (const action of actions) {
            if (action.kind === 'fire_bullet' && action.originX !== undefined && action.originY !== undefined) {
                this.spawnProjectile(
                    action.attackType ?? 'shuriken',
                    action.originX,
                    action.originY,
                    action.velX ?? 0,
                    action.velY ?? 0,
                    true, // enemy projectile → red tint
                );
            }
        }
    }

    private processPlayerAttacks(attacks: IAttackDispatchEvent[]): void {
        for (const atk of attacks) {
            const heroX = this.motion.aabb.x;
            const heroY = this.motion.aabb.y;
            const direction = this.motion.vx >= 0 ? 1 : -1;

            if (atk.weapon === WeaponType.Shuriken) {
                this.spawnProjectile(
                    'shuriken',
                    heroX + direction * 20,
                    heroY + 10,
                    direction * 350,
                    0,
                    false, // player projectile → white tint
                );
            } else if (atk.weapon === WeaponType.NinjaSword) {
                // Sword is melee — check hit against enemies directly.
                this.processSwordHit(heroX, heroY, direction);
            }
        }
    }

    private processSwordHit(heroX: number, heroY: number, direction: number): void {
        const swordRange = 50;
        const weaponCfg = this.cfg.weapon(WeaponType.NinjaSword);
        for (const enemy of this.enemyMgr.all) {
            if (!enemy.alive) continue;
            const dx = enemy.pos.x - heroX;
            const dy = enemy.pos.y - heroY;
            if (Math.abs(dx) <= swordRange && Math.abs(dy) <= 40 && (direction >= 0 ? dx >= 0 : dx <= 0)) {
                const remainingHp = this.dmgSystem.applyToEnemy(enemy.hp, weaponCfg.damage);
                enemy.hp = remainingHp;
                if (remainingHp <= 0) {
                    this.enemyMgr.kill(enemy);
                    this.mgr.onEnemyKilled(enemy.type);
                    // Hide the visual node.
                    const node = this.enemyNodes.get(enemy);
                    if (node && node.isValid) node.active = false;
                }
            }
        }
        // Sword active parry window (req 3.7-3.8).
        this.psm.setSwordActive(true);
        // Deactivate after a short delay (handled by next frame reset).
        setTimeout(() => this.psm.setSwordActive(false), 200);

        // Visual feedback: brief slash arc in front of the hero.
        this.showSwordSlash(heroX, heroY, direction);
    }

    /** Show a brief slash flash in front of the hero for visual feedback. */
    private showSwordSlash(physX: number, physY: number, direction: number): void {
        const pos = this.physicsToCocos(physX + direction * 35, physY);
        const slashNode = new Node('SwordSlash');
        slashNode.layer = this.node.layer;
        this.node.addChild(slashNode);
        const ut = slashNode.addComponent(UITransform);
        ut.setContentSize(40, 50);
        slashNode.setPosition(new Vec3(pos.x, pos.y, 0));
        const sp = slashNode.addComponent(Sprite);
        sp.sizeMode = Sprite.SizeMode.CUSTOM;
        sp.color = new Color(255, 255, 220, 200);
        // Restore ctrl-layer Z-order after appending the slash sprite.
        this.promoteCtrlLayerToTop();
        // Auto-remove after 120ms.
        setTimeout(() => {
            if (slashNode.isValid) {
                slashNode.removeFromParent();
                slashNode.destroy();
            }
        }, 120);
    }

    private spawnProjectile(
        attackType: string,
        x: number, y: number,
        velX: number, velY: number,
        isEnemy: boolean,
    ): void {
        const resPath = attackType === 'fireball'
            ? 'textures/fx/parry_spark'   // reuse spark as fireball placeholder
            : attackType === 'smoke_bomb'
                ? 'textures/fx/jump_dust'  // reuse dust as smoke placeholder
                : isEnemy
                    ? 'textures/enemies/qing_ren'  // enemy shuriken uses enemy sheet
                    : 'textures/characters/kage_red'; // player shuriken uses hero sheet
        const projSize = attackType === 'smoke_bomb' ? 24 : 20;
        const frameSize = 16;
        const node = createSpriteSheetFrame(
            this.node,
            resPath,
            0,
            frameSize, frameSize,
            0, 0,   // position set in syncProjectiles
            projSize, projSize,
            { name: `Projectile_${attackType}`, flipX: isEnemy },
        );
        // Store physics state on the node's userData for movement tracking.
        (node as any)._projState = { x, y, velX, velY, isEnemy, attackType, alive: true };
        this.projectileNodes.push(node);
        // Restore ctrl-layer Z-order after appending the projectile sprite.
        this.promoteCtrlLayerToTop();
    }

    private syncProjectiles(dt: number): void {
        const toRemove: number[] = [];
        for (let i = 0; i < this.projectileNodes.length; i++) {
            const node = this.projectileNodes[i];
            const state = (node as any)._projState;
            if (!state || !state.alive || !node.isValid) {
                toRemove.push(i);
                continue;
            }
            // Move the projectile.
            state.x += state.velX * dt;
            state.y += state.velY * dt;
            const pos = this.physicsToCocos(state.x, state.y);
            node.setPosition(pos);

            // Check bounds — remove if far outside the camera's culling rect.
            const cull = this.camera.cullRect();
            const margin = 200;
            if (state.x < cull.leftX - margin || state.x > cull.rightX + margin ||
                state.y < cull.bottomY - margin || state.y > cull.topY + margin) {
                state.alive = false;
                toRemove.push(i);
                continue;
            }

            // Check collision.
            if (state.isEnemy) {
                // Enemy projectile → check against player.
                const dx = state.x - this.motion.aabb.x;
                const dy = state.y - this.motion.aabb.y;
                if (Math.abs(dx) < 20 && Math.abs(dy) < 30) {
                    const outcome = this.dmgSystem.applyToPlayer({
                        attackType: state.attackType as any,
                        attackerX: state.x,
                        attackerY: state.y,
                        victimX: this.motion.aabb.x,
                        victimY: this.motion.aabb.y,
                    });
                    // Only destroy the projectile if it actually dealt damage
                    // (downgraded or died). On 'no_effect' (i-frames / parry),
                    // let the projectile fly through the player unharmed.
                    if (outcome && outcome.kind !== 'no_effect') {
                        state.alive = false;
                        toRemove.push(i);
                    }
                }
            } else {
                // Player projectile → check against enemies.
                for (const enemy of this.enemyMgr.all) {
                    if (!enemy.alive) continue;
                    const dx = state.x - enemy.pos.x;
                    const dy = state.y - enemy.pos.y;
                    if (Math.abs(dx) < 20 && Math.abs(dy) < 20) {
                        const weaponCfg = this.cfg.weapon(WeaponType.Shuriken);
                        const remainingHp = this.dmgSystem.applyToEnemy(enemy.hp, weaponCfg.damage);
                        enemy.hp = remainingHp;
                        if (remainingHp <= 0) {
                            this.enemyMgr.kill(enemy);
                            this.mgr.onEnemyKilled(enemy.type);
                            const eNode = this.enemyNodes.get(enemy);
                            if (eNode && eNode.isValid) eNode.active = false;
                        }
                        state.alive = false;
                        toRemove.push(i);
                        break;
                    }
                }
            }
        }
        // Remove dead projectiles (iterate in reverse).
        for (let i = toRemove.length - 1; i >= 0; i--) {
            const idx = toRemove[i];
            const node = this.projectileNodes[idx];
            if (node && node.isValid) {
                node.removeFromParent();
                node.destroy();
            }
            this.projectileNodes.splice(idx, 1);
        }
    }

    // ======================================================================
    // Scene flow
    // ======================================================================

    private deriveNextScene(): string {
        const map: Record<string, string> = {
            '1-1': 'Level_1_2',
            '1-2': 'Level_1_3',
            '1-3': 'Level_1_4',
            '1-4': 'Level_1_5',
            '1-5': 'Boss_ShuangHuanFang',
        };
        return map[this.levelId] ?? 'Settlement';
    }

    private pickThemeFolder(): 'forest' | 'castle_wall' | 'demon_castle' {
        switch (this.levelId) {
            case '1-1':
            case '1-2':
                return 'forest';
            case '1-3':
            case '1-4':
                return 'castle_wall';
            case '1-5':
            default:
                return 'demon_castle';
        }
    }

    private refreshHud(): void {
        if (!this.hudNode || !this.mgr) return;
        const lb = this.hudNode.getComponent(Label);
        if (lb) {
            const r = this.mgr.result();
            const colorStr = this.psm.color.charAt(0).toUpperCase() + this.psm.color.slice(1);
            lb.string = `Time: ${Math.max(0, Math.ceil(r.remainingSec))}s  Kills: ${Object.values(r.kills).reduce((a, b) => a + b, 0)}  Lives: ${this.psm.lives}  [${colorStr}]`;
        }
    }
}
