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

import { hostCore } from "./host.js";

import type { PluginRuntimeWindow } from "./runtime-contract.js";

export type FtxDecoderId = "ft2" | "ft4" | "ft8";

export interface FtxMessage {
  message?: string | undefined;
  ts_ms?: number | undefined;
  _tsMs?: number | undefined;
  snr_db?: number | undefined;
  dt_s?: number | undefined;
  freq_hz?: number | undefined;
  receiver?: unknown;
  [key: string]: unknown;
}

export interface LocatorDetails { grid: string; station: string | null; source: string | null; target: string | null }
interface BarFrames { count: number; newestTsMs: number; html: string }
interface ConfirmApi {
  confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean>;
}
interface FtxBridge {
  ft8BaseHz?: number;
  getDecodeHistoryRetentionMs?: () => number;
  trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
  renderFt8Message?: (message: string) => string;
  ft8EscapeHtml?: (message: string) => string;
  ft8ExtractLocatorDetails?: (message: string) => LocatorDetails[];
  ft8ExtractAllGrids?: (message: string) => string[];
  ft8ExtractLikelyCallsign?: (message: string) => string | null;
  getDecodeRigMeta?: () => unknown;
  mapAddLocator?: (
    raw: string,
    grids: string[],
    decoder: FtxDecoderId,
    station: string | null,
    message: FtxMessage,
  ) => void;
  navigateToMapLocator?: (grid: string, decoder: FtxDecoderId) => void;
  clearMapMarkersByType?: (decoder: FtxDecoderId) => void;
  setFt8FamilyBarDecoder?: (decoder: FtxDecoderId) => void;
  updateFt8Bar?: () => void;
  registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void;
  takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>;
  trxUi: ConfirmApi;
  clearFt8Bar?: () => void;
  closeFt8Bar?: () => void;
  [key: string]: unknown;
}

interface FtxConfig {
  id: FtxDecoderId;
  label: "FT2" | "FT4" | "FT8";
  periodMs: number;
  periodDigits?: number;
}

const bridge = window as unknown as FtxBridge & PluginRuntimeWindow;

// Legacy ft8.js owned this formatter locally; the shared module kept reading it
// off `window`, where nothing published it, so bar timestamps rendered empty.
function formatBarTime(timestampMs: number): string {
  if (!timestampMs) return "--:--:--";
  return new Date(timestampMs)
    .toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}

function finiteNumber(value: unknown): number | null {
  const number = typeof value === "number" ? value : Number(value);
  return Number.isFinite(number) ? number : null;
}

function isAlphaNumeric(value: string | undefined): boolean {
  return value !== undefined && /[A-Za-z0-9]/.test(value);
}

function isGrid(value: string): boolean {
  const normalized = value.trim().toUpperCase();
  return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized)
    && normalized !== "RR73" && normalized !== "73" && normalized !== "RR";
}

export function escapeFtxHtml(input: string): string {
  return input.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;").replaceAll('"', "&quot;");
}

export function extractFtxGrids(message: string): string[] {
  return [...new Set(message.toUpperCase().split(/[^A-Z0-9]+/).filter(isGrid))];
}

function tokenize(message: string): string[] {
  return message.toUpperCase().split(/[^A-Z0-9/]+/).filter(Boolean);
}

function isCallsign(token: string | undefined): token is string {
  return token !== undefined && token.length >= 3 && token.length <= 12
    && !["CQ", "DE", "QRZ", "DX"].includes(token) && !isGrid(token)
    && /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
}

export function extractFtxLocatorDetails(message: string): LocatorDetails[] {
  const tokens = tokenize(message);
  const grids = extractFtxGrids(message);
  const gridIndex = tokens.findIndex(isGrid);
  const callsigns = tokens.slice(0, gridIndex < 0 ? tokens.length : gridIndex).filter(isCallsign);
  const directed = callsigns.length >= 2 && !["CQ", "DE", "QRZ"].includes(tokens[0] ?? "");
  const source = directed ? callsigns[1] ?? null : callsigns[0] ?? null;
  const target = directed ? callsigns[0] ?? null : null;
  return grids.map((grid) => ({ grid, station: source, source, target }));
}

export function extractFtxCallsign(message: string): string | null {
  return extractFtxLocatorDetails(message)[0]?.station ?? tokenize(message).find(isCallsign) ?? null;
}

export function renderFtxMessage(message: string): string {
  let html = "";
  let index = 0;
  while (index < message.length) {
    if (!isAlphaNumeric(message[index])) {
      html += escapeFtxHtml(message[index] ?? "");
      index += 1;
      continue;
    }
    let end = index + 1;
    while (end < message.length && isAlphaNumeric(message[end])) end += 1;
    const token = message.slice(index, end);
    const grid = token.toUpperCase();
    html += isGrid(grid)
      ? `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`
      : escapeFtxHtml(token);
    index = end;
  }
  return html;
}

export function installFtxCompatibilityHelpers(): void {
  bridge.renderFt8Message = renderFtxMessage;
  bridge.ft8EscapeHtml = escapeFtxHtml;
  bridge.ft8ExtractLocatorDetails = extractFtxLocatorDetails;
  bridge.ft8ExtractAllGrids = extractFtxGrids;
  bridge.ft8ExtractLikelyCallsign = extractFtxCallsign;
}

export function initializeFt8FamilyBar(): void {
  const labels: Record<FtxDecoderId, string> = { ft8: "FT8", ft4: "FT4", ft2: "FT2" };
  const builders: Partial<Record<FtxDecoderId, () => BarFrames>> = {};
  const dismissed: Record<FtxDecoderId, number> = { ft8: 0, ft4: 0, ft2: 0 };
  const overlay = document.getElementById("ft8-bar-overlay");
  let active: FtxDecoderId = "ft8";
  const update = (): void => {
    if (!overlay) return;
    const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value ?? "").toUpperCase();
    const result = builders[active]?.();
    if ((mode !== "DIG" && mode !== "USB") || !result || result.count === 0 || result.newestTsMs <= dismissed[active]) {
      overlay.style.display = "none";
      overlay.innerHTML = "";
      return;
    }
    const label = labels[active];
    overlay.innerHTML = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">${label}</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearFt8Bar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearFt8Bar();}" aria-label="Clear ${label} overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeFt8Bar()" aria-label="Close ${label} overlay">&times;</button></span></div>${result.html}`;
    overlay.style.display = "flex";
  };
  bridge.registerFt8FamilyBarRenderer = (decoder, builder) => { builders[decoder] = builder; };
  bridge.setFt8FamilyBarDecoder = (decoder) => { active = decoder; update(); };
  bridge.updateFt8Bar = update;
  bridge.clearFt8Bar = () => {
    bridge.trxPluginRuntime.reset(active);
  };
  bridge.closeFt8Bar = () => {
    dismissed[active] = Date.now();
    update();
  };
}

export function initializeFtxDecoder(config: FtxConfig): void {
  const { id, label, periodMs, periodDigits = 1 } = config;
  const status = document.getElementById(`${id}-status`);
  const period = document.getElementById(`${id}-period`);
  const messagesElement = document.getElementById(`${id}-messages`);
  const filterInput = document.getElementById(`${id}-filter`) as HTMLInputElement | null;
  let filterText = "";
  let history: FtxMessage[] = [];

  const renderMessage = (message: string): string => {
    return bridge.renderFt8Message?.(message) ?? renderFtxMessage(message);
  };
  const retentionMs = (): number => bridge.getDecodeHistoryRetentionMs?.() ?? 86_400_000;
  const prune = (): void => {
    const cutoff = Date.now() - retentionMs();
    history = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= cutoff);
  };
  const schedule = (job: () => void): void => {
    if (bridge.trxScheduleUiFrameJob) bridge.trxScheduleUiFrameJob(`${id}-history`, job);
    else job();
  };
  const displayFrequency = (value: unknown): number | null => {
    const raw = finiteNumber(value);
    if (raw === null) return null;
    const base = finiteNumber(bridge.ft8BaseHz);
    return base !== null && base > 0 && raw >= 0 && raw < 100_000 ? base + raw : raw;
  };
  const renderRow = (message: FtxMessage): HTMLDivElement => {
    const row = document.createElement("div");
    row.className = "ft8-row";
    const raw = message.message ?? "";
    row.dataset.message = raw.toUpperCase();
    row.dataset.decoder = id;
    const storedFrequency = finiteNumber(message.freq_hz);
    row.dataset.storedFreqHz = storedFrequency === null ? "" : String(storedFrequency);
    const snr = finiteNumber(message.snr_db);
    const delta = finiteNumber(message.dt_s);
    const frequency = displayFrequency(message.freq_hz);
    const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
    const time = timestamp === null ? "--:--:--" : new Date(timestamp).toLocaleTimeString([], {
      hour: "2-digit", minute: "2-digit", second: "2-digit",
    });
    row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
    return row;
  };
  const render = (): void => {
    prune();
    if (!messagesElement) return;
    const fragment = document.createDocumentFragment();
    let count = 0;
    for (const message of history) {
      if (count >= 200) break;
      if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
      fragment.appendChild(renderRow(message));
      count += 1;
    }
    messagesElement.replaceChildren(fragment);
  };
  const normalize = (message: FtxMessage): FtxMessage => {
    const raw = message.message ?? "";
    const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
    const grids = locatorDetails.length > 0
      ? locatorDetails.map(({ grid }) => grid)
      : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
    const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
    const frequency = displayFrequency(message.freq_hz);
    if (grids.length > 0) {
      bridge.mapAddLocator?.(raw, grids, id, station, {
        ...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
      });
    }
    return {
      receiver: bridge.getDecodeRigMeta?.() ?? null,
      ts_ms: message.ts_ms,
      snr_db: message.snr_db,
      dt_s: message.dt_s,
      freq_hz: frequency ?? message.freq_hz,
      message: message.message,
      _tsMs: finiteNumber(message.ts_ms) ?? Date.now(),
    };
  };
  const receiveBatch = (messages: FtxMessage[]): void => {
    if (messages.length === 0) return;
    if (status) status.textContent = "Receiving";
    history = messages.map(normalize).reverse().concat(history);
    prune();
    bridge.setFt8FamilyBarDecoder?.(id);
    bridge.updateFt8Bar?.();
    schedule(render);
  };
  const reset = (): void => {
    history = [];
    bridge.updateFt8Bar?.();
    render();
    bridge.clearMapMarkersByType?.(id);
  };
  const barFrames = (): BarFrames => {
    const recent = history
      .filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000)
      .slice(0, 8);
    let html = "";
    for (const message of recent) {
      const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
      const time = timestamp === null ? "" : `<span class="aprs-bar-time">${formatBarTime(timestamp)}</span>`;
      const snr = finiteNumber(message.snr_db);
      const delta = finiteNumber(message.dt_s);
      const frequency = displayFrequency(message.freq_hz);
      const detail = [snr === null ? "-- dB" : `${snr.toFixed(1)} dB`, delta === null ? null : `dt ${delta.toFixed(2)}`, frequency === null ? null : `${frequency.toFixed(0)} Hz`].filter((part): part is string => part !== null).join(" · ");
      html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${time}<span class="aprs-bar-call">${renderMessage(message.message ?? "")}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
    }
    return { count: recent.length, newestTsMs: recent.reduce((latest, message) => Math.max(latest, finiteNumber(message._tsMs ?? message.ts_ms) ?? 0), 0), html };
  };

  bridge.trxPluginRuntime.registerDecoder({
    id,
    onMessage: (message: FtxMessage) => { receiveBatch([message]); },
    onBatch: receiveBatch,
    restore: receiveBatch,
    prune: () => { prune(); render(); },
    reset,
  });
  bridge.registerFt8FamilyBarRenderer?.(id, barFrames);

  const updatePeriod = (): void => {
    if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1000).toFixed(periodDigits)}s`;
  };
  updatePeriod();
  window.setInterval(updatePeriod, 250);
  filterInput?.addEventListener("input", () => { filterText = filterInput.value.trim().toUpperCase(); render(); });
  messagesElement?.addEventListener("click", (event) => {
    if (!(event.target instanceof Element)) return;
    const grid = event.target.closest<HTMLElement>(".ft8-locator[data-locator-grid]")?.dataset.locatorGrid;
    if (grid) { bridge.navigateToMapLocator?.(grid.toUpperCase(), id); event.preventDefault(); }
  });
  const toggle = document.getElementById(`${id}-decode-toggle-btn`);
  toggle?.addEventListener("click", () => { void (async () => {
    try {
      await bridge.takeSchedulerControlForDecoderDisable?.(toggle);
      await hostCore.postPath(`/toggle_${id}_decode`);
    } catch (error: unknown) { console.error(`${label} toggle failed`, error); }
  })(); });
  document.getElementById(`settings-clear-${id}-history`)?.addEventListener("click", () => { void (async () => {
    if (!await bridge.trxUi.confirm({ title: `Clear ${label} history?`, message: `All stored ${label} decodes will be permanently removed.`, confirmLabel: "Clear history" })) return;
    try { await hostCore.postPath(`/clear_${id}_decode`); reset(); }
    catch (error: unknown) { console.error(`${label} history clear failed`, error); }
  })(); });
}
