/*
 * floorplan-3d — the drawn floorplan, extruded, with who is in each room.
 *
 * The same `GET /floorplan/presence` payload the Pebble watchapp renders, at the
 * fidelity a real screen allows. If the two ever disagree, one of them is lying.
 *
 * WHY THIS IS CANVAS 2D AND NOT THREE.JS
 * ---------------------------------------
 * docs/endpoint-surfaces.md planned to vendor three.js and called it a deliberate break
 * with the dependency-free rule. Building it made the cheaper answer obvious, so this
 * takes it instead:
 *
 *   - The scene is prisms standing on a plane. There is no camera motion beyond an
 *     orbit, no lighting model worth the name, no textures, no physics. An isometric
 *     projection with painter's-algorithm sorting produces exactly that picture in
 *     ~200 lines of canvas 2D.
 *   - The door panel and kitchen panel are small machines. Canvas 2D redraws this in
 *     under a millisecond; a WebGL context on integrated graphics is a much less
 *     predictable proposition, and "the floorplan is smooth on the TV and juddery on
 *     the panel" is a bad outcome for a view whose whole job is a glance.
 *   - A megabyte of vendored library is a megabyte to keep patched, and it would be the
 *     only dependency in the entire frontend.
 *
 * If this ever grows real lighting or a model import, three.js becomes the right answer
 * and this file becomes the fallback. Until then it is not a compromise, it is the
 * smaller correct tool.
 *
 * WHAT IT INHERITS FROM THE WATCHAPP, DELIBERATELY
 * -------------------------------------------------
 *   - Occupied rooms lit, empty rooms dark, and a THIRD state for rooms HA never
 *     reports on. Rendering "no data" as "empty" is a quiet lie on any screen.
 *   - Occupants as their colour + initial, upgraded to their photo where there is room.
 *     The colour ring stays even with a photo: it is what ties this marker to the same
 *     person on the watch and in the admin panel.
 *   - `unplaced` people get a visible shelf, not a hidden list. They are who you are
 *     most often looking for.
 */
"use strict";

(function (global) {
  const DEG = Math.PI / 180;

  class Floorplan3D {
    /**
     * @param {HTMLCanvasElement} canvas
     * @param {object} options { wallHeight, pitch, yaw, photoUrl }
     *   photoUrl(personId) -> a URL for that person's picture, or null.
     */
    constructor(canvas, options) {
      this.canvas = canvas;
      this.ctx = canvas.getContext("2d");
      this.options = Object.assign(
        { wallHeight: 0.16, pitch: 34, yaw: 30, photoUrl: null },
        options || {}
      );
      this.level = null;
      this.unplaced = [];
      this.photos = new Map();
      this.hover = null;
      this._resize();
      window.addEventListener("resize", () => this._resize());
      this._bindOrbit();
    }

    _resize() {
      const rect = this.canvas.getBoundingClientRect();
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      this.canvas.width = Math.max(1, Math.round(rect.width * dpr));
      this.canvas.height = Math.max(1, Math.round(rect.height * dpr));
      this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      this.width = rect.width;
      this.height = rect.height;
      this.draw();
    }

    /**
     * Drag to orbit. Deliberately yaw-only with a clamped pitch, and NO free-fly: an
     * unconstrained camera on a wall panel is something people knock askew and cannot
     * get back, and there is no keyboard next to it to reset with.
     */
    _bindOrbit() {
      let dragging = false;
      let lastX = 0;
      let lastY = 0;
      const start = (x, y) => {
        dragging = true;
        lastX = x;
        lastY = y;
      };
      const move = (x, y) => {
        if (!dragging) return;
        this.options.yaw += (x - lastX) * 0.4;
        this.options.pitch = Math.max(12, Math.min(70, this.options.pitch - (y - lastY) * 0.25));
        lastX = x;
        lastY = y;
        this.draw();
      };
      const end = () => {
        dragging = false;
      };

      this.canvas.addEventListener("pointerdown", (e) => start(e.clientX, e.clientY));
      this.canvas.addEventListener("pointermove", (e) => move(e.clientX, e.clientY));
      this.canvas.addEventListener("pointerup", end);
      this.canvas.addEventListener("pointerleave", end);
    }

    /** Feed it a level out of GET /floorplan/presence, plus the payload's `unplaced`. */
    setLevel(level, unplaced) {
      this.level = level;
      this.unplaced = unplaced || [];
      if (this.options.photoUrl) this._preloadPhotos();
      this.draw();
    }

    _preloadPhotos() {
      const people = [];
      for (const room of (this.level && this.level.rooms) || []) {
        for (const person of room.occupants || []) people.push(person);
      }
      people.push(...this.unplaced);
      for (const person of people) {
        if (!person.has_photo || this.photos.has(person.id)) continue;
        const url = this.options.photoUrl(person.id);
        if (!url) continue;
        const image = new Image();
        image.crossOrigin = "anonymous";
        image.onload = () => {
          this.photos.set(person.id, image);
          this.draw();
        };
        // A missing photo is not an error: the initial-on-colour fallback is the
        // designed rendering, not a degraded one.
        image.onerror = () => this.photos.set(person.id, null);
        image.src = url;
        this.photos.set(person.id, undefined); // in flight
      }
    }

    /** Normalised plan coords (0..1, plus a height) -> screen pixels. */
    _project(x, y, z) {
      const yaw = this.options.yaw * DEG;
      const pitch = this.options.pitch * DEG;
      // Centre the plan on the origin so orbiting rotates the house rather than
      // swinging it out of frame.
      const cx = x - 0.5;
      const cy = y - 0.5;
      const rx = cx * Math.cos(yaw) - cy * Math.sin(yaw);
      const ry = cx * Math.sin(yaw) + cy * Math.cos(yaw);
      const scale = Math.min(this.width, this.height) * 0.78;
      return {
        x: this.width / 2 + rx * scale,
        y: this.height / 2 + ry * scale * Math.sin(pitch) - (z || 0) * scale * Math.cos(pitch),
      };
    }

    /** Depth key for painter's-algorithm sorting: further from the camera draws first. */
    _depth(points) {
      const yaw = this.options.yaw * DEG;
      let sum = 0;
      for (const [x, y] of points) {
        sum += (x - 0.5) * Math.sin(yaw) + (y - 0.5) * Math.cos(yaw);
      }
      return sum / points.length;
    }

    _roomState(room) {
      // THREE states, never two. A room the plan has drawn but whose area HA never
      // reports is neither occupied nor confirmed-empty, and drawing it as empty is a
      // quiet lie — "nobody is in the study" and "nothing can see the study" are
      // different sentences.
      //
      // A radar target counts as occupied even with nobody named. Somebody IS in that
      // room; that the house cannot say who is a fact about the house, not about the
      // room being empty — and a guest, or a household member whose phone is flat, is
      // exactly the case where lighting the room matters most.
      if ((room.occupants || []).length || (room.targets || []).length) return "occupied";
      if (!room.ha_area_id) return "unknown";
      return "empty";
    }

    draw() {
      const { ctx } = this;
      ctx.clearRect(0, 0, this.width, this.height);
      if (!this.level || !(this.level.rooms || []).length) {
        ctx.fillStyle = "rgba(234,220,255,0.5)";
        ctx.font = "16px system-ui, sans-serif";
        ctx.textAlign = "center";
        ctx.fillText("No rooms drawn on this level yet.", this.width / 2, this.height / 2);
        return;
      }

      const height = this.options.wallHeight;
      const rooms = (this.level.rooms || [])
        .map((room) => {
          let points = [];
          try {
            points = typeof room.points === "string" ? JSON.parse(room.points) : room.points || [];
          } catch (err) {
            points = [];
          }
          return { room, points, depth: this._depth(points.length ? points : [[0.5, 0.5]]) };
        })
        .filter((entry) => entry.points.length >= 3)
        .sort((a, b) => a.depth - b.depth);

      for (const { room, points } of rooms) {
        const state = this._roomState(room);
        this._drawWalls(points, height, state);
        this._drawFloor(points, height, state, room);
      }

      // Markers last and in the same order, so a marker is never hidden behind a wall
      // drawn after it.
      for (const { room, points } of rooms) {
        this._drawOccupants(room, points, height);
      }

      this._drawUnplacedShelf();
    }

    _drawWalls(points, height, state) {
      const { ctx } = this;
      // Each wall quad gets its own depth so the far ones do not paint over the near.
      const walls = [];
      for (let i = 0; i < points.length; i++) {
        const a = points[i];
        const b = points[(i + 1) % points.length];
        walls.push({ a, b, depth: this._depth([a, b]) });
      }
      walls.sort((p, q) => p.depth - q.depth);

      for (const { a, b } of walls) {
        const p1 = this._project(a[0], a[1], 0);
        const p2 = this._project(b[0], b[1], 0);
        const p3 = this._project(b[0], b[1], height);
        const p4 = this._project(a[0], a[1], height);
        ctx.beginPath();
        ctx.moveTo(p1.x, p1.y);
        ctx.lineTo(p2.x, p2.y);
        ctx.lineTo(p3.x, p3.y);
        ctx.lineTo(p4.x, p4.y);
        ctx.closePath();
        ctx.fillStyle =
          state === "occupied" ? "rgba(210,190,240,0.30)" : "rgba(150,130,190,0.13)";
        ctx.fill();
        ctx.strokeStyle =
          state === "occupied" ? "rgba(255,255,255,0.55)" : "rgba(160,140,200,0.28)";
        ctx.lineWidth = 1;
        ctx.stroke();
      }
    }

    _drawFloor(points, height, state, room) {
      const { ctx } = this;
      ctx.beginPath();
      points.forEach(([x, y], i) => {
        const p = this._project(x, y, height);
        if (i === 0) ctx.moveTo(p.x, p.y);
        else ctx.lineTo(p.x, p.y);
      });
      ctx.closePath();

      // Occupancy reads in LIGHTNESS, not hue — the occupant dots already use colour to
      // mean *who*, and making the room fill compete on the same channel is how both
      // stop being readable.
      if (state === "occupied") ctx.fillStyle = "rgba(255,252,245,0.90)";
      else ctx.fillStyle = "rgba(28,18,44,0.85)";
      ctx.fill();

      ctx.strokeStyle =
        state === "occupied" ? "rgba(255,255,255,0.9)" : "rgba(160,140,200,0.45)";
      ctx.lineWidth = state === "occupied" ? 2 : 1;
      // The third state is drawn dashed: one extra call, and it is the difference
      // between "empty" and "nothing can see this room".
      ctx.setLineDash(state === "unknown" ? [5, 4] : []);
      ctx.stroke();
      ctx.setLineDash([]);

      const centre = this._centroid(points, height);
      ctx.fillStyle = state === "occupied" ? "rgba(30,18,45,0.75)" : "rgba(200,180,235,0.45)";
      ctx.font = "600 11px system-ui, sans-serif";
      ctx.textAlign = "center";
      ctx.fillText(room.name || "", centre.x, centre.y + 4);
    }

    _centroid(points, z) {
      let x = 0;
      let y = 0;
      for (const [px, py] of points) {
        x += px;
        y += py;
      }
      return this._project(x / points.length, y / points.length, z);
    }

    _drawOccupants(room, points, height) {
      const occupants = room.occupants || [];
      const targets = room.targets || [];
      const radius = 17;

      // ANONYMOUS TARGETS FIRST. A radar sees a moving blob, not a name. These are
      // drawn where they actually are and left unlabelled, because identity comes from
      // BLE (room-level) and putting a name on the wrong blob is the failure that makes
      // a presence display worse than none.
      for (const target of targets) {
        this._drawAnonymousTarget(target, height);
      }

      if (!occupants.length) return;

      // Anyone the server could fuse to a single target is drawn AT that position.
      // Everyone else falls back to the room centroid, which is the room-level answer
      // this view has always given.
      const positioned = occupants.filter((p) => p.position);
      const unpositioned = occupants.filter((p) => !p.position);

      for (const person of positioned) {
        const p = this._project(person.position.x, person.position.y, height);
        this._drawFootprint(p);
        this._drawMarker(person, p.x, p.y - radius - 12, radius);
      }

      if (!unpositioned.length) return;
      const centre = this._centroid(points, height);
      const spread = Math.min(unpositioned.length - 1, 3) * (radius + 4);
      unpositioned.slice(0, 4).forEach((person, index) => {
        const x = centre.x - spread / 2 + index * (radius + 4);
        // Floated above the floor so the marker reads as standing in the room rather
        // than as painted on it — a flat sprite on the floor is unreadable at a glance,
        // which is the only way this view is ever used.
        this._drawMarker(person, x, centre.y - radius - 14, radius);
      });

      if (unpositioned.length > 4) {
        const { ctx } = this;
        ctx.fillStyle = "rgba(30,18,45,0.8)";
        ctx.font = "600 11px system-ui, sans-serif";
        ctx.fillText(`+${unpositioned.length - 4}`, centre.x + spread / 2 + radius, centre.y - radius - 10);
      }
    }

    /**
     * A dot on the floor under a positioned marker, so the eye can tell WHERE the
     * person is from where the disc merely floats. Without it a marker lifted 30px off
     * the floor reads as being 30px further back in the room.
     */
    _drawFootprint(p) {
      const { ctx } = this;
      ctx.beginPath();
      ctx.ellipse(p.x, p.y, 7, 3.5, 0, 0, Math.PI * 2);
      ctx.fillStyle = "rgba(30,18,45,0.35)";
      ctx.fill();
      ctx.beginPath();
      ctx.moveTo(p.x, p.y);
      ctx.lineTo(p.x, p.y - 12);
      ctx.strokeStyle = "rgba(30,18,45,0.35)";
      ctx.lineWidth = 1;
      ctx.stroke();
    }

    /**
     * Somebody is here, and the system does not know who. Drawn as a hollow puck — no
     * initial, no colour — because every visual language this view has for a PERSON is
     * reserved for people it can actually name.
     */
    _drawAnonymousTarget(target, height) {
      const { ctx } = this;
      const p = this._project(target.x, target.y, height);
      this._drawFootprint(p);
      ctx.beginPath();
      ctx.arc(p.x, p.y - 20, 11, 0, Math.PI * 2);
      ctx.fillStyle = "rgba(120,100,160,0.35)";
      ctx.fill();
      ctx.strokeStyle = "rgba(200,180,235,0.75)";
      ctx.lineWidth = 2;
      ctx.setLineDash([3, 3]);
      ctx.stroke();
      ctx.setLineDash([]);
      ctx.fillStyle = "rgba(30,18,45,0.8)";
      ctx.font = "700 12px system-ui, sans-serif";
      ctx.textAlign = "center";
      ctx.fillText("?", p.x, p.y - 16);
    }

    _drawMarker(person, x, y, radius) {
      const { ctx } = this;
      const colour = person.color || "#c084fc";
      const photo = this.photos.get(person.id);

      ctx.save();
      ctx.beginPath();
      ctx.arc(x, y, radius, 0, Math.PI * 2);
      ctx.closePath();

      if (photo) {
        ctx.clip();
        ctx.drawImage(photo, x - radius, y - radius, radius * 2, radius * 2);
        ctx.restore();
      } else {
        ctx.fillStyle = colour;
        ctx.fill();
        ctx.restore();
        // Dark glyph on the person's colour — every value in identity's palette is
        // mid-to-bright, so a white letter would vanish on the amber.
        ctx.fillStyle = "#101014";
        ctx.font = `700 ${Math.round(radius)}px system-ui, sans-serif`;
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.fillText(person.initial || "?", x, y + 1);
        ctx.textBaseline = "alphabetic";
      }

      // The ring stays even behind a photo: it is what ties this marker to the same
      // person's marker on the watch and in the admin panel.
      ctx.beginPath();
      ctx.arc(x, y, radius, 0, Math.PI * 2);
      ctx.strokeStyle = colour;
      ctx.lineWidth = 3;
      ctx.stroke();
    }

    /**
     * Everyone who is home but not locatable, along the bottom. A shelf rather than a
     * hidden list because these are the people you are most often looking for — the
     * whole reason somebody walks up to this screen.
     */
    _drawUnplacedShelf() {
      if (!this.unplaced.length) return;
      const { ctx } = this;
      const radius = 14;
      const y = this.height - radius - 10;
      let x = radius + 12;

      ctx.fillStyle = "rgba(234,220,255,0.55)";
      ctx.font = "12px system-ui, sans-serif";
      ctx.textAlign = "left";
      ctx.fillText("Home, room unknown:", 12, y - radius - 6);

      for (const person of this.unplaced.slice(0, 8)) {
        this._drawMarker(person, x, y, radius);
        x += radius * 2 + 8;
      }
    }
  }

  global.Floorplan3D = Floorplan3D;
})(typeof window !== "undefined" ? window : globalThis);
