package com.tiedup.remake.v2.furniture;

import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.ItemMasterKey;
import com.tiedup.remake.v2.bondage.CollarHelper;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.state.PlayerCaptorManager;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.furniture.network.PacketSyncFurnitureState;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.syncher.EntityDataAccessor;
import net.minecraft.network.syncher.EntityDataSerializers;
import net.minecraft.network.syncher.SynchedEntityData;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EntityDimensions;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.Pose;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.entity.IEntityAdditionalSpawnData;
import net.minecraftforge.network.NetworkHooks;
import org.jetbrains.annotations.Nullable;

/**
 * Core furniture entity for all data-driven furniture pieces.
 *
 * <p>Extends {@link Entity} (not LivingEntity) and implements {@link ISeatProvider}
 * for seat management plus {@link IEntityAdditionalSpawnData} for syncing the
 * furniture definition ID to clients on spawn.</p>
 *
 * <p>Behavior varies per instance based on the {@code tiedup_furniture_id} stored
 * in SynchedEntityData, which maps to a {@link FurnitureDefinition} in
 * {@link FurnitureRegistry}. All ISeatProvider calls delegate to the definition
 * with null-safety: if the definition is removed by a data pack reload, the
 * entity degrades gracefully (no seats, default hitbox, no interaction).</p>
 *
 * <p>{@code getAddEntityPacket()} is overridden to return
 * {@link NetworkHooks#getEntitySpawningPacket(Entity)}, which is required for
 * Forge to include the {@code IEntityAdditionalSpawnData} buffer in the spawn
 * packet. Without this override, the entity would be invisible on clients.</p>
 */
public class EntityFurniture
    extends Entity
    implements ISeatProvider, IEntityAdditionalSpawnData
{

    // ========== SynchedEntityData Accessors ==========

    /** The furniture definition ID (ResourceLocation string form). */
    private static final EntityDataAccessor<String> FURNITURE_ID =
        SynchedEntityData.defineId(
            EntityFurniture.class,
            EntityDataSerializers.STRING
        );

    /**
     * Bitmask of locked seats. Bit N corresponds to seat index N
     * in {@link FurnitureDefinition#seats()}. Max 8 seats per furniture.
     */
    private static final EntityDataAccessor<Byte> SEAT_LOCK_BITS =
        SynchedEntityData.defineId(
            EntityFurniture.class,
            EntityDataSerializers.BYTE
        );

    /** Current animation state (idle, occupied, locking, struggle). */
    private static final EntityDataAccessor<Byte> ANIM_STATE =
        SynchedEntityData.defineId(
            EntityFurniture.class,
            EntityDataSerializers.BYTE
        );

    /**
     * Passenger UUID → seat id, serialized as
     * {@code "uuid;seatId|uuid;seatId|..."} (empty string = no assignments).
     * Seat id itself must not contain {@code |} or {@code ;} — furniture
     * definitions use lowercase snake_case which is safe.
     * <p>
     * Server updates this string alongside every {@link #seatAssignments}
     * mutation so clients see the authoritative mapping. Without this, each
     * side independently ran {@code findNearestAvailableSeat} and could
     * diverge on multi-seat furniture (wrong render offset, wrong anim).
     */
    private static final EntityDataAccessor<String> SEAT_ASSIGNMENTS_SYNC =
        SynchedEntityData.defineId(
            EntityFurniture.class,
            EntityDataSerializers.STRING
        );

    // ========== Animation State Constants ==========

    public static final byte STATE_IDLE = 0;
    public static final byte STATE_OCCUPIED = 1;
    public static final byte STATE_LOCKING = 2;
    public static final byte STATE_STRUGGLE = 3;
    public static final byte STATE_UNLOCKING = 4;
    public static final byte STATE_ENTERING = 5;
    public static final byte STATE_EXITING = 6;

    // ========== Server-side State ==========

    /**
     * Maps passenger UUID to their assigned seat ID. Server-authoritative.
     * Persisted in NBT so seat assignments survive chunk unload/reload.
     */
    private final Map<UUID, String> seatAssignments = new HashMap<>();

    /**
     * Accumulated damage from player attacks. Decays slowly per tick.
     * When this reaches the definition's {@code breakResistance}, the furniture breaks.
     */
    private float currentDamage = 0f;

    /**
     * Ticks remaining for a transitional animation state (ENTERING, EXITING, LOCKING, UNLOCKING).
     * When this reaches 0, the animation state is reset to {@link #transitionTargetState}.
     */
    private int transitionTicksLeft = 0;

    /** The state to transition to after the current animation completes. */
    private byte transitionTargetState = STATE_IDLE;

    // ========== Constructor ==========

    public EntityFurniture(EntityType<?> type, Level level) {
        super(type, level);
        // Prevent players from placing blocks inside the furniture's hitbox
        this.blocksBuilding = true;
    }

    // ========== SynchedEntityData Registration ==========

    @Override
    protected void defineSynchedData() {
        // Entity base class registers its own fields before calling this.
        // Direct Entity subclasses do NOT call super (it is abstract).
        this.entityData.define(FURNITURE_ID, "");
        this.entityData.define(SEAT_LOCK_BITS, (byte) 0);
        this.entityData.define(ANIM_STATE, STATE_IDLE);
        this.entityData.define(SEAT_ASSIGNMENTS_SYNC, "");
    }

    // ========== IEntityAdditionalSpawnData ==========

    /**
     * Server-side: write furniture ID and facing to the spawn packet.
     * This is the only reliable way to sync the definition ID to the client
     * before the entity's first render frame.
     */
    @Override
    public void writeSpawnData(FriendlyByteBuf buffer) {
        buffer.writeUtf(getFurnitureId());
        buffer.writeFloat(this.getYRot());
    }

    /**
     * Client-side: read furniture ID and facing from the spawn packet.
     * Called before the entity is added to the client world.
     */
    @Override
    public void readSpawnData(FriendlyByteBuf additionalData) {
        setFurnitureId(additionalData.readUtf());
        this.setYRot(additionalData.readFloat());
        // Recalculate bounding box with the correct definition dimensions
        this.refreshDimensions();
    }

    // ========== Variable Dimensions ==========

    /**
     * Returns dimensions from the furniture definition, falling back to 1x1
     * if the definition is missing (e.g., data pack removed the furniture type).
     */
    @Override
    public EntityDimensions getDimensions(Pose pose) {
        FurnitureDefinition def = getDefinition();
        if (def != null) {
            return EntityDimensions.fixed(
                def.hitboxWidth(),
                def.hitboxHeight()
            );
        }
        return EntityDimensions.fixed(1.0f, 1.0f);
    }

    // ========== ISeatProvider Implementation ==========

    @Override
    public List<SeatDefinition> getSeats() {
        FurnitureDefinition def = getDefinition();
        return def != null ? def.seats() : Collections.emptyList();
    }

    @Override
    @Nullable
    public SeatDefinition getSeatForPassenger(Entity passenger) {
        String seatId = seatAssignments.get(passenger.getUUID());
        if (seatId == null) return null;
        FurnitureDefinition def = getDefinition();
        return def != null ? def.getSeat(seatId) : null;
    }

    @Override
    public void assignSeat(Entity passenger, String seatId) {
        seatAssignments.put(passenger.getUUID(), seatId);
        syncSeatAssignmentsIfServer();
    }

    @Override
    public void releaseSeat(Entity passenger) {
        seatAssignments.remove(passenger.getUUID());
        syncSeatAssignmentsIfServer();
    }

    /** Push seatAssignments into SEAT_ASSIGNMENTS_SYNC. No-op on client. */
    private void syncSeatAssignmentsIfServer() {
        if (this.level().isClientSide) return;
        this.entityData.set(
            SEAT_ASSIGNMENTS_SYNC,
            FurnitureSeatSyncCodec.encode(seatAssignments)
        );
    }

    /** Rebuild seatAssignments from the server's broadcast. */
    private void applySyncedSeatAssignments(String serialized) {
        seatAssignments.clear();
        seatAssignments.putAll(FurnitureSeatSyncCodec.decode(serialized));
    }

    @Override
    public boolean isSeatLocked(String seatId) {
        FurnitureDefinition def = getDefinition();
        if (def == null) return false;
        int idx = def.getSeatIndex(seatId);
        if (idx < 0) return false;
        return (this.entityData.get(SEAT_LOCK_BITS) & (1 << idx)) != 0;
    }

    @Override
    public void setSeatLocked(String seatId, boolean locked) {
        FurnitureDefinition def = getDefinition();
        if (def == null) return;
        int idx = def.getSeatIndex(seatId);
        if (idx < 0) return;
        byte bits = this.entityData.get(SEAT_LOCK_BITS);
        if (locked) {
            bits |= (byte) (1 << idx);
        } else {
            bits &= (byte) ~(1 << idx);
        }
        this.entityData.set(SEAT_LOCK_BITS, bits);

        // Update persistent data for reconnection system on the seated player
        if (!this.level().isClientSide) {
            Entity passenger = findPassengerInSeat(seatId);
            if (passenger instanceof ServerPlayer serverPlayer) {
                if (locked) {
                    writeFurnitureReconnectionTag(serverPlayer, seatId);
                } else {
                    serverPlayer
                        .getPersistentData()
                        .remove("tiedup_locked_furniture");
                }
            }
        }
    }

    /**
     * Find the passenger entity sitting in a specific seat.
     *
     * @param seatId the seat identifier to search for
     * @return the passenger entity, or null if the seat is unoccupied
     */
    @Nullable
    public Entity findPassengerInSeat(String seatId) {
        for (Entity passenger : this.getPassengers()) {
            String assignedSeat = seatAssignments.get(passenger.getUUID());
            if (seatId.equals(assignedSeat)) {
                return passenger;
            }
        }
        return null;
    }

    /**
     * Write the reconnection tag to a player's persistent data so they can be
     * re-mounted on login if they disconnect while locked in a furniture seat.
     *
     * @param player the server player locked in this furniture
     * @param seatId the seat ID the player is locked in
     */
    private void writeFurnitureReconnectionTag(
        ServerPlayer player,
        String seatId
    ) {
        CompoundTag tag = new CompoundTag();
        BlockPos pos = this.blockPosition();
        tag.putInt("x", pos.getX());
        tag.putInt("y", pos.getY());
        tag.putInt("z", pos.getZ());
        tag.putString("dim", this.level().dimension().location().toString());
        tag.putString("furniture_uuid", this.getStringUUID());
        tag.putString("seat_id", seatId);
        player.getPersistentData().put("tiedup_locked_furniture", tag);
    }

    @Override
    public int getLockedDifficulty(String seatId) {
        FurnitureDefinition def = getDefinition();
        if (def == null) return 0;
        SeatDefinition seat = def.getSeat(seatId);
        return seat != null ? seat.lockedDifficulty() : 0;
    }

    @Override
    public Set<BodyRegionV2> getBlockedRegions(String seatId) {
        FurnitureDefinition def = getDefinition();
        if (def == null) return Collections.emptySet();
        SeatDefinition seat = def.getSeat(seatId);
        return seat != null ? seat.blockedRegions() : Collections.emptySet();
    }

    @Override
    public boolean hasItemDifficultyBonus(String seatId) {
        FurnitureDefinition def = getDefinition();
        if (def == null) return false;
        SeatDefinition seat = def.getSeat(seatId);
        return seat != null && seat.itemDifficultyBonus();
    }

    // ========== Vanilla Riding System ==========

    @Override
    protected boolean canAddPassenger(Entity passenger) {
        FurnitureDefinition def = getDefinition();
        return def != null && this.getPassengers().size() < def.seats().size();
    }

    /**
     * Position the passenger at their assigned seat's world position.
     *
     * <p>On the client side, the exact seat transform is read from the parsed GLB
     * data via {@code FurnitureSeatPositionHelper} (client-only). On the server
     * side (or when GLB data is unavailable), an approximate position is computed
     * by spacing seats evenly along the furniture's local right axis.</p>
     */
    @Override
    protected void positionRider(
        Entity passenger,
        Entity.MoveFunction moveFunction
    ) {
        if (!this.hasPassenger(passenger)) return;

        SeatDefinition seat = getSeatForPassenger(passenger);
        if (seat == null) {
            // Fallback: center of entity at base height
            moveFunction.accept(
                passenger,
                this.getX(),
                this.getY(),
                this.getZ()
            );
            return;
        }

        // Client-side: try to use real seat transforms parsed from the GLB model.
        // FurnitureSeatPositionHelper is @OnlyIn(Dist.CLIENT) so we guard with isClientSide.
        if (this.level().isClientSide) {
            FurnitureDefinition def = getDefinition();
            if (def != null) {
                double[] pos =
                    com.tiedup.remake.v2.furniture.client.FurnitureSeatPositionHelper.getSeatWorldPosition(
                        def,
                        seat.id(),
                        this.getX(),
                        this.getY(),
                        this.getZ(),
                        this.getYRot()
                    );
                if (pos != null) {
                    moveFunction.accept(passenger, pos[0], pos[1], pos[2]);
                    return;
                }
            }
        }

        // Server-side fallback (or missing GLB data): approximate positioning.
        // Seats are spaced evenly along the entity's local right axis.
        FurnitureDefinition def = getDefinition();
        int seatCount = def != null ? def.seats().size() : 1;
        int seatIdx = def != null ? def.getSeatIndex(seat.id()) : 0;
        if (seatIdx < 0) seatIdx = 0;

        Vec3 right = FurnitureSeatGeometry.rightAxis(this.getYRot());
        double offset = FurnitureSeatGeometry.seatOffset(seatIdx, seatCount);

        moveFunction.accept(
            passenger,
            this.getX() + right.x * offset,
            this.getY() + 0.5,
            this.getZ() + right.z * offset
        );
    }

    /**
     * Called after a passenger is added to the entity.
     * Assigns the passenger to the nearest available seat based on the passenger's
     * look direction, and updates animation state.
     */
    @Override
    protected void addPassenger(Entity passenger) {
        super.addPassenger(passenger);

        // Seat selection is server-authoritative. The client waits for the
        // SEAT_ASSIGNMENTS_SYNC broadcast (~1 tick later) before knowing
        // which seat this passenger is in. During that gap positionRider
        // falls back to entity center — 1 frame of imperceptible glitch
        // on mount, in exchange for deterministic multi-seat correctness.
        if (!this.level().isClientSide) {
            SeatDefinition nearest = findNearestAvailableSeat(passenger);
            if (nearest != null) {
                assignSeat(passenger, nearest.id());
            }
        }

        // Play entering transition: furniture shows "Occupied" clip while player settles in.
        // After 20 ticks (1 second), state stabilizes to OCCUPIED.
        if (!this.level().isClientSide) {
            this.entityData.set(ANIM_STATE, STATE_ENTERING);
            this.transitionTicksLeft = 20;
            this.transitionTargetState = STATE_OCCUPIED;
        }

        // Seat-pose animation is kicked off from onSyncedDataUpdated once
        // SEAT_ASSIGNMENTS_SYNC arrives (~1 tick after mount). The cold-
        // cache retry in AnimationTickHandler covers the case where the
        // GLB hasn't parsed yet.
    }

    /**
     * Client-side: when the synched {@code ANIM_STATE} or seat assignments
     * change, re-play the seat pose for each seated player so the authored
     * state-specific clip kicks in. Without this, a server-side transition
     * (mount entering → occupied, lock close, struggle start) never
     * propagates to the player's pose.
     */
    @Override
    public void onSyncedDataUpdated(
        net.minecraft.network.syncher.EntityDataAccessor<?> key
    ) {
        super.onSyncedDataUpdated(key);
        if (!this.level().isClientSide) return;
        if (ANIM_STATE.equals(key)) {
            restartAllSeatAnimations();
        } else if (SEAT_ASSIGNMENTS_SYNC.equals(key)) {
            applySyncedSeatAssignments(
                this.entityData.get(SEAT_ASSIGNMENTS_SYNC)
            );
            restartAllSeatAnimations();
        }
    }

    /** Client-only — caller guards via level().isClientSide. */
    private void restartAllSeatAnimations() {
        for (Entity passenger : this.getPassengers()) {
            if (passenger instanceof Player player) {
                com.tiedup.remake.v2.furniture.client.FurnitureClientAnimator
                    .start(this, player);
            }
        }
    }

    /**
     * Find the available seat whose approximate world position has the smallest
     * angle to the passenger's look direction.
     *
     * <p>Since we don't have GLB armature transforms yet (Task 14), seats are
     * approximated as evenly spaced along the entity's local right axis.
     * Seat 0 is at entity center, seat 1 is offset +1 block on the right, etc.
     * The entity's Y rotation determines the right axis.</p>
     *
     * @param passenger the entity being seated
     * @return the best available seat, or null if no seats are available
     */
    @Nullable
    private SeatDefinition findNearestAvailableSeat(Entity passenger) {
        FurnitureDefinition def = getDefinition();
        if (def == null || def.seats().isEmpty()) return null;

        Vec3 passengerPos = passenger.getEyePosition();
        Vec3 lookDir = passenger.getLookAngle();
        Vec3 right = FurnitureSeatGeometry.rightAxis(this.getYRot());

        SeatDefinition best = null;
        double bestScore = Double.MAX_VALUE;

        List<SeatDefinition> seats = def.seats();
        int seatCount = seats.size();

        for (int i = 0; i < seatCount; i++) {
            SeatDefinition seat = seats.get(i);

            // Skip already-occupied seats
            if (seatAssignments.containsValue(seat.id())) continue;

            // Approximate seat world position: entity origin + offset along right axis.
            // For a single seat, it's at center. For multiple, spread evenly.
            double offset = FurnitureSeatGeometry.seatOffset(i, seatCount);
            Vec3 seatWorldPos = new Vec3(
                this.getX() + right.x * offset,
                this.getY() + 0.5,
                this.getZ() + right.z * offset
            );

            // Score: angle between passenger look direction and direction to seat.
            // Use negative dot product of normalized vectors (smaller angle = lower score).
            Vec3 toSeat = seatWorldPos.subtract(passengerPos);
            double distSq = toSeat.lengthSqr();
            if (distSq < 1e-6) {
                // Passenger is essentially on top of the seat — best possible score
                best = seat;
                bestScore = -1.0;
                continue;
            }

            Vec3 toSeatNorm = toSeat.normalize();
            // Dot product: 1.0 = looking directly at seat, -1.0 = looking away.
            // We want the highest dot product, so use negative as score.
            double dot = lookDir.dot(toSeatNorm);
            double score = -dot;

            if (score < bestScore) {
                bestScore = score;
                best = seat;
            }
        }

        return best;
    }

    /**
     * Find the occupied, lockable seat whose approximate world position is
     * closest to the player's look direction. Used for key interactions so
     * that multi-seat furniture targets the seat the player is looking at.
     *
     * @param player the player performing the lock/unlock interaction
     * @param def the furniture definition (must not be null)
     * @return the best matching seat, or null if no occupied lockable seats exist
     */
    @Nullable
    private SeatDefinition findNearestOccupiedLockableSeat(
        Player player,
        FurnitureDefinition def
    ) {
        Vec3 playerPos = player.getEyePosition();
        Vec3 lookDir = player.getLookAngle();
        Vec3 right = FurnitureSeatGeometry.rightAxis(this.getYRot());

        SeatDefinition best = null;
        double bestScore = Double.MAX_VALUE;

        List<SeatDefinition> seats = def.seats();
        int seatCount = seats.size();

        for (int i = 0; i < seatCount; i++) {
            SeatDefinition seat = seats.get(i);

            // Only consider occupied, lockable seats
            if (
                !seat.lockable() || !seatAssignments.containsValue(seat.id())
            ) continue;

            double offset = FurnitureSeatGeometry.seatOffset(i, seatCount);
            Vec3 seatWorldPos = new Vec3(
                this.getX() + right.x * offset,
                this.getY() + 0.5,
                this.getZ() + right.z * offset
            );

            Vec3 toSeat = seatWorldPos.subtract(playerPos);
            double distSq = toSeat.lengthSqr();
            if (distSq < 1e-6) {
                best = seat;
                bestScore = -1.0;
                continue;
            }

            Vec3 toSeatNorm = toSeat.normalize();
            double dot = lookDir.dot(toSeatNorm);
            double score = -dot;

            if (score < bestScore) {
                bestScore = score;
                best = seat;
            }
        }

        return best;
    }

    /**
     * Called when a passenger is being removed from the entity.
     *
     * <p>If the passenger's seat is locked and neither the entity nor passenger
     * is being removed/killed, the dismount is cancelled by re-mounting the
     * passenger on the next server tick via {@code getServer().execute()}.</p>
     *
     * <p>This deferred re-mount pattern is the same one used by
     * {@link com.tiedup.remake.events.captivity.ForcedSeatingHandler} for forced seating.
     * We cannot call {@code startRiding()} during the removePassenger call chain
     * because the passenger list is being mutated.</p>
     */
    @Override
    protected void removePassenger(Entity passenger) {
        SeatDefinition seat = getSeatForPassenger(passenger);

        if (seat != null && isSeatLocked(seat.id()) && !this.isRemoved()) {
            // Play denied sound to the passenger attempting to leave a locked seat
            if (passenger instanceof ServerPlayer sp) {
                FurnitureDefinition def = getDefinition();
                if (def != null && def.feedback().deniedSound() != null) {
                    sp
                        .level()
                        .playSound(
                            null,
                            sp.getX(),
                            sp.getY(),
                            sp.getZ(),
                            SoundEvent.createVariableRangeEvent(
                                def.feedback().deniedSound()
                            ),
                            SoundSource.PLAYERS,
                            0.5f,
                            1.0f
                        );
                }
            }

            // Locked seat: prevent voluntary dismount by re-mounting next tick
            super.removePassenger(passenger);

            if (this.getServer() != null) {
                String lockedSeatId = seat.id();
                Entity self = this;
                this.getServer().execute(() -> {
                    if (
                        passenger.isAlive() &&
                        self.isAlive() &&
                        !self.isRemoved()
                    ) {
                        passenger.startRiding(self, true);
                        // Re-assign the specific seat (not just first available)
                        assignSeat(passenger, lockedSeatId);
                    } else {
                        // Passenger disconnected or entity removed — clean up the orphaned seat
                        releaseSeat(passenger);
                        updateAnimState();
                    }
                });
            }
            return;
        }

        // Normal dismount
        super.removePassenger(passenger);
        if (seat != null) {
            releaseSeat(passenger);
        }
        // Clear reconnection tag on normal dismount
        if (passenger instanceof ServerPlayer serverPlayer) {
            serverPlayer.getPersistentData().remove("tiedup_locked_furniture");
        }

        // Play exiting transition: furniture transitions to Idle over 20 ticks.
        // If other passengers remain, target state stays OCCUPIED.
        if (!this.level().isClientSide) {
            this.entityData.set(ANIM_STATE, STATE_EXITING);
            this.transitionTicksLeft = 20;
            this.transitionTargetState = this.getPassengers().isEmpty()
                ? STATE_IDLE
                : STATE_OCCUPIED;
        }

        // Client-side: immediately stop the furniture pose animation on the dismounting player.
        // BondageAnimationManager is @OnlyIn(Dist.CLIENT), so we guard with isClientSide.
        // The safety tick in AnimationTickHandler also catches this after a 3-tick grace period,
        // but stopping immediately avoids a visible animation glitch during dismount.
        if (this.level().isClientSide && passenger instanceof Player) {
            com.tiedup.remake.client.animation.BondageAnimationManager.stopFurniture(
                (Player) passenger
            );
        }
    }

    /**
     * Update the synched animation state based on passenger occupancy.
     * More complex states (LOCKING, STRUGGLE, ENTERING, EXITING, UNLOCKING) are set
     * explicitly by other systems. This method does NOT overwrite an active transition
     * to avoid cutting short one-shot animations.
     */
    private void updateAnimState() {
        // Don't clobber active transitions — let the tick timer handle the reset
        if (this.transitionTicksLeft > 0) return;

        byte state = this.getPassengers().isEmpty()
            ? STATE_IDLE
            : STATE_OCCUPIED;
        this.entityData.set(ANIM_STATE, state);
    }

    // ========== Interaction ==========

    /**
     * Handle right-click interactions with the furniture.
     *
     * <p>Priority order:</p>
     * <ol>
     *   <li>Force-mount a leashed captive (collar ownership required)</li>
     *   <li>Key item + occupied lockable seat: toggle lock</li>
     *   <li>Empty hand + available seat: self-mount</li>
     * </ol>
     */
    @Override
    public InteractionResult interact(Player player, InteractionHand hand) {
        if (this.level().isClientSide) {
            return InteractionResult.SUCCESS;
        }

        FurnitureDefinition def = getDefinition();
        if (def == null) {
            return InteractionResult.PASS;
        }

        // Priority 1: Force-mount a leashed captive onto an available seat.
        // The interacting player must be a captor with at least one leashed captive,
        // and the captive must wear a collar owned by this player (or player is OP).
        if (
            player instanceof ServerPlayer serverPlayer &&
            this.getPassengers().size() < def.seats().size()
        ) {
            PlayerBindState ownerState = PlayerBindState.getInstance(
                serverPlayer
            );
            if (ownerState != null) {
                PlayerCaptorManager captorManager =
                    ownerState.getCaptorManager();
                if (captorManager != null && captorManager.hasCaptives()) {
                    for (IBondageState captive : captorManager.getCaptives()) {
                        LivingEntity captiveEntity = captive.asLivingEntity();

                        // Unified authorization via shared predicate.
                        if (
                            !FurnitureAuthPredicate.canForceMount(
                                serverPlayer,
                                this,
                                captiveEntity
                            )
                        ) {
                            continue;
                        }

                        // Detach leash only (drop the lead, keep tied-up status)
                        captive.free(false);

                        // Force-mount: startRiding triggers addPassenger which assigns nearest seat
                        boolean mounted = captiveEntity.startRiding(this, true);
                        if (mounted) {
                            // Play mount sound
                            FurnitureFeedback feedback = def.feedback();
                            if (feedback.mountSound() != null) {
                                this.level().playSound(
                                    null,
                                    this.getX(),
                                    this.getY(),
                                    this.getZ(),
                                    SoundEvent.createVariableRangeEvent(
                                        feedback.mountSound()
                                    ),
                                    SoundSource.BLOCKS,
                                    1.0f,
                                    1.0f
                                );
                            }

                            // Broadcast updated state to tracking clients
                            PacketSyncFurnitureState.sendToTracking(this);

                            TiedUpMod.LOGGER.debug(
                                "[EntityFurniture] {} force-mounted captive {} onto furniture {}",
                                serverPlayer.getName().getString(),
                                captiveEntity.getName().getString(),
                                getFurnitureId()
                            );
                        }
                        return InteractionResult.CONSUME;
                    }
                }
            }
        }

        // Priority 2: Key + occupied seat -> lock/unlock
        // Use look direction to pick the nearest occupied, lockable seat.
        // Authorization is enforced via FurnitureAuthPredicate so the in-world
        // path cannot drift from the packet path.
        ItemStack heldItem = player.getItemInHand(hand);
        if (
            isKeyItem(heldItem) &&
            !this.getPassengers().isEmpty() &&
            player instanceof ServerPlayer sp
        ) {
            SeatDefinition targetSeat = findNearestOccupiedLockableSeat(
                player,
                def
            );
            if (
                targetSeat != null &&
                FurnitureAuthPredicate.canLockUnlock(sp, this, targetSeat.id())
            ) {
                boolean wasLocked = isSeatLocked(targetSeat.id());
                setSeatLocked(targetSeat.id(), !wasLocked);

                // Play lock/unlock sound
                FurnitureFeedback feedback = def.feedback();
                ResourceLocation soundRL = wasLocked
                    ? feedback.unlockSound()
                    : feedback.lockSound();
                if (soundRL != null) {
                    this.level().playSound(
                        null,
                        this.getX(),
                        this.getY(),
                        this.getZ(),
                        SoundEvent.createVariableRangeEvent(soundRL),
                        SoundSource.BLOCKS,
                        1.0f,
                        1.0f
                    );
                }

                // Set lock/unlock animation with transition timer
                boolean nowLocked = !wasLocked;
                this.entityData.set(
                    ANIM_STATE,
                    nowLocked ? STATE_LOCKING : STATE_UNLOCKING
                );
                this.transitionTicksLeft = 20;
                this.transitionTargetState = STATE_OCCUPIED;

                TiedUpMod.LOGGER.debug(
                    "[EntityFurniture] {} {} seat '{}' on furniture {}",
                    player.getName().getString(),
                    wasLocked ? "unlocked" : "locked",
                    targetSeat.id(),
                    getFurnitureId()
                );
                return InteractionResult.CONSUME;
            }
        }

        // Priority 3: Empty hand + available seat -> self mount
        if (
            heldItem.isEmpty() &&
            this.getPassengers().size() < def.seats().size()
        ) {
            player.startRiding(this);
            return InteractionResult.CONSUME;
        }

        // No valid action — play denied sound if configured
        FurnitureFeedback feedback = def.feedback();
        if (feedback != null && feedback.deniedSound() != null) {
            this.level().playSound(
                null,
                this.getX(),
                this.getY(),
                this.getZ(),
                SoundEvent.createVariableRangeEvent(feedback.deniedSound()),
                SoundSource.BLOCKS,
                0.5f,
                1.0f
            );
        }

        return InteractionResult.PASS;
    }

    /**
     * Check if the given item is a key that can lock/unlock furniture seats.
     * Hardcoded to {@link ItemMasterKey}: the seat-lock mechanic is single-key
     * by design (no per-seat keys). A future second key item would need an
     * {@code ILockKey} interface; until then {@code instanceof} is cheapest.
     */
    private boolean isKeyItem(ItemStack stack) {
        return !stack.isEmpty() && stack.getItem() instanceof ItemMasterKey;
    }

    // ========== Damage ==========

    /**
     * Handle left-click (attack) interactions.
     *
     * <p>Creative mode: instant discard. Survival mode: accumulate damage
     * until it reaches the definition's {@code breakResistance}, then break
     * the furniture, eject all passengers, and optionally drop an item.</p>
     *
     * @return true if the attack was consumed, false to pass to vanilla handling
     */
    @Override
    public boolean skipAttackInteraction(Entity attacker) {
        if (this.level().isClientSide) {
            return true;
        }

        if (attacker instanceof Player player) {
            if (player.isCreative()) {
                this.ejectPassengers();
                this.discard();
                return true;
            }

            // Cannot break occupied furniture in survival — must remove passengers first.
            // Creative mode bypasses this check (handled above with instant discard).
            if (!this.getPassengers().isEmpty()) {
                FurnitureDefinition occupiedDef = getDefinition();
                if (
                    occupiedDef != null &&
                    occupiedDef.feedback().deniedSound() != null
                ) {
                    this.level().playSound(
                        null,
                        this.getX(),
                        this.getY(),
                        this.getZ(),
                        SoundEvent.createVariableRangeEvent(
                            occupiedDef.feedback().deniedSound()
                        ),
                        SoundSource.BLOCKS,
                        0.5f,
                        1.0f
                    );
                }
                return true; // Consume the attack but accumulate no damage
            }

            FurnitureDefinition def = getDefinition();
            float resistance = def != null ? def.breakResistance() : 100f;
            this.currentDamage += 1.0f;

            if (this.currentDamage >= resistance) {
                this.ejectPassengers();
                if (def != null && def.dropOnBreak()) {
                    ItemStack dropStack = FurniturePlacerItem.createStack(
                        def.id()
                    );
                    if (!dropStack.isEmpty()) {
                        this.spawnAtLocation(dropStack);
                    }
                }
                this.discard();
            }

            return true;
        }

        return false;
    }

    // ========== Tick ==========

    /**
     * Per-tick logic. Currently only handles damage decay on the server.
     * Future tasks may add animation ticking or sound loops here.
     */
    @Override
    public void tick() {
        super.tick();

        if (!this.level().isClientSide) {
            // Transition animation timer: count down and reset state when complete
            if (this.transitionTicksLeft > 0) {
                this.transitionTicksLeft--;
                if (this.transitionTicksLeft == 0) {
                    this.entityData.set(ANIM_STATE, this.transitionTargetState);
                }
            }

            // Damage decay: ~20 seconds to fully heal 1 point of damage
            if (this.currentDamage > 0) {
                this.currentDamage = Math.max(0f, this.currentDamage - 0.05f);
            }

            // Periodic cleanup: remove stale seat assignments for passengers
            // that are no longer riding this entity (e.g., disconnected players).
            if (this.tickCount % 100 == 0) {
                cleanupStaleSeatAssignments();
            }
        }
    }

    /**
     * Remove seat assignments whose UUID does not match any current passenger.
     * This catches edge cases where a player disconnects or is removed without
     * going through the normal removePassenger path (e.g., server crash recovery,
     * /kick, or chunk unload races).
     */
    private void cleanupStaleSeatAssignments() {
        if (seatAssignments.isEmpty()) return;

        Set<UUID> passengerUuids = new java.util.HashSet<>();
        for (Entity passenger : this.getPassengers()) {
            passengerUuids.add(passenger.getUUID());
        }

        boolean removed = seatAssignments
            .keySet()
            .removeIf(uuid -> !passengerUuids.contains(uuid));

        if (removed) {
            TiedUpMod.LOGGER.debug(
                "[EntityFurniture] Cleaned up stale seat assignments on {}",
                getFurnitureId()
            );
            syncSeatAssignmentsIfServer();
            updateAnimState();
        }
    }

    // ========== NBT Persistence ==========

    @Override
    protected void addAdditionalSaveData(CompoundTag tag) {
        tag.putString(FurnitureRegistry.NBT_FURNITURE_ID, getFurnitureId());
        tag.putFloat("facing", this.getYRot());
        tag.putByte("seat_locks", this.entityData.get(SEAT_LOCK_BITS));
        tag.putFloat("current_damage", this.currentDamage);

        // Persist transition state so animations don't get stuck after server restart
        if (this.transitionTicksLeft > 0) {
            tag.putInt("transition_ticks", this.transitionTicksLeft);
            tag.putByte("transition_target", this.transitionTargetState);
        }

        // Persist seat assignments so locked passengers keep their seat after reload
        CompoundTag assignments = new CompoundTag();
        for (Map.Entry<UUID, String> entry : seatAssignments.entrySet()) {
            assignments.putString(entry.getKey().toString(), entry.getValue());
        }
        tag.put("seat_assignments", assignments);
    }

    @Override
    protected void readAdditionalSaveData(CompoundTag tag) {
        if (tag.contains(FurnitureRegistry.NBT_FURNITURE_ID, 8)) {
            // 8 = TAG_String
            setFurnitureId(tag.getString(FurnitureRegistry.NBT_FURNITURE_ID));
        }
        if (tag.contains("facing")) {
            this.setYRot(tag.getFloat("facing"));
        }
        if (tag.contains("seat_locks")) {
            this.entityData.set(SEAT_LOCK_BITS, tag.getByte("seat_locks"));
        }
        if (tag.contains("current_damage")) {
            this.currentDamage = tag.getFloat("current_damage");
        }

        // Restore transition state so in-progress animations resume after reload
        if (tag.contains("transition_ticks")) {
            this.transitionTicksLeft = tag.getInt("transition_ticks");
            this.transitionTargetState = tag.getByte("transition_target");
        }

        // Restore seat assignments
        seatAssignments.clear();
        if (tag.contains("seat_assignments", 10)) {
            // 10 = TAG_Compound
            CompoundTag assignments = tag.getCompound("seat_assignments");
            for (String key : assignments.getAllKeys()) {
                try {
                    UUID uuid = UUID.fromString(key);
                    seatAssignments.put(uuid, assignments.getString(key));
                } catch (IllegalArgumentException e) {
                    TiedUpMod.LOGGER.warn(
                        "[EntityFurniture] Skipping invalid UUID in seat assignments: {}",
                        key
                    );
                }
            }
        }
        // Push the restored map into the synced field so clients that start
        // tracking the entity after load get the right assignments too.
        syncSeatAssignmentsIfServer();

        this.refreshDimensions();
    }

    // ========== Getters / Setters ==========

    /**
     * Get the furniture definition ID string (ResourceLocation form).
     * May be empty if the entity has not been initialized yet.
     */
    public String getFurnitureId() {
        return this.entityData.get(FURNITURE_ID);
    }

    /**
     * Set the furniture definition ID. Triggers a dimension refresh so the
     * bounding box updates to match the new definition's hitbox.
     */
    public void setFurnitureId(String id) {
        this.entityData.set(FURNITURE_ID, id != null ? id : "");
        this.refreshDimensions();
    }

    /**
     * Resolve the current furniture definition from the registry.
     *
     * @return the definition, or null if the ID is empty, unparseable,
     *         or no longer registered (e.g., data pack was removed)
     */
    @Nullable
    public FurnitureDefinition getDefinition() {
        return FurnitureRegistry.get(getFurnitureId());
    }

    /**
     * Get the raw seat lock bitmask. Bit N corresponds to seat index N in the
     * furniture definition's seat list.
     *
     * @return the lock bitmask byte (0 = all unlocked)
     */
    public byte getSeatLockBits() {
        return this.entityData.get(SEAT_LOCK_BITS);
    }

    /**
     * Set the raw seat lock bitmask directly. Used by the network sync packet
     * ({@link com.tiedup.remake.v2.furniture.network.PacketSyncFurnitureState})
     * to apply server-authoritative state on the client without per-seat iteration.
     *
     * <p>Server code should prefer {@link #setSeatLocked(String, boolean)} for
     * individual seat lock changes, as it validates the seat ID against the
     * furniture definition.</p>
     *
     * @param bits the raw lock bitmask
     */
    public void setSeatLockBitsRaw(byte bits) {
        this.entityData.set(SEAT_LOCK_BITS, bits);
    }

    /**
     * Get the current animation state byte. Use the STATE_ constants to compare.
     */
    public byte getAnimState() {
        return this.entityData.get(ANIM_STATE);
    }

    /**
     * Explicitly set the animation state. Used by external systems (e.g., struggle
     * minigame) to signal visual state changes beyond simple occupancy.
     */
    public void setAnimState(byte state) {
        this.entityData.set(ANIM_STATE, state);
    }

    /**
     * Set a transient animation state that auto-reverts to {@code target}
     * after {@code ticks} ticks. Callers of transitional states
     * (LOCKING/UNLOCKING/ENTERING/EXITING) MUST use this rather than
     * {@link #setAnimState} so the tick-decrement path has the right
     * target to revert to.
     */
    public void setTransitionState(byte state, int ticks, byte target) {
        this.entityData.set(ANIM_STATE, state);
        this.transitionTicksLeft = ticks;
        this.transitionTargetState = target;
    }

    // ========== Entity Behavior Overrides ==========

    /** Furniture can be targeted by the crosshair (for interaction and attack). */
    @Override
    public boolean isPickable() {
        return true;
    }

    /** Furniture cannot be pushed by entities or pistons. */
    @Override
    public boolean isPushable() {
        return false;
    }

    /** Furniture has solid collision (entities cannot walk through it). */
    @Override
    public boolean canBeCollidedWith() {
        return true;
    }

    /** Furniture does not emit movement sounds or events. */
    @Override
    protected Entity.MovementEmission getMovementEmission() {
        return Entity.MovementEmission.NONE;
    }

    // ========== Spawn Packet ==========

    /**
     * Return the Forge-aware spawn packet so that {@link IEntityAdditionalSpawnData}
     * fields ({@code writeSpawnData}/{@code readSpawnData}) are included.
     *
     * <p>This override is required per the Forge Community Wiki. Without it,
     * Forge sends a vanilla {@code ClientboundAddEntityPacket} which does NOT
     * include the additional spawn buffer, causing the entity to be invisible
     * or have missing data on the client.</p>
     */
    @Override
    public Packet<ClientGamePacketListener> getAddEntityPacket() {
        return NetworkHooks.getEntitySpawningPacket(this);
    }
}
