scripts/realtime.jsjavascript
import { MODEL_URLS, DETECTOR_OPTIONS } from './config.js';

const APP_STATES = {
  CALIBRATING: 'CALIBRATING',
  TRACKING: 'TRACKING',
};

const DESCRIPTOR_SIZE = 128;
const CELLS_PER_ROW = 10;
const MAX_DELTA = 0.15;
const THRESHOLD = 0.6;
const GRAPH_MAX_POINTS = 240;
const GRAPH_UPDATES_PER_SECOND = 3;
const COUNTDOWN_SECONDS = 3;

const runtime = {
  appState: APP_STATES.CALIBRATING,
  baselineDescriptor: null,
  needBaseline: false,
  distanceBuffer: [],
  graphHistory: [],
  equalizerRows: [],
  frameRequestId: null,
  graphTimerId: null,
  detectorBusy: false,
  initialized: false,
};

const els = {
  video: document.getElementById('webcam'),
  embeddingBar: document.getElementById('embedding-bar'),
  graph: document.getElementById('distance-graph'),
  countdownCircle: document.getElementById('countdown-circle'),
  btnReset: document.getElementById('btn-reset'),
  statusPill: document.getElementById('status-pill'),
  distanceValue: document.getElementById('distance-value'),
  trackingState: document.getElementById('tracking-state'),
};

const graphCtx = els.graph ? els.graph.getContext('2d') : null;

/**
 * Reads graph colors from shared style tokens with sane fallbacks.
 *
 * @returns {{background: string, signal: string, threshold: string}} Graph color palette.
 */
function getGraphPalette() {
  const styles = getComputedStyle(document.documentElement);
  const background = styles.getPropertyValue('--panel-2').trim() || '#2c2318';
  const signal = styles.getPropertyValue('--dev').trim() || '#7fe3b0';
  const threshold = styles.getPropertyValue('--net').trim() || '#ff8a4c';
  return { background, signal, threshold };
}

/**
 * Runtime bridge for diagnostics and Playwright checks.
 *
 * @type {object}
 */
window.gstmxxRealtime = {
  /**
   * Returns a clone of current runtime state values for tests and debugging.
   *
   * @returns {object} Serializable snapshot of calibrating/tracking internals.
   */
  getState() {
    return {
      appState: runtime.appState,
      hasBaseline: Boolean(runtime.baselineDescriptor),
      needBaseline: runtime.needBaseline,
      distanceBufferLength: runtime.distanceBuffer.length,
      graphHistoryLength: runtime.graphHistory.length,
    };
  },
  /**
   * Forces the calibrating workflow in tests without pressing UI controls.
   *
   * @returns {void}
   */
  triggerCalibration() {
    startCalibration();
  },
};

/**
 * Updates the floating status message shown over the video feed.
 *
 * @param {string} message Message visible in the status pill.
 * @returns {void}
 */
function setStatus(message) {
  if (!els.statusPill) return;
  els.statusPill.textContent = message;
}

/**
 * Updates the right-panel state label between CALIBRATING and TRACKING.
 *
 * @param {string} state Current app state value.
 * @returns {void}
 */
function setTrackingState(state) {
  if (!els.trackingState) return;
  els.trackingState.textContent = state;
}

/**
 * Updates the numeric distance readout with a fixed 3-decimal value.
 *
 * @param {number} value Euclidean distance value to display.
 * @returns {void}
 */
function setDistanceValue(value) {
  if (!els.distanceValue) return;
  els.distanceValue.textContent = Number.isFinite(value) ? value.toFixed(3) : '0.000';
}

/**
 * Builds the 128x10 descriptor equalizer grid and caches each row.
 *
 * @returns {void}
 */
function initEqualizer() {
  if (!els.embeddingBar) return;
  els.embeddingBar.innerHTML = '';
  runtime.equalizerRows = [];

  for (let rowIndex = 0; rowIndex < DESCRIPTOR_SIZE; rowIndex += 1) {
    const row = document.createElement('div');
    row.className = 'descriptor-row';

    for (let cellIndex = 0; cellIndex < CELLS_PER_ROW; cellIndex += 1) {
      const cell = document.createElement('div');
      cell.className = 'cell';
      row.appendChild(cell);
    }

    els.embeddingBar.appendChild(row);
    runtime.equalizerRows.push(row);
  }
}

/**
 * Draws the smoothed distance history with time on the vertical axis.
 * Old samples are at the top and newest samples at the bottom.
 *
 * @returns {void}
 */
function drawGraphCanvas() {
  if (!graphCtx || !els.graph) return;

  const palette = getGraphPalette();

  const width = els.graph.width;
  const height = els.graph.height;

  graphCtx.clearRect(0, 0, width, height);

  graphCtx.fillStyle = palette.background;
  graphCtx.fillRect(0, 0, width, height);

  const thresholdX = Math.min((THRESHOLD / 1.0) * width, width);
  graphCtx.strokeStyle = palette.threshold;
  graphCtx.lineWidth = 2;
  graphCtx.beginPath();
  graphCtx.moveTo(thresholdX, 0);
  graphCtx.lineTo(thresholdX, height);
  graphCtx.stroke();

  if (!runtime.graphHistory.length) return;

  const points = runtime.graphHistory;
  const maxVisible = Math.min(points.length, GRAPH_MAX_POINTS);
  const start = points.length - maxVisible;
  const span = Math.max(maxVisible - 1, 1);

  graphCtx.strokeStyle = palette.signal;
  graphCtx.lineWidth = 2;
  graphCtx.beginPath();

  for (let i = 0; i < maxVisible; i += 1) {
    const value = points[start + i];
    const normalized = Math.max(0, Math.min(1, value / 1.0));
    const x = normalized * width;
    const y = (i / span) * height;

    if (i === 0) graphCtx.moveTo(x, y);
    else graphCtx.lineTo(x, y);
  }

  graphCtx.stroke();
}

/**
 * Updates every descriptor row based on per-index absolute baseline deltas.
 *
 * @param {Float32Array|number[]} baseline Baseline descriptor captured during calibration.
 * @param {Float32Array|number[]} current Current live descriptor.
 * @returns {void}
 */
function updateEqualizer(baseline, current) {
  if (!runtime.equalizerRows.length) return;

  for (let i = 0; i < DESCRIPTOR_SIZE; i += 1) {
    const delta = Math.abs(baseline[i] - current[i]);
    const scaled = Math.floor((delta / MAX_DELTA) * CELLS_PER_ROW);
    const litCells = Math.max(0, Math.min(CELLS_PER_ROW, scaled));
    const cells = runtime.equalizerRows[i].children;

    for (let c = 0; c < CELLS_PER_ROW; c += 1) {
      const cell = cells[c];
      cell.className = 'cell';

      if (c >= litCells) continue;
      if (c < 3) cell.classList.add('on-low');
      else if (c < 7) cell.classList.add('on-med');
      else cell.classList.add('on-high');
    }
  }
}

/**
 * Resets the equalizer UI to its neutral unlit state.
 *
 * @returns {void}
 */
function resetEqualizer() {
  for (const row of runtime.equalizerRows) {
    for (const cell of row.children) cell.className = 'cell';
  }
}

/**
 * Resizes the graph canvas to match CSS pixels and device pixel ratio.
 *
 * @returns {void}
 */
function resizeGraphCanvas() {
  if (!els.graph) return;
  const rect = els.graph.getBoundingClientRect();
  const dpr = Math.max(window.devicePixelRatio || 1, 1);
  const width = Math.max(Math.floor(rect.width * dpr), 1);
  const height = Math.max(Math.floor(rect.height * dpr), 1);

  if (els.graph.width === width && els.graph.height === height) return;
  els.graph.width = width;
  els.graph.height = height;
  drawGraphCanvas();
}

/**
 * Captures a baseline descriptor after the countdown by waiting for the first valid face.
 *
 * @returns {void}
 */
function armSafeBaselineCapture() {
  runtime.needBaseline = true;
  setStatus('Waiting for a clear face to capture baseline...');
  if (!els.countdownCircle) return;
  els.countdownCircle.style.display = 'flex';
  els.countdownCircle.classList.add('waiting');
  els.countdownCircle.textContent = 'Face not detected. Step into frame.';
}

/**
 * Runs the 3-2-1 countdown then enables safe baseline capture mode.
 *
 * @returns {void}
 */
function startCalibration() {
  runtime.appState = APP_STATES.CALIBRATING;
  runtime.baselineDescriptor = null;
  runtime.needBaseline = false;
  runtime.distanceBuffer = [];
  runtime.graphHistory = [];
  setTrackingState(APP_STATES.CALIBRATING);
  setDistanceValue(0);
  resetEqualizer();
  drawGraphCanvas();

  if (els.btnReset) els.btnReset.style.display = 'none';
  if (!els.countdownCircle) {
    armSafeBaselineCapture();
    return;
  }

  let remaining = COUNTDOWN_SECONDS;
  els.countdownCircle.classList.remove('waiting');
  els.countdownCircle.style.display = 'flex';
  els.countdownCircle.textContent = String(remaining);
  setStatus('Calibration countdown started...');

  const interval = window.setInterval(() => {
    remaining -= 1;
    if (remaining > 0) {
      els.countdownCircle.textContent = String(remaining);
      return;
    }
    window.clearInterval(interval);
    armSafeBaselineCapture();
  }, 1000);
}

/**
 * Updates app state after a valid baseline descriptor is captured.
 *
 * @param {Float32Array|number[]} descriptor Descriptor captured from the live frame.
 * @returns {void}
 */
function setBaseline(descriptor) {
  runtime.baselineDescriptor = Array.from(descriptor);
  runtime.needBaseline = false;
  runtime.appState = APP_STATES.TRACKING;
  runtime.distanceBuffer = [];
  runtime.graphHistory = [];
  setTrackingState(APP_STATES.TRACKING);
  setDistanceValue(0);
  drawGraphCanvas();
  setStatus('Tracking active. Move your face to inspect descriptor drift.');

  if (els.countdownCircle) {
    els.countdownCircle.style.display = 'none';
    els.countdownCircle.classList.remove('waiting');
  }
  if (els.btnReset) els.btnReset.style.display = 'inline-flex';
}

/**
 * Handles one detected face based on the current runtime state.
 *
 * @param {*} detection face-api detection result containing a descriptor.
 * @returns {void}
 */
function processDetection(detection) {
  const currentDescriptor = detection.descriptor;
  if (!currentDescriptor) return;

  if (runtime.appState === APP_STATES.CALIBRATING && runtime.needBaseline) {
    setBaseline(currentDescriptor);
    return;
  }

  if (runtime.appState !== APP_STATES.TRACKING || !runtime.baselineDescriptor) return;

  const totalDistance = faceapi.euclideanDistance(runtime.baselineDescriptor, currentDescriptor);
  runtime.distanceBuffer.push(totalDistance);
  setDistanceValue(totalDistance);
  updateEqualizer(runtime.baselineDescriptor, currentDescriptor);

  if (totalDistance > THRESHOLD) setStatus('Distance crossed threshold (0.6): likely non-match');
  else setStatus('Within threshold: likely match');
}

/**
 * Handles "no face detected" situations depending on calibrating/tracking state.
 *
 * @returns {void}
 */
function processNoFace() {
  if (runtime.appState === APP_STATES.CALIBRATING && runtime.needBaseline && els.countdownCircle) {
    els.countdownCircle.style.display = 'flex';
    els.countdownCircle.classList.add('waiting');
    els.countdownCircle.textContent = 'Face not detected. Step into frame.';
  }

  if (runtime.appState === APP_STATES.TRACKING) {
    setStatus('Tracking paused: no face detected.');
  }
}

/**
 * Executes one face-api detection pass and schedules the next animation frame.
 *
 * @returns {Promise<void>}
 */
async function onFrame() {
  if (!runtime.initialized) return;
  runtime.frameRequestId = window.requestAnimationFrame(onFrame);
  if (runtime.detectorBusy) return;
  if (!els.video || els.video.readyState < 2) return;

  runtime.detectorBusy = true;
  try {
    const detection = await faceapi.detectSingleFace(els.video, DETECTOR_OPTIONS)
      .withFaceLandmarks()
      .withFaceDescriptor();

    if (detection) processDetection(detection);
    else processNoFace();
  } catch (err) {
    console.error('realtime detection loop error:', err);
    setStatus('Detection error. Check console.');
  } finally {
    runtime.detectorBusy = false;
  }
}

/**
 * Pulls buffered distance samples, computes an average, and updates graph history.
 *
 * @returns {void}
 */
function flushDistanceBuffer() {
  if (!runtime.distanceBuffer.length) return;
  const sum = runtime.distanceBuffer.reduce((acc, value) => acc + value, 0);
  const averageDistance = sum / runtime.distanceBuffer.length;
  runtime.distanceBuffer = [];

  runtime.graphHistory.push(averageDistance);
  if (runtime.graphHistory.length > GRAPH_MAX_POINTS) runtime.graphHistory.shift();
  drawGraphCanvas();
}

/**
 * Starts the low-frequency graph smoothing loop at 3 updates per second.
 *
 * @returns {void}
 */
function startGraphLoop() {
  if (runtime.graphTimerId) window.clearInterval(runtime.graphTimerId);
  runtime.graphTimerId = window.setInterval(flushDistanceBuffer, 1000 / GRAPH_UPDATES_PER_SECOND);
}

/**
 * Requests camera stream and binds it to the realtime video element.
 *
 * @returns {Promise<void>}
 */
async function initWebcam() {
  if (!els.video) throw new Error('Missing #webcam element');

  const stream = await navigator.mediaDevices.getUserMedia({
    video: { facingMode: 'user' },
    audio: false,
  });
  els.video.srcObject = stream;
  await els.video.play();
  setStatus('Webcam ready. Press "Set Baseline" to start calibration.');
}

/**
 * Loads the face-api models required for descriptor extraction.
 *
 * @returns {Promise<void>}
 */
async function loadModels() {
  const modelUris = [MODEL_URLS.tiny, MODEL_URLS.landmarks, MODEL_URLS.recognition];
  await Promise.all([
    faceapi.nets.tinyFaceDetector.loadFromUri(modelUris[0]),
    faceapi.nets.faceLandmark68Net.loadFromUri(modelUris[1]),
    faceapi.nets.faceRecognitionNet.loadFromUri(modelUris[2]),
  ]);
  setStatus('Models loaded. Preparing camera...');
}

/**
 * Binds interaction and resize listeners needed during runtime.
 *
 * @returns {void}
 */
function bindListeners() {
  if (els.btnReset) els.btnReset.addEventListener('click', startCalibration);
  window.addEventListener('resize', resizeGraphCanvas);
}

/**
 * Initializes realtime visualizer components and starts analysis loops.
 *
 * @returns {Promise<void>}
 */
export async function initRealtime() {
  if (runtime.initialized) return;
  runtime.initialized = true;
  setStatus('Loading models...');
  setTrackingState(APP_STATES.CALIBRATING);

  initEqualizer();
  bindListeners();
  resizeGraphCanvas();
  drawGraphCanvas();

  await loadModels();
  await initWebcam();

  startGraphLoop();
  onFrame();
}

/**
 * Bootstraps realtime visualizer and reports startup errors in the status pill.
 *
 * @returns {void}
 */
async function boot() {
  try {
    await initRealtime();
  } catch (err) {
    console.error('Failed to initialize realtime visualizer:', err);
    setStatus(`Initialization failed: ${err.message || String(err)}`);
  }
}

boot();