/*
 * PebbleKit JS — runs on the phone, inside the Pebble app, only while the watchapp is
 * open. It fetches identity's /floorplan/presence, projects the plan into the watch's
 * usable box, packs it per WIREFORMAT.md, and sends one AppMessage.
 *
 * THE PROJECTION HAPPENS HERE, NOT ON THE WATCH
 * ----------------------------------------------
 * The watch has 64 KB of RAM and no floating point worth using. Everything that needs
 * arithmetic — the inscribed box for a round screen, scaling normalised polygons,
 * dropping rooms too small to draw — happens on the phone, and the watch receives
 * integers it can draw directly.
 *
 * THE ROUND SCREEN IS THE REASON THIS IS NOT TRIVIAL
 * ---------------------------------------------------
 * A floorplan is a rectangle; the Round 2's screen is a circle. The plan is inscribed
 * in it, so for a plan of aspect ratio a on usable diameter D:
 *
 *     w = D*a / sqrt(a^2+1)      h = D / sqrt(a^2+1)
 *
 * The corners of the screen are simply not available, which is why nothing important
 * (no status footer, no legend) is placed there.
 */

var CONFIG_KEY = "presenceConfig";

function config() {
  var stored = localStorage.getItem(CONFIG_KEY);
  var parsed = {};
  try {
    parsed = stored ? JSON.parse(stored) : {};
  } catch (e) {
    parsed = {};
  }
  return {
    apiUrl: (parsed.apiUrl || "").replace(/\/$/, ""),
    token: parsed.token || "",
    level: parsed.level || "",
  };
}

// identity's PERSON_COLORS, in order. Sent as an index rather than as RGB: one byte
// instead of three, and the watch holds the same table so the colour on the wrist is
// byte-identical to the one in the admin panel. Every value sits on the 2-bit-per-
// channel lattice a colour Pebble renders natively, which is why they survive the trip.
var PERSON_COLORS = [
  "#FF0000", "#0055FF", "#FFAA00", "#00AA00",
  "#AA00FF", "#00AAAA", "#FF55AA", "#AA5500",
];

function colourIndex(hex) {
  var upper = String(hex || "").toUpperCase();
  var index = PERSON_COLORS.indexOf(upper);
  return index === -1 ? 255 : index;
}

// --- byte writer ----------------------------------------------------------------------
function Writer() {
  this.bytes = [];
}
Writer.prototype.u8 = function (value) {
  this.bytes.push(Math.max(0, Math.min(255, value | 0)));
  return this;
};
Writer.prototype.str = function (text, cap) {
  // Truncated to whole BYTES, not characters — a UTF-8 sequence cut in half would
  // decode to a replacement glyph on the watch, so the cut is made on a code-point
  // boundary by shortening the string until it fits.
  var s = String(text || "");
  var encoded = utf8(s);
  while (encoded.length > cap) {
    s = s.slice(0, -1);
    encoded = utf8(s);
  }
  this.u8(encoded.length);
  for (var i = 0; i < encoded.length; i++) this.bytes.push(encoded[i]);
  return this;
};

function utf8(str) {
  var out = [];
  for (var i = 0; i < str.length; i++) {
    var c = str.charCodeAt(i);
    if (c < 0x80) out.push(c);
    else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
    else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
  }
  return out;
}

// --- projection -----------------------------------------------------------------------
/**
 * The box a rectangular plan gets on this watch, as fractions of the screen, then
 * quantised to 0..255. Returns a function mapping normalised plan coords into it.
 */
function projector(aspect, round) {
  if (!round) {
    // Rectangular screens use the whole area; the caller's margin is already applied.
    return function (x, y) {
      return [Math.round(x * 255), Math.round(y * 255)];
    };
  }
  var denom = Math.sqrt(aspect * aspect + 1);
  var w = aspect / denom;
  var h = 1 / denom;
  var ox = (1 - w) / 2;
  var oy = (1 - h) / 2;
  return function (x, y) {
    return [Math.round((ox + x * w) * 255), Math.round((oy + y * h) * 255)];
  };
}

// --- payload --------------------------------------------------------------------------
function build(plan, level, round, nameCap) {
  var writer = new Writer();
  var rooms = (level.rooms || []).filter(function (r) {
    return (r.points || []).length >= 3;
  });
  var unplaced = plan.unplaced || [];

  // A plan drawn wider than tall has aspect > 1. The polygons are already normalised to
  // the level's own extent, so the aspect is that extent's — which identity does not
  // report, and 1:1 is the honest assumption rather than a guess at the house's shape.
  var project = projector(1, round);

  writer.u8(1).u8(rooms.length).u8(unplaced.length).u8(plan.positions_available ? 1 : 0);

  rooms.forEach(function (room) {
    writer.str(room.name, 24);

    var occupants = room.occupants || [];
    var targets = room.targets || [];
    // Matches floorplan3d.js's rule exactly, and for the same reason: a radar target
    // means somebody IS in there. That the house cannot name them is a fact about the
    // house, not about the room being empty.
    var state = occupants.length || targets.length ? 1 : room.ha_area_id ? 0 : 2;
    writer.u8(state);

    writer.u8(room.points.length);
    room.points.forEach(function (p) {
      var xy = project(p[0], p[1]);
      writer.u8(xy[0]).u8(xy[1]);
    });

    writer.u8(targets.length);
    targets.forEach(function (t) {
      var xy = project(t.x, t.y);
      writer.u8(xy[0]).u8(xy[1]);
    });

    writer.u8(occupants.length);
    occupants.forEach(function (person) {
      writer.u8(colourIndex(person.color));
      writer.u8((person.initial || "?").charCodeAt(0));
      var hasPosition = !!person.position;
      writer.u8(hasPosition ? 1 : 0);
      if (hasPosition) {
        var xy = project(person.position.x, person.position.y);
        writer.u8(xy[0]).u8(xy[1]);
      }
      writer.str(nameCap ? person.name : "", nameCap);
    });
  });

  unplaced.forEach(function (person) {
    writer.u8(colourIndex(person.color));
    writer.u8((person.initial || "?").charCodeAt(0));
    writer.str(nameCap ? person.name : "", nameCap);
  });

  return writer.bytes;
}

/**
 * Build, and shed detail until it fits. Order is deliberate — see WIREFORMAT.md. It
 * never sends a truncated structure: a payload that decodes half-way is worse than one
 * that does not arrive, because the watch cannot tell "three rooms" from "three rooms
 * and then the buffer ran out".
 */
function buildWithinBudget(plan, level, round, budget) {
  var bytes = build(plan, level, round, 16);
  if (bytes.length <= budget) return { bytes: bytes, degraded: null };

  bytes = build(plan, level, round, 0);
  if (bytes.length <= budget) return { bytes: bytes, degraded: "names dropped" };

  var trimmed = {
    rooms: (level.rooms || []).filter(function (r) {
      return (r.occupants || []).length || (r.targets || []).length;
    }),
  };
  bytes = build(plan, trimmed, round, 0);
  if (bytes.length <= budget) return { bytes: bytes, degraded: "empty rooms dropped" };

  return { bytes: null, degraded: "plan too large for this watch" };
}

// --- fetch ----------------------------------------------------------------------------
function send(bytes, status) {
  var message = { STATUS: status || "" };
  if (bytes) message.PLAN = bytes;
  Pebble.sendAppMessage(message, function () {}, function (e) {
    console.log("presence: send failed: " + JSON.stringify(e));
  });
}

function refresh() {
  var settings = config();
  if (!settings.apiUrl || !settings.token) {
    send(null, "Not set up — open the app's settings on your phone.");
    return;
  }

  var request = new XMLHttpRequest();
  request.open("GET", settings.apiUrl + "/floorplan/presence", true);
  request.setRequestHeader("Authorization", "Bearer " + settings.token);
  request.timeout = 12000;

  request.onload = function () {
    if (request.status !== 200) {
      send(null, "identity said " + request.status);
      return;
    }
    var plan;
    try {
      plan = JSON.parse(request.responseText);
    } catch (e) {
      send(null, "Bad response from identity");
      return;
    }
    var levels = plan.levels || [];
    if (!levels.length) {
      send(null, "No floorplan drawn yet");
      return;
    }
    var level =
      levels.filter(function (l) {
        return String(l.id) === String(settings.level);
      })[0] || levels[0];

    var round = Pebble.getActiveWatchInfo
      ? ["chalk"].indexOf(Pebble.getActiveWatchInfo().platform) !== -1
      : false;

    var result = buildWithinBudget(plan, level, round, 1800);
    if (!result.bytes) {
      send(null, result.degraded);
      return;
    }
    send(result.bytes, result.degraded || level.name || "");
  };

  request.ontimeout = function () {
    // Off the home network without the tunnel up is the common case, and it deserves
    // its own sentence rather than a generic failure — the watch shows its last state
    // with an age on it either way.
    send(null, "No answer — is the WireGuard tunnel up?");
  };
  request.onerror = function () {
    send(null, "Could not reach identity");
  };
  request.send();
}

Pebble.addEventListener("ready", refresh);
Pebble.addEventListener("appmessage", refresh); // the watch asking for a refresh

Pebble.addEventListener("showConfiguration", function () {
  var settings = config();
  var html =
    "data:text/html," +
    encodeURIComponent(
      "<!doctype html><meta name=viewport content='width=device-width,initial-scale=1'>" +
        "<style>body{font-family:system-ui;margin:18px;background:#111;color:#eee}" +
        "label{display:block;margin:14px 0 4px}input{width:100%;padding:10px;font-size:16px;" +
        "border-radius:8px;border:1px solid #555;background:#222;color:#eee}" +
        "button{margin-top:18px;padding:12px 20px;font-size:16px;border:0;border-radius:8px;" +
        "background:#c084fc}small{color:#999;display:block;margin-top:6px}</style>" +
        "<h2>Who's home</h2>" +
        "<label>identity URL</label><input id=u placeholder='http://192.168.30.12:8097' value='" +
        settings.apiUrl +
        "'><small>Reachable from your phone — on the LAN directly, off it over WireGuard. " +
        "Nothing here is exposed to the internet.</small>" +
        "<label>Bearer token</label><input id=t value='" +
        settings.token +
        "'><small>identity's IDENTITY_TOKEN. It can read the household's presence " +
        "history, so treat it as one.</small>" +
        "<label>Level id (optional)</label><input id=l value='" +
        settings.level +
        "'><small>Blank uses the first level.</small>" +
        "<button onclick=\"location.href='pebblejs://close#'+encodeURIComponent(JSON.stringify(" +
        "{apiUrl:document.getElementById('u').value,token:document.getElementById('t').value," +
        "level:document.getElementById('l').value}))\">Save</button>"
    );
  Pebble.openURL(html);
});

Pebble.addEventListener("webviewclosed", function (event) {
  if (!event.response) return;
  try {
    localStorage.setItem(CONFIG_KEY, JSON.stringify(JSON.parse(decodeURIComponent(event.response))));
    refresh();
  } catch (e) {
    console.log("presence: bad settings payload");
  }
});

// Exported for the headless test in tools/. Harmless on the phone, where `module` is
// undefined — and worth having, because the writer and the C reader agreeing is the one
// thing in this app that cannot be checked by looking at it.
if (typeof module !== "undefined") {
  module.exports = { build: build, buildWithinBudget: buildWithinBudget, projector: projector, utf8: utf8 };
}
