import { assert } from "../../../../core/assert.js";
import { Ray3 } from "../../../../core/geom/3d/ray/Ray3.js";
import { PhysicsSystem } from "../../../physics/ecs/PhysicsSystem.js";
import { PhysicsSurfacePoint } from "../../../physics/queries/PhysicsSurfacePoint.js";
import { System } from "../../../ecs/System.js";
import { Transform } from "../../../ecs/transform/Transform.js";
import { FirstPersonPlayerController } from "../FirstPersonPlayerController.js";
import { FirstPersonPlayerControllerSystem } from "../FirstPersonPlayerControllerSystem.js";

/**
 * Populates the {@link FirstPersonSensors} struct on each controller
 * entity, once per fixed step. Designed to run BEFORE the main
 * {@link FirstPersonPlayerControllerSystem} `fixedUpdate` so abilities
 * see fresh sensor data on the same tick.
 *
 * Raycasts go through {@link PhysicsSystem.raycast} — BVH-backed,
 * static + dynamic broadphase. The player's own body is excluded via a
 * per-call filter so downward probes don't collide with the player's
 * own capsule.
 *
 * BVH raycasts are cheap (~1000/frame at 60Hz is comfortable), so this
 * system doesn't aggressively optimize. NPC controllers that don't
 * need sensors can simply not have this system attached.
 *
 * Sensor data lives at `runtime.sensors` on the
 * {@link FirstPersonPlayerControllerSystem}'s per-entity runtime.
 *
 * @author Alex Goldring
 * @copyright Company Named Limited (c) 2026
 */
export class FirstPersonSensorsSystem extends System {
    constructor() {
        super();

        this.dependencies = [FirstPersonPlayerController, Transform];

        /**
         * Reference to the controller system — needed to look up each
         * entity's per-controller runtime (which owns the sensors slot).
         * Auto-acquired in startup; can be overridden by the caller.
         * @type {FirstPersonPlayerControllerSystem|null}
         */
        this.controllerSystem = null;

        /**
         * Physics system used as the spatial-query backend. Auto-acquired
         * in startup; can be overridden. Required — sensors are unusable
         * without it.
         * @type {PhysicsSystem|null}
         */
        this.physicsSystem = null;

        /**
         * Pre-allocated raycast query primitive. Refilled in place per
         * {@link _probeRay} call so the system doesn't allocate a new
         * `Ray3` every probe.
         * @private
         * @type {Ray3}
         */
        this._probe_ray = new Ray3();

        /**
         * Pre-allocated raycast result. Refilled in place per probe;
         * gameplay-visible sensor fields are copied out before the next
         * probe overwrites it.
         * @private
         * @type {PhysicsSurfacePoint}
         */
        this._probe_hit = new PhysicsSurfacePoint();
    }

    async startup(entityManager) {
        this.entityManager = entityManager;

        // Auto-acquire collaborators if they're already in the world.
        // Caller can still override either field before fixedUpdate runs.
        if (this.controllerSystem === null) {
            const cs = entityManager.getSystem(FirstPersonPlayerControllerSystem);
            if (cs !== null) this.controllerSystem = cs;
        }
        if (this.physicsSystem === null) {
            const ps = entityManager.getSystem(PhysicsSystem);
            if (ps !== null) this.physicsSystem = ps;
        }
    }

    fixedUpdate(dt) {
        const ecd = this.entityManager.dataset;
        if (ecd === null) return;
        ecd.traverseComponents(FirstPersonPlayerController, this._populateForEntity, this);
    }

    /**
     * @private
     */
    _populateForEntity(controller, entity) {
        // Sensors live on the per-entity runtime owned by the
        // controller's own system. Look it up by entity — no leakage
        // through the controller component itself.
        if (this.controllerSystem === null) return; // not wired up
        const runtime = this.controllerSystem.getRuntime(entity);
        if (runtime === undefined) return;
        const sensors = runtime.sensors;
        if (sensors === undefined || sensors === null) return;

        // Sensors are useless without physics. Assert at the first
        // populate; the misconfiguration would otherwise show up as
        // mysteriously inert abilities.
        assert.notEqual(this.physicsSystem, null,
            "FirstPersonSensorsSystem requires a PhysicsSystem to be in the world. "
            + "Add it before this system, or set `sensorsSystem.physicsSystem` "
            + "explicitly.");

        const ecd = this.entityManager.dataset;
        const transform = ecd.getComponent(entity, Transform);
        if (transform === undefined) return;

        sensors.clearAll();

        // Probe origin: at chest height (approx body height × 0.65).
        const cfg = controller.config;
        const chestY = cfg.body.height * 0.65;
        const ox = transform.position.x;
        const oy = transform.position.y + chestY;
        const oz = transform.position.z;

        // Body-local forward + right (engine-local axes — see DESIGN.md
        // §5.3 for the conventions). bodyYaw lives on the runtime.
        const yaw = runtime.bodyYaw;
        const fx = Math.sin(yaw);
        const fz = Math.cos(yaw);
        const rx = Math.cos(yaw);
        const rz = -Math.sin(yaw);

        const wallProbeDist = cfg.body.radius * 2 + 0.05; // just past the capsule
        const obstacleProbeDist = 1.2; // ~one stride

        // Wall left: probe along -right.
        this._probeRay(sensors.wallLeft, ox, oy, oz, -rx, 0, -rz, wallProbeDist, entity);
        // Wall right: probe along +right.
        this._probeRay(sensors.wallRight, ox, oy, oz, rx, 0, rz, wallProbeDist, entity);
        // Wall front: probe along +forward.
        this._probeRay(sensors.wallFront, ox, oy, oz, fx, 0, fz, wallProbeDist, entity);
        // Obstacle ahead: same direction as wallFront but longer reach.
        // Used by mantle/vault; the consumer compares the hit altitude to
        // the body height to decide vault vs. mantle vs. neither.
        this._probeRay(sensors.obstacleAhead, ox, oy, oz, fx, 0, fz, obstacleProbeDist, entity);

        // Ledge ahead — only meaningful if obstacleAhead hit something
        // around chest height: probe down from just past the top of the
        // obstacle, look for ground.
        if (sensors.obstacleAhead.hit) {
            const topY = transform.position.y + cfg.body.height + 0.1;
            const px = sensors.obstacleAhead.point.x + fx * 0.3;
            const pz = sensors.obstacleAhead.point.z + fz * 0.3;
            this._probeRay(
                sensors.ledgeAhead,
                px, topY, pz,
                0, -1, 0,
                cfg.body.height * 0.5,
                entity,
            );
        }

        // Can the player STAND on that ledge, or only HANG from it? Probe
        // straight down a capsule-width forward of the grab edge: if the top
        // surface is still there a standing capsule fits; if it has dropped
        // away it's a thin wall / knife-edge you could only hang from. Mantle
        // reads this to refuse vaulting onto a top it would fall right off.
        if (sensors.ledgeAhead.hit) {
            const reach = cfg.body.radius * 2;
            sensors.ledgeStandable = this._probeStandable(
                sensors.ledgeAhead.point.x + fx * reach,
                sensors.ledgeAhead.point.y,
                sensors.ledgeAhead.point.z + fz * reach,
                entity,
            );
        }
    }

    /**
     * Down-probe a point on (or just past) a ledge top: returns true if a
     * surface is still there near `edgeY` — i.e. the top is wide enough for a
     * standing capsule. A miss (or a much-lower hit) means the surface dropped
     * away: a thin wall / lip you can only hang from, not stand on.
     * @private
     */
    _probeStandable(px, edgeY, pz, excludeEntity) {
        const ray = this._probe_ray;
        ray.setOrigin(px, edgeY + 0.2, pz);
        ray.setDirection(0, -1, 0);
        ray.tMax = 0.4;
        const filter = (e) => e !== excludeEntity;
        const out = this._probe_hit;
        if (!this.physicsSystem.raycast(ray, out, filter)) return false;
        return out.position.y > edgeY - 0.15; // surface still up near the edge height
    }

    /**
     * Raycast through {@link PhysicsSystem.raycast}, populating the slot
     * if anything is hit. Direction is assumed unit (the controller's
     * callers pass body-local unit axes).
     *
     * The raycast now writes the AABB face normal into `_probe_hit.normal`,
     * which is exact for AABB-shaped colliders (the common case in our gym)
     * and a stable approximation elsewhere. Future narrowphase refinement
     * will replace this with the true shape normal at the same call site.
     *
     * @private
     */
    _probeRay(slot, ox, oy, oz, dx, dy, dz, maxDist, excludeEntity) {
        // Exclude the player's own body from the result — downward
        // probes from chest start inside the capsule, would otherwise
        // hit the capsule's bottom hemisphere.
        const ray = this._probe_ray;
        ray.setOrigin(ox, oy, oz);
        ray.setDirection(dx, dy, dz);
        ray.tMax = maxDist;
        const filter = (e) => e !== excludeEntity;
        const out = this._probe_hit;
        if (!this.physicsSystem.raycast(ray, out, filter)) return;

        slot.hit = true;
        slot.distance = out.t;
        slot.point.set(out.position.x, out.position.y, out.position.z);
        slot.normal.set(out.normal.x, out.normal.y, out.normal.z);
        slot.surfaceTag = null; // future: surface-tag lookup
    }
}
