// Web client entry point.
//
// M6c: full chain — punch hole, harvest signed peer pk, request relay,
// open WS to relay, secretbox handshake, password challenge, login.
// On success: display PeerInfo (hostname, platform, displays, codec set).
// Video lands in M6d.

import { sodiumReady, signOpen, base64Decode } from "./crypto.js";
import { hbb } from "./proto/generated.js";
import {
  genUuid,
  punchHole,
  rendezvousUrl,
  relayUrl,
} from "./transport/rendezvous.js";
import { Relay } from "./transport/relay.js";
import { Session } from "./transport/session.js";
import { VideoPipeline } from "./decode/video.js";
import { AudioPipeline } from "./decode/audio.js";
import { CanvasView } from "./ui/canvas.js";
import { bindMouse } from "./input/mouse.js";
import { bindKeyboard, sendCtrlAltDel } from "./input/keyboard.js";
import {
  applyIncoming as applyClipboard,
  applyIncomingMulti as applyClipboardMulti,
  bindClipboard,
} from "./input/clipboard.js";

interface CustomConfig {
  api_server: string;
  rendezvous_server: string;
  relay_server: string;
  key: string;
  peer_id: string;
  admin_name: string;
}

const CLIENT_VERSION = "1.4.0";

function readConfig(): CustomConfig {
  const tag = document.getElementById("custom-config");
  if (!tag) throw new Error("missing <script id='custom-config'> tag");
  const raw = (tag.textContent || "").trim();
  if (!raw) throw new Error("empty custom-config payload");
  return JSON.parse(raw) as CustomConfig;
}

function status(html: string): void {
  const root = document.getElementById("root");
  if (!root) return;
  root.innerHTML = `<div class="placeholder">${html}</div>`;
}

function escapeHtml(s: string): string {
  return s.replace(/[&<>"']/g, (c) => ({
    "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
  })[c]!);
}

function utf8Bytes(s: string): Uint8Array {
  return new TextEncoder().encode(s);
}

/**
 * Render a password prompt and return the entered string when the user
 * submits. The prompt re-renders with `errMsg` when set (e.g. "Wrong
 * Password" from a previous attempt).
 */
function askPassword(cfg: CustomConfig, errMsg?: string): Promise<string> {
  return new Promise((resolve) => {
    const root = document.getElementById("root");
    if (!root) {
      resolve("");
      return;
    }
    const errBlock = errMsg
      ? `<p class="error-inline">${escapeHtml(errMsg)}</p>`
      : "";
    root.innerHTML = `
      <div class="placeholder">
        <h1>Connect to <code>${escapeHtml(cfg.peer_id)}</code></h1>
        <p class="muted">If the host requires a password, enter it below. Leave blank for "accept without password" hosts.</p>
        ${errBlock}
        <form id="pw-form" class="pw-form">
          <input id="pw-input" type="password" autocomplete="current-password" placeholder="Password (optional)" />
          <button type="submit">Connect</button>
        </form>
      </div>`;
    const form = document.getElementById("pw-form") as HTMLFormElement | null;
    const input = document.getElementById("pw-input") as HTMLInputElement | null;
    input?.focus();
    form?.addEventListener("submit", (ev) => {
      ev.preventDefault();
      resolve(input?.value || "");
    });
  });
}

async function main(): Promise<void> {
  const cfg = readConfig();
  await sodiumReady;

  if (!cfg.key) throw new Error("custom-config.key empty (id_ed25519.pub missing)");
  const serverPk = base64Decode(cfg.key);
  if (serverPk.length !== 32) throw new Error(`server pk wrong length ${serverPk.length}`);

  // Retry loop: prompt for password, attempt connect, if "Wrong Password"
  // re-prompt and try again. Other errors break out and surface as fatal.
  let lastErr: string | undefined;
  while (true) {
    const password = await askPassword(cfg, lastErr);
    try {
      const ready = await connectOnce(cfg, serverPk, password, /*statusUI=*/true);
      // First connect succeeded — hand off to the session loop, which
      // owns reconnect.
      await runSession(cfg, serverPk, password, ready);
      return;
    } catch (e) {
      const msg = String(e);
      if (msg.includes("Wrong Password") || msg.includes("Bad password") || msg.toLowerCase().includes("password")) {
        lastErr = "Wrong password — try again.";
        continue;
      }
      console.error("[rustdesk-web] fatal:", e);
      status(`<div class="error"><h1>Connection failed</h1><pre>${escapeHtml(msg)}</pre></div>`);
      return;
    }
  }
}

interface ConnectResult {
  session: Session;
  peerInfo: hbb.IPeerInfo;
  preloginExtras: hbb.Message[];
}

/**
 * Single connect attempt: rendezvous → verify peer pk → relay WS →
 * secure handshake → login. Returns a live, logged-in Session.
 *
 * Used both for the initial connect (with statusUI=true to show
 * step-by-step progress) and for each reconnect attempt (statusUI=false
 * — the reconnect overlay is its own UI element, the placeholder is
 * already gone by then).
 */
async function connectOnce(
  cfg: CustomConfig,
  serverPk: Uint8Array,
  password: string,
  statusUI: boolean,
): Promise<ConnectResult> {
  const setStatus = (html: string): void => { if (statusUI) status(html); };

  setStatus(`<h1>Connecting to ${escapeHtml(cfg.peer_id)}…</h1><p class="muted">1/3: rendezvous</p>`);
  const wsRendezvous = rendezvousUrl(cfg.rendezvous_server);
  const ph = await punchHole(wsRendezvous, cfg.peer_id, cfg.key, CLIENT_VERSION);
  if (ph.signedIdPk.length === 0) {
    throw new Error("Server didn't sign the peer key — update the host's RustDesk to 1.4+.");
  }

  let peerSignPk: Uint8Array;
  let verifiedId: string;
  try {
    const idPkBytes = signOpen(ph.signedIdPk, serverPk);
    const idPk = hbb.IdPk.decode(idPkBytes);
    verifiedId = idPk.id || "";
    peerSignPk = (idPk.pk as Uint8Array) || new Uint8Array(0);
  } catch (e) {
    throw new Error(`Signature verify failed — server pk on file (${cfg.key}) does not match what hbbs signed with. (${e})`);
  }
  if (verifiedId !== cfg.peer_id) {
    throw new Error(`Peer ID mismatch (signed=${verifiedId}, requested=${cfg.peer_id})`);
  }

  if (!ph.relayServer) {
    throw new Error("No relay server. Browser cannot do direct connect; set --relay-servers <host> on hbbs.");
  }

  const uuid = ph.peerUuid || genUuid();
  if (!ph.peerUuid) {
    console.warn("[rustdesk-web] no peer uuid in RelayResponse — fallback (relay pairing will likely fail)");
  }

  setStatus(`<h1>Connecting to ${escapeHtml(cfg.peer_id)}…</h1><p class="muted">2/3: relay handshake</p>`);
  const wsRelay = relayUrl(ph.relayServer);
  const relay = await Relay.connect({
    wsUrl: wsRelay,
    peerId: cfg.peer_id,
    uuid,
    licenceKey: cfg.key,
  });

  setStatus(`<h1>Connecting to ${escapeHtml(cfg.peer_id)}…</h1><p class="muted">3/3: secure handshake + login</p>`);
  try {
    const ready = await Session.open({
      relay,
      peerId: cfg.peer_id,
      peerSignPk,
      myName: cfg.admin_name || "web-admin",
      password: utf8Bytes(password),
      clientVersion: CLIENT_VERSION,
      sessionId: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER),
    });
    (window as unknown as { __rdw: unknown }).__rdw = { relay, session: ready.session, peerInfo: ready.peerInfo, cfg };
    return ready;
  } catch (e) {
    relay.close();
    throw e;
  }
}

const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));

async function runSession(
  cfg: CustomConfig,
  serverPk: Uint8Array,
  password: string,
  initial: ConnectResult,
): Promise<void> {
  const root = document.getElementById("root");
  if (!root) return;
  // Replace the status placeholder with the canvas + HUD.
  root.innerHTML = `<div class="session" id="session"></div><div class="hud" id="hud">connecting…</div>`;
  const sessionEl = document.getElementById("session") as HTMLElement;
  const hudEl = document.getElementById("hud") as HTMLElement;

  const canvas = new CanvasView(sessionEl);
  const pipeline = new VideoPipeline((frame) => canvas.draw(frame));
  const audio = new AudioPipeline();

  // Mutable session — closures below read the current value at call
  // time (let-binding capture, not value capture), so reassigning on
  // reconnect makes every HUD button automatically forward to the new
  // session without re-binding handlers.
  let session = initial.session;
  let peerInfo = initial.peerInfo;
  let preloginExtras = initial.preloginExtras;

  // Track the active display. Mouse coords need the display's virtual-
  // desktop origin (DisplayInfo.{x, y}) added to them so clicks on
  // display 2 don't land on display 1.
  let displays = peerInfo.displays || [];
  let currentDisplay = peerInfo.current_display || 0;
  if (currentDisplay < 0 || currentDisplay >= displays.length) currentDisplay = 0;
  const getOrigin = (): { x: number; y: number } => {
    const d = displays[currentDisplay];
    return { x: d?.x || 0, y: d?.y || 0 };
  };

  // User-set option state. The host doesn't remember these across
  // sessions, so we re-send them on every reconnect.
  let userQuality: hbb.ImageQuality | null = null;
  let userFps: number | null = null;

  // Wire input. Handlers close over the `session` let, so reassigning
  // it on reconnect transparently retargets them.
  let detachMouse = bindMouse(canvas, getSessionProxy(), getOrigin);
  let detachKb = bindKeyboard(canvas, getSessionProxy());
  let detachClip = bindClipboard(getSessionProxy());
  function getSessionProxy(): Session {
    // The input modules expect a Session; give them a Proxy that
    // forwards method calls to whatever session is current at call
    // time. Avoids re-binding window/canvas listeners on each reconnect.
    return new Proxy({} as Session, {
      get(_t, prop) {
        const target = session as unknown as Record<string | symbol, unknown>;
        const value = target[prop];
        if (typeof value === "function") {
          return (value as (...args: unknown[]) => unknown).bind(session);
        }
        return value;
      },
    });
  }
  // Focus the canvas so keystrokes start flowing immediately.
  canvas.el().focus();

  // Wipe the initial "connecting…" text so it doesn't sit forever
  // *next to* the controls we're about to append.
  hudEl.textContent = "";

  // FPS / dims line (left of HUD).
  const fpsLine = document.createElement("span");
  fpsLine.className = "hud-fps";
  hudEl.appendChild(fpsLine);

  // Mute toggle. Also tells the peer to stop encoding audio while muted
  // (saves their CPU + relay bandwidth — re-enabled on unmute).
  const muteBtn = document.createElement("button");
  muteBtn.className = "hud-btn";
  muteBtn.textContent = "🔇 Mute";
  muteBtn.addEventListener("click", () => {
    const muted = !audio.isMuted();
    audio.setMuted(muted);
    muteBtn.textContent = muted ? "🔈 Unmute" : "🔇 Mute";
    // BoolOption: NotSet=0, No=1, Yes=2 — explicit Yes/No; NotSet would
    // leave the host's existing setting alone.
    session.send(hbb.Message.create({
      misc: hbb.Misc.create({
        option: hbb.OptionMessage.create({
          disable_audio: muted
            ? hbb.OptionMessage.BoolOption.Yes
            : hbb.OptionMessage.BoolOption.No,
        }),
      }),
    })).catch(() => { /* relay closed */ });
    // First user gesture also unblocks the AudioContext on browsers that
    // require a click before audio plays.
    audio.resume().catch(() => { /* ignore */ });
  });
  hudEl.appendChild(muteBtn);

  // Image quality picker. Wire format per src/server/video_qos.rs:218-238:
  //   - Preset enum (image_quality field): Low=2, Balanced=3, Best=4
  //   - Custom (custom_image_quality field): bitrate-percent << 8 — host
  //     does `(q >> 8 & 0xFFF) * 2 / 100` to recover the float ratio.
  // We expose the three presets only; the desktop client also has a
  // free slider that maps to the Custom branch, but presets cover the
  // common cases (Low for slow links, Best for screen sharing demos).
  const qualitySel = document.createElement("select");
  qualitySel.className = "hud-select";
  qualitySel.title = "Image quality";
  for (const [label, value] of [
    ["Balanced", hbb.ImageQuality.Balanced],
    ["Best", hbb.ImageQuality.Best],
    ["Low", hbb.ImageQuality.Low],
  ] as const) {
    const opt = document.createElement("option");
    opt.value = String(value);
    opt.textContent = label;
    qualitySel.appendChild(opt);
  }
  qualitySel.addEventListener("change", () => {
    const q = parseInt(qualitySel.value, 10) as hbb.ImageQuality;
    userQuality = q;
    session.send(hbb.Message.create({
      misc: hbb.Misc.create({
        option: hbb.OptionMessage.create({ image_quality: q }),
      }),
    })).catch(() => { /* relay closed */ });
    canvas.el().focus();
  });
  hudEl.appendChild(qualitySel);

  // Custom FPS picker. Host clamps via VIDEO_QOS.user_custom_fps —
  // any value > 0 takes effect immediately, but the host caps at 30
  // for clients that don't advertise allow_more_fps (we don't, so 30
  // is the practical max even if we send 60).
  const fpsSel = document.createElement("select");
  fpsSel.className = "hud-select";
  fpsSel.title = "Target FPS";
  for (const fps of [15, 30, 60]) {
    const opt = document.createElement("option");
    opt.value = String(fps);
    opt.textContent = `${fps} fps`;
    if (fps === 30) opt.selected = true;
    fpsSel.appendChild(opt);
  }
  fpsSel.addEventListener("change", () => {
    const fps = parseInt(fpsSel.value, 10);
    userFps = fps;
    session.send(hbb.Message.create({
      misc: hbb.Misc.create({
        option: hbb.OptionMessage.create({ custom_fps: fps }),
      }),
    })).catch(() => { /* relay closed */ });
    canvas.el().focus();
  });
  hudEl.appendChild(fpsSel);

  // Display picker — only shown when the host has more than one screen.
  if (displays.length > 1) {
    const sel = document.createElement("select");
    sel.className = "hud-select";
    displays.forEach((d, i) => {
      const opt = document.createElement("option");
      opt.value = String(i);
      opt.textContent = `Display ${i + 1} · ${d.width}×${d.height}`;
      if (i === currentDisplay) opt.selected = true;
      sel.appendChild(opt);
    });
    sel.addEventListener("change", () => {
      const idx = parseInt(sel.value, 10);
      // Two-message dance — required for clients >= 1.2.4 (we send
      // version "1.4.0"). The host's switch_display_to skips
      // unsubscribing the previous service for these clients and waits
      // for an explicit CaptureDisplays{set:[idx]} to clean up. Without
      // that follow-up, switching display 0 → 1 → 0 doesn't actually
      // restore display 0 because both video services stay subscribed
      // and the host's display_idx tracker can desync. See
      // src/server/connection.rs:3679-3699 (switch_display_to) and
      // src/ui_session_interface.rs:849-869 (mirrors this dance in the
      // desktop client).
      session.send(hbb.Message.create({
        misc: hbb.Misc.create({
          switch_display: hbb.SwitchDisplay.create({ display: idx }),
        }),
      })).catch(() => { /* relay closed */ });
      session.send(hbb.Message.create({
        misc: hbb.Misc.create({
          capture_displays: hbb.CaptureDisplays.create({ set: [idx] }),
        }),
      })).catch(() => { /* relay closed */ });
      currentDisplay = idx;
      canvas.el().focus();
    });
    hudEl.appendChild(sel);
  }

  // Ctrl+Alt+Del (Windows hosts only — server-side `#[cfg(windows)]`).
  const cadBtn = document.createElement("button");
  cadBtn.textContent = "Ctrl+Alt+Del";
  cadBtn.className = "hud-btn";
  cadBtn.addEventListener("click", () => {
    sendCtrlAltDel(session);
    canvas.el().focus();
  });
  hudEl.appendChild(cadBtn);

  setInterval(() => {
    const sz = canvas.size();
    const dims = sz.width ? `${sz.width}×${sz.height}` : "—";
    const d = pipeline.diagStats();
    // recv = wire frames/sec from the host, decode = browser-decoded
    // frames/sec, draw = canvas paints/sec. Three numbers narrow down
    // where any FPS gap is sitting.
    fpsLine.textContent = ` ${escapeHtml(peerInfo.hostname || cfg.peer_id)} · ${dims} · recv ${d.recvFps} / dec ${d.decodeFps} / draw ${canvas.fps()} fps · ${d.codec || "—"}  `;
  }, 1000);
  void detachMouse; void detachKb; void detachClip; // kept-alive refs for future cleanup

  // Reconnect overlay — a centred banner that appears on top of the
  // canvas while we're trying to re-establish the session. The canvas
  // keeps its last-known frame underneath so the user has visual
  // continuity across blips.
  const overlay = document.createElement("div");
  overlay.className = "reconnect-overlay";
  overlay.style.display = "none";
  sessionEl.appendChild(overlay);
  const showOverlay = (text: string): void => {
    overlay.textContent = text;
    overlay.style.display = "flex";
  };
  const hideOverlay = (): void => {
    overlay.style.display = "none";
  };

  // Outer loop: each iteration is one connected session. After the
  // first iteration's recv loop exits, we attempt reconnect with
  // backoff and either continue here (success) or render a fatal
  // overlay and return (give up).
  let attempt = 0;
  const MAX_ATTEMPTS = 10;
  while (true) {
    // Replay any pre-login messages from the just-completed login.
    // The most important one is Misc{audio_format} — the host's
    // audio_service first-snapshot fires between add_connection and
    // login_response, so it arrives BEFORE LoginResponse and we
    // stash it during login.
    for (const m of preloginExtras) {
      if (m.misc?.audio_format) {
        try {
          audio.configure(m.misc.audio_format);
          // After (re)configure the gain defaults to 1; re-apply mute
          // if the user had toggled it before the disconnect.
          if (audio.isMuted()) audio.setMuted(true);
        } catch (e) {
          console.warn("[rustdesk-web] audio init failed:", e);
        }
      }
    }
    preloginExtras = [];

    // On reconnect, re-apply user-set options. The host treats each
    // session as fresh and resets these to defaults.
    if (attempt > 0) {
      if (audio.isMuted()) {
        session.send(hbb.Message.create({
          misc: hbb.Misc.create({
            option: hbb.OptionMessage.create({
              disable_audio: hbb.OptionMessage.BoolOption.Yes,
            }),
          }),
        })).catch(() => { /* swallow */ });
      }
      if (userQuality != null) {
        session.send(hbb.Message.create({
          misc: hbb.Misc.create({
            option: hbb.OptionMessage.create({ image_quality: userQuality }),
          }),
        })).catch(() => { /* swallow */ });
      }
      if (userFps != null) {
        session.send(hbb.Message.create({
          misc: hbb.Misc.create({
            option: hbb.OptionMessage.create({ custom_fps: userFps }),
          }),
        })).catch(() => { /* swallow */ });
      }
      if (currentDisplay > 0) {
        session.send(hbb.Message.create({
          misc: hbb.Misc.create({
            switch_display: hbb.SwitchDisplay.create({ display: currentDisplay }),
          }),
        })).catch(() => { /* swallow */ });
        session.send(hbb.Message.create({
          misc: hbb.Misc.create({
            capture_displays: hbb.CaptureDisplays.create({ set: [currentDisplay] }),
          }),
        })).catch(() => { /* swallow */ });
      }
    }

    // Inner receive loop. Branches by Message oneof. Returns when the
    // session breaks (relay close, decrypt fail, etc.).
    let disconnectReason = "";
    try {
      while (true) {
        const msg = await session.recv();
        if (msg.video_frame) {
          pipeline.pushVideoFrame(msg.video_frame);
          continue;
        }
        if (msg.test_delay) {
          const td = msg.test_delay;
          session.send(hbb.Message.create({
            test_delay: hbb.TestDelay.create({
              time: td.time,
              from_client: true,
              last_delay: td.last_delay,
              target_bitrate: td.target_bitrate,
            }),
          })).catch(() => { /* relay closing — drop */ });
          continue;
        }
        if (msg.audio_frame) {
          const data = (msg.audio_frame.data as Uint8Array) || new Uint8Array(0);
          audio.pushFrame(data);
          continue;
        }
        if (msg.cursor_data || msg.cursor_position || msg.cursor_id != null) {
          // Cursor sprite + position — deferred to a QoL milestone. The
          // loose `!= null` here is load-bearing: protobufjs static-
          // module sets unpopulated oneof fields to `null` (not
          // `undefined`), so a strict `!== undefined` check would
          // swallow every other Message type.
          continue;
        }
        if (msg.clipboard) {
          applyClipboard(msg.clipboard).catch(() => { /* swallowed */ });
          continue;
        }
        if (msg.multi_clipboards) {
          applyClipboardMulti(msg.multi_clipboards).catch(() => { /* swallowed */ });
          continue;
        }
        if (msg.misc) {
          if (msg.misc.audio_format) {
            try {
              audio.configure(msg.misc.audio_format);
              if (audio.isMuted()) audio.setMuted(true);
            } catch (e) {
              console.warn("[rustdesk-web] audio init failed:", e);
            }
          }
          continue;
        }
      }
    } catch (e) {
      disconnectReason = String(e);
      console.warn("[rustdesk-web] session disconnected:", disconnectReason);
    }

    // Disconnected. Reset the codec pipeline so the new session's
    // first keyframe reconfigures from scratch (codec or resolution
    // may have changed if the peer was restarted).
    pipeline.close();

    // Try to reconnect with exponential backoff. The first attempt
    // is immediate; subsequent attempts back off 1s, 2s, 4s, ...
    // capped at 30s.
    let reconnected: ConnectResult | null = null;
    for (attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
      showOverlay(`Reconnecting (${attempt}/${MAX_ATTEMPTS})…`);
      try {
        reconnected = await connectOnce(cfg, serverPk, password, /*statusUI=*/false);
        break;
      } catch (e) {
        const msg = String(e);
        // Auth errors won't fix on retry — the peer changed something
        // (password rotated, key changed). Bail with a clear message.
        if (msg.toLowerCase().includes("password") ||
            msg.toLowerCase().includes("signature verify")) {
          showOverlay(`Cannot reconnect: ${msg.slice(0, 100)}`);
          audio.close();
          return;
        }
        if (attempt === MAX_ATTEMPTS) break;
        // 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
        const delay = Math.min(1000 * 2 ** (attempt - 1), 30_000);
        showOverlay(`Reconnecting (${attempt}/${MAX_ATTEMPTS})… retry in ${Math.round(delay / 1000)}s`);
        await sleep(delay);
      }
    }

    if (!reconnected) {
      showOverlay(`Disconnected. ${escapeHtml(disconnectReason).slice(0, 120)}`);
      audio.close();
      return;
    }

    // Reconnected — swap in the new session and loop back. The let-
    // binding capture means HUD click handlers and the input-handler
    // proxy automatically retarget without rebinding.
    session = reconnected.session;
    peerInfo = reconnected.peerInfo;
    preloginExtras = reconnected.preloginExtras;
    displays = peerInfo.displays || [];
    if (currentDisplay >= displays.length) currentDisplay = 0;
    hideOverlay();
    canvas.el().focus();
  }
}

main().catch((err) => {
  console.error("[rustdesk-web] boot failed:", err);
  status(`<div class="error"><h1>Failed to start</h1><pre>${escapeHtml(String(err))}</pre></div>`);
});
