// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later

export type AprsCategory = "position" | "message" | "weather" | "telemetry" | "other";
export type AprsTypeFilter = "all" | AprsCategory;

export interface AprsPacket {
  rig_id?: string | null;
  receiver?: unknown;
  srcCall?: string;
  destCall?: string;
  path?: string;
  info?: string;
  info_bytes?: number[];
  type?: string;
  crcOk?: boolean;
  ts_ms?: number | null;
  lat?: number | null;
  lon?: number | null;
  symbolTable?: string | null;
  symbolCode?: string | null;
  _tsMs?: number;
  _ts?: string;
  src_call?: string;
  dest_call?: string;
  packet_type?: string;
  crc_ok?: boolean;
  symbol_table?: string | null;
  symbol_code?: string | null;
}

export function aprsPacketCategory(packet: AprsPacket): AprsCategory {
  const type = (packet.type ?? "").toLowerCase();
  const info = (packet.info ?? "").toLowerCase();
  if ((packet.lat != null && packet.lon != null) || type.includes("position")) return "position";
  if (type.includes("message") || info.startsWith(":")) return "message";
  if (type.includes("weather") || info.startsWith("_")) return "weather";
  if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
  return "other";
}

export function aprsCategoryLabel(category: AprsCategory): string {
  switch (category) {
    case "position": return "Position";
    case "message": return "Message";
    case "weather": return "Weather";
    case "telemetry": return "Telemetry";
    default: return "Other";
  }
}

export function aprsAgeText(timestampMs: number | undefined): string {
  if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
  const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1000);
  if (seconds < 5) return "just now";
  if (seconds < 60) return `${String(seconds)}s ago`;
  const minutes = Math.round(seconds / 60);
  if (minutes < 60) return `${String(minutes)}m ago`;
  return `${String(Math.round(minutes / 60))}h ago`;
}

export function aprsPacketSignature(packet: AprsPacket): string {
  return [packet.srcCall ?? "", packet.destCall ?? "", packet.path ?? "", packet.info ?? "", packet.type ?? "",
    packet.lat?.toFixed(4) ?? "", packet.lon?.toFixed(4) ?? ""].join("|");
}

export function collapseAprsDuplicates(packets: AprsPacket[]): AprsPacket[] {
  const seen = new Set<string>();
  return packets.filter((packet) => {
    const signature = aprsPacketSignature(packet);
    if (seen.has(signature)) return false;
    seen.add(signature);
    return true;
  });
}

export function aprsHexBytes(bytes: number[] | undefined): string {
  if (!bytes?.length) return "--";
  return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
}

export function renderAprsInfo(packet: AprsPacket): string {
  if (packet.info_bytes?.length) {
    return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
  }
  return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
}

function renderAprsByte(byte: number): string {
  return byte >= 0x20 && byte <= 0x7e
    ? escapeAprsCharacter(String.fromCharCode(byte))
    : `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
}

function renderAprsCharacter(character: string): string {
  const code = character.charCodeAt(0);
  return code >= 0x20 && code <= 0x7e
    ? escapeAprsCharacter(character)
    : `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
}

function escapeAprsCharacter(character: string): string {
  if (character === "<") return "&lt;";
  if (character === ">") return "&gt;";
  if (character === "&") return "&amp;";
  if (character === '"') return "&quot;";
  return character;
}

// The vendored sprite sheets (assets/web/vendor/aprs-symbols-24-*.png) are
// 16x6 grids of 24px cells covering the printable codes 0x21..0x7E, so a
// symbol's cell index is simply `code - 0x21`.  The sheet URLs live in CSS so
// the retina variants can be picked up by a media query; only the cell offsets
// are computed here.
const APRS_SPRITE_COLUMNS = 16;
const APRS_SPRITE_CELL_PX = 24;
const APRS_SPRITE_FIRST_CODE = 0x21;
const APRS_SPRITE_LAST_CODE = 0x7e;

export interface AprsSymbolSprite {
  /** Sheet modifier class appended to `.aprs-symbol`. */
  className: string;
  /** `background-position` covering the overlay layer first, if any. */
  backgroundPosition: string;
  /** Human-readable description for the tooltip. */
  label: string;
}

function aprsSpriteOffset(code: string): string | null {
  if (code.length !== 1) return null;
  const point = code.charCodeAt(0);
  if (point < APRS_SPRITE_FIRST_CODE || point > APRS_SPRITE_LAST_CODE) return null;
  const index = point - APRS_SPRITE_FIRST_CODE;
  const column = index % APRS_SPRITE_COLUMNS;
  const row = Math.floor(index / APRS_SPRITE_COLUMNS);
  return `${String(-column * APRS_SPRITE_CELL_PX)}px ${String(-row * APRS_SPRITE_CELL_PX)}px`;
}

/**
 * Resolve an APRS table/code pair to a sprite cell.  A table identifier of `/`
 * selects the primary sheet and `\` the alternate one; any other character is
 * an overlay, which draws that character from the overlay sheet on top of the
 * alternate symbol.  Returns null when the pair is outside the sprite sheets,
 * leaving callers to fall back to the raw character.
 */
export function aprsSymbolSprite(
  symbolTable: string | null | undefined,
  symbolCode: string | null | undefined,
): AprsSymbolSprite | null {
  if (!symbolTable || !symbolCode) return null;
  const symbolOffset = aprsSpriteOffset(symbolCode);
  if (!symbolOffset) return null;
  if (symbolTable === "/") {
    return {
      className: "aprs-symbol-primary",
      backgroundPosition: symbolOffset,
      label: `Primary APRS symbol ${symbolTable}${symbolCode}`,
    };
  }
  if (symbolTable === "\\") {
    return {
      className: "aprs-symbol-alternate",
      backgroundPosition: symbolOffset,
      label: `Alternate APRS symbol ${symbolTable}${symbolCode}`,
    };
  }
  const overlayOffset = aprsSpriteOffset(symbolTable);
  if (!overlayOffset) return null;
  return {
    className: "aprs-symbol-overlaid",
    backgroundPosition: `${overlayOffset}, ${symbolOffset}`,
    label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}`,
  };
}

export function renderLocalAprsSymbol(packet: AprsPacket, escapeHtml: (value: string) => string): string {
  if (!packet.symbolTable || !packet.symbolCode) return "";
  const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
  if (sprite) {
    return `<span class="aprs-symbol ${sprite.className}" role="img"` +
      ` style="background-position:${sprite.backgroundPosition}"` +
      ` title="${escapeHtml(sprite.label)}" aria-label="${escapeHtml(sprite.label)}"></span>`;
  }
  const symbol = escapeHtml(packet.symbolCode);
  const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
  return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
}

export function normalizeAprsPacket(packet: AprsPacket, receiver: unknown): AprsPacket {
  return {
    rig_id: packet.rig_id || null,
    receiver,
    srcCall: packet.src_call ?? "",
    destCall: packet.dest_call ?? "",
    path: packet.path ?? "",
    info: packet.info ?? "",
    info_bytes: packet.info_bytes ?? [],
    type: packet.packet_type ?? "",
    crcOk: packet.crc_ok ?? false,
    ts_ms: packet.ts_ms ?? null,
    lat: packet.lat ?? null,
    lon: packet.lon ?? null,
    symbolTable: packet.symbol_table ?? null,
    symbolCode: packet.symbol_code ?? null,
  };
}
