/**
 * Pure functions with no Lit/DOM dependencies — easy to cover with unit tests.
 */
import { union } from 'polyclip-ts';

/** Zigbee LQI color: ≤40 — red, ≥180 — green, in between — an hsl gradient. */
export function lqiColor(lqi: number): string {
  const hue = Math.max(0, Math.min(120, ((lqi - 40) / 140) * 120));
  return `hsl(${Math.round(hue)}, 85%, 55%)`;
}

/** Snap a coordinate to the nearest grid node with step pitch. */
export function snapToGrid(v: number, pitch: number): number {
  return Math.round(v / pitch) * pitch;
}

/** Real-world length (cm) of a segment given the grid pitch (render units per cell) and cm per cell. */
export function segmentCm(a: number[], b: number[], gridPitch: number, cellCm: number): number {
  const cells = Math.hypot(b[0] - a[0], b[1] - a[1]) / gridPitch;
  return cells * cellCm;
}

/** Format a length (cm) for display: metric metres ("1.25 m") or imperial feet+inches ("4′ 1″"). */
export function formatLength(cm: number, imperial: boolean): string {
  if (imperial) {
    const totalIn = cm / 2.54;
    let ft = Math.floor(totalIn / 12);
    let inch = Math.round(totalIn - ft * 12);
    if (inch === 12) { ft += 1; inch = 0; }
    return `${ft}′ ${inch}″`;
  }
  return `${(cm / 100).toFixed(2)} m`;
}

/**
 * Canonical key of a segment (independent of direction).
 * `prec` = decimals used to compare coordinates: the default 1 suits render units,
 * normalized (0..1) coordinates need more (see roomEdges).
 */
export function segKey(a: number[], b: number[], prec = 1): string {
  const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
  return `${p[0].toFixed(prec)},${p[1].toFixed(prec)}-${q[0].toFixed(prec)},${q[1].toFixed(prec)}`;
}

/**
 * Wall segments derived from room outlines (normalized coordinates in and out).
 *
 * A line has no independent existence on the plan: it can only be an edge of a closed
 * room. Shared walls are emitted once, which is what makes deleting a room keep the
 * borders its neighbours still contribute — the neighbour's polygon still yields them.
 */
export function roomPoly(r: any): number[][] | null {
  if (r?.poly?.length >= 3) return r.poly;
  if (r && r.x != null && r.y != null && r.w != null && r.h != null)
    return [[r.x, r.y], [r.x + r.w, r.y], [r.x + r.w, r.y + r.h], [r.x, r.y + r.h]];
  return null;
}

export function roomEdges(rooms: any[]): number[][] {
  const out: number[][] = [];
  const seen = new Set<string>();
  for (const r of rooms || []) {
    const pts = roomPoly(r);
    if (!pts) continue;
    for (let i = 0; i < pts.length; i++) {
      const a = pts[i];
      const b = pts[(i + 1) % pts.length];
      const k = segKey(a, b, 5);
      if (seen.has(k)) continue;
      seen.add(k);
      out.push([a[0], a[1], b[0], b[1]]);
    }
  }
  return out;
}

/**
 * Snap a point onto the nearest wall of any room, returning the snapped point and
 * the wall's angle (degrees), or null when no wall is within maxDist. Walls are the
 * DERIVED room edges (a line has no independent existence on the plan), so an opening
 * placed with this stays valid however rooms are later edited — it keeps absolute
 * coordinates and is not tied to a room id or edge index.
 */
export function snapToWall(
  p: number[], rooms: any[], maxDist: number,
): { x: number; y: number; angle: number } | null {
  let best: { x: number; y: number; angle: number } | null = null;
  let bestD = maxDist;
  for (const e of roomEdges(rooms)) {
    const [x1, y1, x2, y2] = e;
    const dx = x2 - x1, dy = y2 - y1;
    const len2 = dx * dx + dy * dy;
    if (!len2) continue;
    let t = ((p[0] - x1) * dx + (p[1] - y1) * dy) / len2;
    t = Math.max(0, Math.min(1, t));
    const q = [x1 + t * dx, y1 + t * dy];
    const d = Math.hypot(p[0] - q[0], p[1] - q[1]);
    if (d < bestD) {
      bestD = d;
      // normalize to [-90, 90): two rooms sharing a wall yield the same edge in
      // OPPOSITE directions — without this, dragging an opening across segment
      // boundaries would flip its hinge side back and forth
      let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
      if (angle >= 90) angle -= 180;
      else if (angle < -90) angle += 180;
      best = { x: q[0], y: q[1], angle };
    }
  }
  return best;
}

/**
 * How far open an opening is drawn, 0..1, from its contact sensor state.
 * No sensor bound → doors default to open (the familiar swing symbol), windows to
 * closed (intact glass) — same convention as a static architectural plan.
 * `unavailable`/`unknown` freeze the default too: an outage must not fake motion.
 */
export function openingAmount(
  type: 'door' | 'window', state: string | null | undefined, invert = false,
): number {
  if (state == null || state === 'unavailable' || state === 'unknown')
    return type === 'door' ? 1 : 0;
  const open = isActiveState(state) !== !!invert;
  return open ? 1 : 0;
}

/**
 * Is an entity "active / detected"? Used by presence ripples, which are opted into per
 * device and therefore must not depend on the card-wide live_states toggle.
 * Anything unknown — including `unavailable` — counts as idle: a sensor outage should
 * calm the plan down, never leave a ring pulsing forever.
 */
export function isActiveState(state?: string | null): boolean {
  return ['on', 'open', 'home', 'detected', 'playing', 'cleaning'].includes(String(state));
}

/** Point equality within a tolerance. */
export function samePoint(a: number[], b: number[], eps = 0.001): boolean {
  return Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps;
}

/** Point inside a polygon (ray casting). */
export function pointInPolygon(p: number[], poly: number[][]): boolean {
  let inside = false;
  for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
    const [xi, yi] = poly[i];
    const [xj, yj] = poly[j];
    if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
  }
  return inside;
}

/** Distance from p to segment ab. */
function distToSeg(p: number[], a: number[], b: number[]): number {
  const dx = b[0] - a[0];
  const dy = b[1] - a[1];
  const len2 = dx * dx + dy * dy;
  let t = len2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2 : 0;
  t = Math.max(0, Math.min(1, t));
  return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
}

/**
 * Is p on the outline itself (within eps)? This is the normal case, not an anomaly:
 * neighbouring rooms share walls, so their vertices sit on each other's outlines —
 * including mid-span, since real walls overlap collinearly rather than match exactly.
 */
/**
 * Project a point onto the nearest edge of a polygon and return that point,
 * or null when the polygon has no edges. Used to snap a Split click onto the
 * actual wall (rooms may not be grid-aligned — imported polygons, older configs),
 * so cutting no longer requires hitting a grid node exactly on the outline.
 */
export function closestPointOnBoundary(p: number[], poly: number[][]): number[] | null {
  if (!poly || poly.length < 2) return null;
  let best: number[] | null = null;
  let bestD = Infinity;
  for (let i = 0; i < poly.length; i++) {
    const a = poly[i], b = poly[(i + 1) % poly.length];
    const dx = b[0] - a[0], dy = b[1] - a[1];
    const len2 = dx * dx + dy * dy;
    let t = len2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2 : 0;
    t = Math.max(0, Math.min(1, t));
    const q = [a[0] + t * dx, a[1] + t * dy];
    const d = Math.hypot(p[0] - q[0], p[1] - q[1]);
    if (d < bestD) { bestD = d; best = q; }
  }
  return best;
}

export function pointOnBoundary(p: number[], poly: number[][], eps = 1e-6): boolean {
  if (!poly || poly.length < 2) return false;
  for (let i = 0; i < poly.length; i++)
    if (distToSeg(p, poly[i], poly[(i + 1) % poly.length]) <= eps) return true;
  return false;
}

/** Inside the outline AND not on it — a point on a shared wall is not "inside". */
export function pointStrictlyInside(p: number[], poly: number[][], eps = 1e-6): boolean {
  if (!poly || poly.length < 3) return false;
  if (pointOnBoundary(p, poly, eps)) return false;
  return pointInPolygon(p, poly);
}

function cross3(a: number[], b: number[], c: number[]): number {
  return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
}

/**
 * Do two segments cross transversally? Touching at an endpoint and collinear overlap
 * deliberately do NOT count — that is what sharing a wall looks like.
 */
export function segmentsProperlyCross(
  p1: number[], p2: number[], p3: number[], p4: number[], eps = 1e-9,
): boolean {
  const d1 = cross3(p3, p4, p1);
  const d2 = cross3(p3, p4, p2);
  const d3 = cross3(p1, p2, p3);
  const d4 = cross3(p1, p2, p4);
  return (
    ((d1 > eps && d2 < -eps) || (d1 < -eps && d2 > eps)) &&
    ((d3 > eps && d4 < -eps) || (d3 < -eps && d4 > eps))
  );
}

/** Is any area of outline `a` strictly inside `b`? Also catches nested and duplicate outlines. */
function coversArea(a: number[][], b: number[][], eps: number): boolean {
  let allOnBoundary = true;
  for (const v of a) {
    if (pointStrictlyInside(v, b, eps)) return true;
    if (!pointOnBoundary(v, b, eps)) allOnBoundary = false;
  }
  // every vertex sits on b's outline → a duplicate or traced outline: probe the middle
  if (allOnBoundary) {
    const c = [
      a.reduce((s, p) => s + p[0], 0) / a.length,
      a.reduce((s, p) => s + p[1], 0) / a.length,
    ];
    return pointStrictlyInside(c, b, eps);
  }
  return false;
}

/** Is `inner` fully contained in `outer` (edges may touch, never cross)? */
export function polyContainsPoly(outer: number[][], inner: number[][], eps = 1e-6): boolean {
  if (!outer || !inner || outer.length < 3 || inner.length < 3) return false;
  for (let i = 0; i < inner.length; i++)
    for (let j = 0; j < outer.length; j++)
      if (segmentsProperlyCross(inner[i], inner[(i + 1) % inner.length], outer[j], outer[(j + 1) % outer.length]))
        return false;
  for (const v of inner)
    if (!pointStrictlyInside(v, outer, eps) && !pointOnBoundary(v, outer, eps)) return false;
  // identical/traced outlines are NOT containment — probe the centroid strictness both ways
  const c = [
    inner.reduce((s, p) => s + p[0], 0) / inner.length,
    inner.reduce((s, p) => s + p[1], 0) / inner.length,
  ];
  return pointStrictlyInside(c, outer, eps) && polygonArea(inner) < polygonArea(outer) - eps;
}

/**
 * Do two room outlines ILLEGALLY share floor area? Sharing a wall (fully or
 * partially) and touching at a corner are normal. Since v1.34.0 full nesting is
 * legal too (island rooms: a column inside a ring, an inner room) — only edge
 * crossings and PARTIAL overlaps are rejected.
 */
export function roomsOverlap(a: number[][], b: number[][], eps = 1e-6): boolean {
  if (!a || !b || a.length < 3 || b.length < 3) return false;
  for (let i = 0; i < a.length; i++)
    for (let j = 0; j < b.length; j++)
      if (segmentsProperlyCross(a[i], a[(i + 1) % a.length], b[j], b[(j + 1) % b.length])) return true;
  if (polyContainsPoly(a, b, eps) || polyContainsPoly(b, a, eps)) return false;
  return coversArea(a, b, eps) || coversArea(b, a, eps);
}

/**
 * Direct islands of `poly` among `others`: outlines fully inside it that are not
 * themselves inside a bigger island (those are subtracted by their parent).
 */
export function islandsOf(poly: number[][], others: number[][][], eps = 1e-6): number[][][] {
  const inside = others.filter((o) => polyContainsPoly(poly, o, eps));
  return inside.filter((o) => !inside.some((p) => p !== o && polyContainsPoly(p, o, eps)));
}

/** Shoelace area of an outline (absolute value). */
export function polygonArea(poly: number[][]): number {
  if (!poly || poly.length < 3) return 0;
  let s = 0;
  for (let i = 0; i < poly.length; i++) {
    const a = poly[i];
    const b = poly[(i + 1) % poly.length];
    s += a[0] * b[1] - b[0] * a[1];
  }
  return Math.abs(s) / 2;
}

function closedRing(poly: number[][]): number[][][] {
  return [[...poly.map((p) => [p[0], p[1]]), [poly[0][0], poly[0][1]]]];
}

/**
 * Union of two room outlines, or null when they may not be merged.
 *
 * "Adjacent" is decided by the result rather than by a separate heuristic: only rooms that
 * genuinely share a wall (fully or partially — real walls overlap collinearly rather than
 * match exactly) collapse into ONE hole-free outline. Rooms that merely touch at a corner,
 * that are apart, or whose union would enclose a hole do not, and are refused.
 */
export function mergeRooms(a: number[][], b: number[][]): number[][] | null {
  if (!a || !b || a.length < 3 || b.length < 3) return null;
  const res = union(closedRing(a) as any, closedRing(b) as any);
  if (res.length !== 1) return null;      // two pieces → not adjacent
  if (res[0].length !== 1) return null;   // a ring plus holes → not a simple room
  const pts = res[0][0].slice(0, -1).map((p: number[]) => [p[0], p[1]]); // drop the closing point
  return pts.length >= 3 ? pts : null;
}

/** Index of the outline edge that p sits on, or -1. */
function edgeIndexOf(poly: number[][], p: number[], eps: number): number {
  for (let i = 0; i < poly.length; i++)
    if (distToSeg(p, poly[i], poly[(i + 1) % poly.length]) <= eps) return i;
  return -1;
}

function dropRepeats(pts: number[][], eps: number): number[][] {
  const out: number[][] = [];
  for (const p of pts) if (!out.length || !samePoint(out[out.length - 1], p, eps)) out.push(p);
  if (out.length > 1 && samePoint(out[0], out[out.length - 1], eps)) out.pop();
  return out;
}

/**
 * Cut a room in two with a straight chord between two points on its walls.
 * Returns the two parts, or null when the cut is not a clean wall-to-wall chord:
 * an end that is not on a wall, a chord that leaves the room (concave outlines) or that
 * runs along a wall and would carve off a zero-area sliver.
 */
export function splitRoom(
  poly: number[][], a: number[], b: number[], eps = 1e-6,
): [number[][], number[][]] | null {
  return splitRoomPath(poly, [a, b], eps);
}

/**
 * Split a room along a polyline: first and last points on walls, intermediate
 * vertices strictly inside the room. A two-point path is the classic straight
 * chord. Returns the two parts, or null when the path is not a clean cut.
 */
export function splitRoomPath(
  poly: number[][], pts: number[][], eps = 1e-6,
): [number[][], number[][]] | null {
  if (!poly || poly.length < 3 || !pts || pts.length < 2) return null;
  const a = pts[0];
  const b = pts[pts.length - 1];
  if (samePoint(a, b, eps)) return null;
  const ia = edgeIndexOf(poly, a, eps);
  const ib = edgeIndexOf(poly, b, eps);
  if (ia < 0 || ib < 0) return null;                       // an end is not on a wall
  const mids = pts.slice(1, -1);
  for (const m of mids) if (!pointStrictlyInside(m, poly, eps)) return null;
  // no path segment may cross a wall
  for (let sI = 0; sI < pts.length - 1; sI++)
    for (let i = 0; i < poly.length; i++)
      if (segmentsProperlyCross(pts[sI], pts[sI + 1], poly[i], poly[(i + 1) % poly.length])) return null;
  // the path may not properly self-intersect
  for (let sI = 0; sI < pts.length - 1; sI++)
    for (let t = sI + 2; t < pts.length - 1; t++)
      if (segmentsProperlyCross(pts[sI], pts[sI + 1], pts[t], pts[t + 1])) return null;
  // a straight chord lying along a wall has its midpoint ON the outline, not inside
  if (pts.length === 2 && !pointStrictlyInside([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], poly, eps))
    return null;
  const walk = (from: number[], fromIdx: number, to: number[], toIdx: number): number[][] => {
    const acc: number[][] = [from];
    let i = (fromIdx + 1) % poly.length;
    for (let guard = 0; guard <= poly.length; guard++) {
      acc.push(poly[i]);
      if (i === toIdx) break;
      i = (i + 1) % poly.length;
    }
    acc.push(to);
    return dropRepeats(acc, eps);
  };
  const p1 = dropRepeats([...walk(a, ia, b, ib), ...[...mids].reverse()], eps);
  const p2 = dropRepeats([...walk(b, ib, a, ia), ...mids], eps);
  if (p1.length < 3 || p2.length < 3) return null;
  if (polygonArea(p1) <= eps || polygonArea(p2) <= eps) return null;
  return [p1, p2];
}

/**
 * Marker id by binding: device → device_id, entity → 'lg_'+entity_id,
 * virtual → the passed-in existing (if it is already a v_ marker) or a new one via newId().
 */
export function markerIdForBinding(
  binding: string,
  existingId: string | undefined,
  newId: () => string,
): string {
  const [kind, ref] = binding.split(':');
  if (kind === 'device') return ref;
  if (kind === 'entity') return 'lg_' + ref;
  return existingId && existingId.startsWith('v_') ? existingId : newId();
}

/** Average LQI over a set of values (or null). */
export function averageLqi(values: number[]): number | null {
  if (!values.length) return null;
  return Math.round(values.reduce((a, b) => a + b, 0) / values.length);
}

/** “Contain” rectangle with the given aspect (w/h) that fits the whole vb [x,y,w,h]. */
export function fitView(vb: number[], aspect: number): { x: number; y: number; w: number; h: number } {
  const planA = vb[2] / vb[3];
  if (aspect > planA) {
    const h = vb[3], w = vb[3] * aspect;
    return { x: vb[0] - (w - vb[2]) / 2, y: vb[1], w, h };
  }
  const w = vb[2], h = vb[2] / aspect;
  return { x: vb[0], y: vb[1] - (h - vb[3]) / 2, w, h };
}

/** Push points apart: no closer than minDist to each other, within rectangle b with padding pad. Mutates pts. */
export function declump(
  pts: { x: number; y: number }[],
  b: { x: number; y: number; w: number; h: number },
  minDist: number,
  pad: number,
): void {
  if (pts.length < 2) return;
  const minX = b.x + pad, maxX = b.x + b.w - pad, minY = b.y + pad, maxY = b.y + b.h - pad;
  for (let it = 0; it < 60; it++) {
    let moved = false;
    for (let i = 0; i < pts.length; i++) {
      for (let j = i + 1; j < pts.length; j++) {
        const dx = pts[j].x - pts[i].x, dy = pts[j].y - pts[i].y;
        const dist = Math.hypot(dx, dy) || 0.001;
        if (dist < minDist) {
          const push = (minDist - dist) / 2;
          const ux = dx / dist, uy = dy / dist;
          pts[i].x -= ux * push; pts[i].y -= uy * push;
          pts[j].x += ux * push; pts[j].y += uy * push;
          moved = true;
        }
      }
    }
    for (const q of pts) {
      q.x = Math.max(minX, Math.min(maxX, q.x));
      q.y = Math.max(minY, Math.min(maxY, q.y));
    }
    if (!moved) break;
  }
}

/**
 * Safe URL for <a href>: only http(s) and relative paths are allowed.
 * Rejects javascript:, data: and other dangerous schemes (XSS via config).
 */
export function safeUrl(url: string | null | undefined): string | null {
  if (!url) return null;
  const u = url.trim();
  if (/^(https?:)?\/\//i.test(u) || u.startsWith('/') || /^[\w./#?=&%~-]+$/i.test(u)) {
    if (/^[a-z][\w+.-]*:/i.test(u) && !/^https?:/i.test(u)) return null;
    return u;
  }
  return null;
}

// ---------------- tap actions ----------------

export type TapAction = 'info' | 'more-info' | 'toggle';

/** Domains a card-wide `tap_action: toggle` may toggle (accidental-tap safe). */
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier']);

/**
 * Domains that must NEVER toggle from a plan tap, even with an explicit
 * per-device override: an accidental tap unlocking a door or disarming an
 * alarm is a security incident, not a UX shortcut.
 */
export const TOGGLE_FORBIDDEN_DOMAINS = new Set(['lock', 'alarm_control_panel']);

/**
 * Resolve the effective tap action for a device icon.
 *
 * Order: per-device override → card-wide default → 'info'.
 * 'toggle' is applied conservatively: a card-wide toggle only affects
 * TOGGLE_SAFE_DOMAINS; an explicit per-device toggle affects any domain
 * except TOGGLE_FORBIDDEN_DOMAINS. Everything else falls back to 'info'.
 */
export function resolveTapAction(
  explicit: string | null | undefined,
  cardDefault: string | null | undefined,
  domain: string | null | undefined,
): TapAction {
  const want = explicit || cardDefault || 'info';
  if (want === 'more-info') return 'more-info';
  if (want !== 'toggle') return 'info';
  if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info';
  if (explicit === 'toggle') return 'toggle';
  return TOGGLE_SAFE_DOMAINS.has(domain) ? 'toggle' : 'info';
}

// ---------------- floors import ----------------

export interface FloorInfo {
  id: string;
  name: string;
  level: number | null;
}

/** HA floor registry → a list ordered by level (unknown levels last), then name. */
export function floorsOf(hass: any): FloorInfo[] {
  const reg = hass?.floors;
  if (!reg || typeof reg !== 'object') return [];
  const list: FloorInfo[] = [];
  for (const f of Object.values<any>(reg)) {
    if (!f || !f.floor_id) continue;
    list.push({ id: f.floor_id, name: f.name || f.floor_id, level: f.level ?? null });
  }
  list.sort((a, b) => {
    const la = a.level ?? 1e9;
    const lb = b.level ?? 1e9;
    return la !== lb ? la - lb : a.name.localeCompare(b.name);
  });
  return list;
}

/** Substitute every occurrence of {name} placeholders in a template string. */
export function subst(s: string, vars?: Record<string, string | number>): string {
  if (!vars) return s;
  let out = s;
  for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v));
  return out;
}

// ---------------- room fills & colors ----------------

export type RoomFillMode = 'none' | 'lqi' | 'light' | 'temp' | 'glow';

/** Per-space display settings with their defaults resolved. */
export interface SpaceDisplay {
  showBorders: boolean;
  showNames: boolean;
  color: string;    // hex like #3ea6ff
  opacity: number;  // 0..1 — applied to borders, names and fills
  fill: RoomFillMode;
  tempMin: number; // comfort range lower bound, °C
  tempMax: number; // comfort range upper bound, °C
  /** Per-space LQI badges near zigbee devices; null = follow the card option. */
  showLqi: boolean | null;
  /** Room-card metrics under the room name (all default off). */
  labelTemp: boolean;
  labelHum: boolean;
  labelLqi: boolean;
  labelLight: boolean;
}

export const DEFAULT_ROOM_COLOR = '#3ea6ff';
export const DEFAULT_ROOM_OPACITY = 0.55;
export const DEFAULT_TEMP_MIN = 20;
export const DEFAULT_TEMP_MAX = 25;

/** Resolve a space's display settings; spaces without a plan default to visible markup. */
export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
  const s = spaceCfg?.settings || {};
  const noPlan = !spaceCfg?.plan_url;
  return {
    showBorders: s.show_borders ?? noPlan,
    showNames: s.show_names ?? noPlan,
    color: typeof s.room_color === 'string' && /^#[0-9a-f]{6}$/i.test(s.room_color) ? s.room_color : DEFAULT_ROOM_COLOR,
    opacity: typeof s.room_opacity === 'number' ? Math.min(1, Math.max(0, s.room_opacity)) : DEFAULT_ROOM_OPACITY,
    fill: ['lqi', 'light', 'temp', 'glow'].includes(s.fill_mode) ? s.fill_mode : 'none',
    tempMin: typeof s.temp_min === 'number' ? s.temp_min : DEFAULT_TEMP_MIN,
    tempMax: typeof s.temp_max === 'number' ? s.temp_max : DEFAULT_TEMP_MAX,
    showLqi: typeof s.show_lqi === 'boolean' ? s.show_lqi : null,
    labelTemp: s.label_temp === true,
    labelHum: s.label_hum === true,
    labelLqi: s.label_lqi === true,
    labelLight: s.label_light === true,
  };
}

// ---------------- global fill colors ----------------

export interface FillColorEntry {
  c: string; // #rrggbb
  a: number; // 0..1 fill opacity
}

/** Global fill palette, grouped by fill mode; stored in config.settings.fill_colors. */
export interface FillColors {
  light_on: FillColorEntry;
  light_off: FillColorEntry;
  /** Rooms with no light sources at all; alpha 0 (default) = no fill, as before. */
  light_none: FillColorEntry;
  temp_cold: FillColorEntry;
  temp_ok: FillColorEntry;
  temp_hot: FillColorEntry;
  lqi_low: FillColorEntry;
  lqi_high: FillColorEntry;
  /** Glow mode: uniform "darkness" over every room + default light color. */
  glow_base: FillColorEntry;
  glow_light: FillColorEntry;
}

export const DEFAULT_FILL_COLORS: FillColors = {
  light_on: { c: '#ffd45c', a: 0.18 },
  light_off: { c: '#9aa0a6', a: 0.14 },
  light_none: { c: '#6b7480', a: 0 },
  temp_cold: { c: '#4fc3f7', a: 0.18 },
  temp_ok: { c: '#66d17a', a: 0.18 },
  temp_hot: { c: '#ffd45c', a: 0.18 },
  lqi_low: { c: '#f25a4a', a: 0.18 },
  lqi_high: { c: '#4bd28f', a: 0.18 },
  glow_base: { c: '#0d1b2a', a: 0.5 },
  glow_light: { c: '#ffd9a0', a: 0.85 },
};

const HEX_RE = /^#[0-9a-f]{6}$/i;

/** Merge stored overrides over the defaults, dropping malformed entries. */
export function fillColorsOf(settings: any): FillColors {
  const out: any = {};
  const src = settings?.fill_colors || {};
  for (const k of Object.keys(DEFAULT_FILL_COLORS) as (keyof FillColors)[]) {
    const d = DEFAULT_FILL_COLORS[k];
    const v = src[k];
    out[k] = {
      c: v && typeof v.c === 'string' && HEX_RE.test(v.c) ? v.c : d.c,
      a: v && typeof v.a === 'number' ? Math.min(1, Math.max(0, v.a)) : d.a,
    };
  }
  return out as FillColors;
}

/** Linear RGB interpolation between two hex colors, t clamped to 0..1. */
export function lerpColor(a: string, b: string, t: number): string {
  const tt = Math.min(1, Math.max(0, t));
  const pa = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16));
  const pb = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16));
  const mix = pa.map((v, i) => Math.round(v + (pb[i] - v) * tt));
  return '#' + mix.map((v) => v.toString(16).padStart(2, '0')).join('');
}

/**
 * Room fill (color + opacity) for the selected mode, or null for "no fill",
 * using the global palette. The LQI gradient interpolates lqi_low → lqi_high
 * over the 40..180 LQI window (same thresholds as the badge color).
 */
export function roomFillStyle(
  mode: RoomFillMode,
  lqi: number | null,
  lights: 'on' | 'off' | 'none',
  temp: number | null | undefined,
  tempMin: number,
  tempMax: number,
  colors: FillColors,
): FillColorEntry | null {
  if (mode === 'lqi') {
    if (lqi == null) return null;
    const t = (lqi - 40) / 140;
    return { c: lerpColor(colors.lqi_low.c, colors.lqi_high.c, t),
             a: colors.lqi_low.a + (colors.lqi_high.a - colors.lqi_low.a) * Math.min(1, Math.max(0, t)) };
  }
  if (mode === 'light') {
    if (lights === 'none') {
      // configurable "no light sources" color; alpha 0 keeps the historical
      // no-fill behavior (and the unfilled hover), so nothing changes until
      // the user assigns an opacity
      return colors.light_none.a > 0 ? colors.light_none : null;
    }
    return lights === 'on' ? colors.light_on : colors.light_off;
  }
  if (mode === 'temp') {
    if (temp == null) return null;
    const lo = Math.min(tempMin, tempMax);
    const hi = Math.max(tempMin, tempMax);
    if (temp < lo) return colors.temp_cold;
    if (temp > hi) return colors.temp_hot;
    return colors.temp_ok;
  }
  return null;
}

/**
 * Room fill color for the selected mode, or null for "no fill".
 * - lqi: red→green gradient by the room's average zigbee signal (null LQI → no fill)
 * - light: warm yellow when any light in the room is on, grey when all known
 *   lights are off; rooms without lights get no fill (a bathroom without smart
 *   bulbs should not look permanently "off").
 */
export function roomFillColor(
  mode: RoomFillMode,
  lqi: number | null,
  lights: 'on' | 'off' | 'none',
  temp?: number | null,
  tempMin: number = DEFAULT_TEMP_MIN,
  tempMax: number = DEFAULT_TEMP_MAX,
): string | null {
  if (mode === 'lqi') return lqi == null ? null : lqiColor(lqi);
  if (mode === 'light') {
    if (lights === 'none') return null;
    return lights === 'on' ? '#ffd45c' : '#9aa0a6';
  }
  if (mode === 'temp') {
    // blue below the comfort range, green inside, yellow above; no reading → no fill.
    // Bounds are swapped automatically if entered in the wrong order.
    if (temp == null) return null;
    const lo = Math.min(tempMin, tempMax);
    const hi = Math.max(tempMin, tempMax);
    if (temp < lo) return '#4fc3f7';
    if (temp > hi) return '#ffd45c';
    return '#66d17a';
  }
  return null;
}

// ---------------- state-reflecting icons ----------------

/**
 * Swap the auto icon for a state variant (open door, unlocked lock…), like core
 * HA does. Conservative: only well-known pairs, only when the user has NOT set
 * a custom icon, and unknown/unavailable states keep the base icon.
 */
export function stateIcon(
  base: string,
  domain: string | null | undefined,
  deviceClass: string | null | undefined,
  state: string | null | undefined,
  hasCustomIcon: boolean,
): string {
  if (hasCustomIcon || !state || state === 'unavailable' || state === 'unknown') return base;
  if (domain === 'binary_sensor') {
    if (deviceClass === 'door') return state === 'on' ? 'mdi:door-open' : 'mdi:door-closed';
    if (deviceClass === 'window') return state === 'on' ? 'mdi:window-open' : 'mdi:window-closed';
    if (deviceClass === 'garage_door') return state === 'on' ? 'mdi:garage-open-variant' : 'mdi:garage-variant';
  }
  if (domain === 'lock') return state === 'locked' ? 'mdi:lock' : 'mdi:lock-open-variant';
  if (domain === 'light' && base === 'mdi:lightbulb') return state === 'on' ? 'mdi:lightbulb-on' : base;
  return base;
}

// ---------------- light color & alarm states ----------------

/**
 * The current color of a light entity as a CSS color, or null when it is off,
 * unavailable or reports no usable color. rgb_color is the source of truth
 * (HA normalizes hs/xy into it); brightness is deliberately ignored — a dim
 * red bulb should still read as red on the plan.
 */
export function lightColorOf(state: any): string | null {
  if (!state || state.state !== 'on') return null;
  const rgb = state.attributes?.rgb_color;
  if (Array.isArray(rgb) && rgb.length >= 3 && rgb.every((v: any) => Number.isFinite(v))) {
    return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
  }
  return null;
}

// ---------------- glow fill (light sources) ----------------

/** Blackbody color temperature → RGB (Tanner Helland approximation). */
export function kelvinToRgb(kelvin: number): [number, number, number] {
  const t = Math.min(40000, Math.max(1000, kelvin)) / 100;
  const r = t <= 66 ? 255 : 329.698727446 * Math.pow(t - 60, -0.1332047592);
  const g = t <= 66
    ? 99.4708025861 * Math.log(t) - 161.1195681661
    : 288.1221695283 * Math.pow(t - 60, -0.0755148492);
  const b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307;
  const cl = (v: number) => Math.round(Math.min(255, Math.max(0, v)));
  return [cl(r), cl(g), cl(b)];
}

/**
 * Color and relative brightness of a light's glow: rgb_color as is, else the
 * color temperature via blackbody, else the configured fallback. Off → null.
 */
export function glowColorOf(state: any, fallback: string): { c: string; bri: number } | null {
  if (!state || state.state !== 'on') return null;
  const a = state.attributes || {};
  const briRaw = Number(a.brightness);
  const bri = Number.isFinite(briRaw) && briRaw > 0 ? Math.max(0.15, Math.min(1, briRaw / 255)) : 1;
  const rgb = a.rgb_color;
  if (Array.isArray(rgb) && rgb.length >= 3 && rgb.every((v: any) => Number.isFinite(v)))
    return { c: `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`, bri };
  const kelvin = Number(a.color_temp_kelvin) || (Number(a.color_temp) > 0 ? 1e6 / Number(a.color_temp) : NaN);
  if (Number.isFinite(kelvin) && kelvin > 0) {
    const [r, g, b] = kelvinToRgb(kelvin);
    return { c: `rgb(${r}, ${g}, ${b})`, bri };
  }
  return { c: fallback, bri };
}

/**
 * Light spilling through a doorway: the sector of the glow circle between the
 * rays source→A and source→B (door edge points), out to radius r. This part of
 * the circle is intentionally NOT clipped by the room (owner's spec: no shadow
 * casting — just the unclipped sector). Null when the door is out of reach or
 * the source sits on a door edge; the sweep is clamped to maxDeg.
 */
export function doorSector(
  src: number[], a: number[], b: number[], r: number, maxDeg = 170,
): number[][] | null {
  const la = Math.hypot(a[0] - src[0], a[1] - src[1]);
  const lb = Math.hypot(b[0] - src[0], b[1] - src[1]);
  if (la < 1e-6 || lb < 1e-6 || Math.min(la, lb) >= r) return null;
  let aa = Math.atan2(a[1] - src[1], a[0] - src[0]);
  let sweep = Math.atan2(b[1] - src[1], b[0] - src[0]) - aa;
  while (sweep > Math.PI) sweep -= 2 * Math.PI;
  while (sweep < -Math.PI) sweep += 2 * Math.PI;
  const max = (maxDeg * Math.PI) / 180;
  if (Math.abs(sweep) > max) {
    const mid = aa + sweep / 2;
    sweep = max * Math.sign(sweep);
    aa = mid - sweep / 2;
  }
  const steps = 8;
  const pts: number[][] = [[src[0], src[1]]];
  for (let i = 0; i <= steps; i++) {
    const ang = aa + (sweep * i) / steps;
    pts.push([src[0] + Math.cos(ang) * r, src[1] + Math.sin(ang) * r]);
  }
  return pts;
}

/**
 * Is there a room on the far side of an opening (relative to the light source)?
 * Entrance doors lead outside — light must not spill there.
 */
export function hasRoomBehind(
  center: number[], angleDeg: number, src: number[], polys: number[][][], probe: number,
): boolean {
  const rad = (angleDeg * Math.PI) / 180;
  const n = [-Math.sin(rad), Math.cos(rad)];
  const toSrc = (src[0] - center[0]) * n[0] + (src[1] - center[1]) * n[1];
  const sgn = toSrc > 0 ? -1 : 1;
  const p = [center[0] + n[0] * probe * sgn, center[1] + n[1] * probe * sgn];
  return polys.some((poly) => pointStrictlyInside(p, poly, 1e-9));
}

/**
 * Group toggle for a switch's controlled entities, HA-group semantics:
 * any target on -> turn everything off; all off -> turn everything on.
 */
export function controlsAction(states: (string | undefined)[]): 'turn_on' | 'turn_off' {
  return states.some((st) => st === 'on') ? 'turn_off' : 'turn_on';
}

/** Only lights and plain switches may be group-controlled from the plan. */
export function isControllable(entityId: string): boolean {
  return entityId.startsWith('light.') || entityId.startsWith('switch.');
}

/** Device classes whose active state is an emergency, not a status. */
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);

/**
 * An alarm is firing: leak/smoke/gas/CO/safety binary sensors in `on`, or a
 * siren that is on. Unavailable/unknown never alarm (an outage is not a fire).
 */
export function isAlarmState(
  domain: string | null | undefined,
  deviceClass: string | null | undefined,
  state: string | null | undefined,
): boolean {
  if (state !== 'on') return false;
  if (domain === 'siren') return true;
  return domain === 'binary_sensor' && !!deviceClass && ALARM_CLASSES.has(deviceClass);
}

// ---------------- room references ----------------

/**
 * Parse a marker's room reference. Two shapes:
 * - `space#area`   — a room bound to an HA area (historical form)
 * - `space#@roomId` — a room WITHOUT an area (sub-area rooms, issue #3):
 *   devices are placed into it manually, by room id.
 */
export function parseRoomRef(
  v: string | null | undefined,
): { space: string; area: string | null; roomId: string | null } | null {
  if (!v) return null;
  const i = v.indexOf('#');
  if (i <= 0) return null;
  const space = v.slice(0, i);
  const rest = v.slice(i + 1);
  if (!rest) return null;
  if (rest.startsWith('@')) {
    const roomId = rest.slice(1);
    return roomId ? { space, area: null, roomId } : null;
  }
  return { space, area: rest, roomId: null };
}

// ---------------- new-device detection ----------------

/**
 * Which auto-appearing device ids are NEW against the known baseline.
 * No baseline yet (first run / upgrade) → nothing is new: every current id
 * becomes the baseline silently, so an update never floods the plan with dots.
 */
export function diffNewDevices(
  currentIds: string[],
  known: string[] | null | undefined,
): { fresh: string[]; known: string[] } {
  if (!Array.isArray(known)) return { fresh: [], known: [...currentIds] };
  const knownSet = new Set(known);
  const fresh = currentIds.filter((id) => !knownSet.has(id));
  return { fresh, known: fresh.length ? [...known, ...fresh] : known };
}
