scripts/upload-consent.jsjavascript
/**
 * @module upload-consent
 * @description
 * Browser-managed clip upload and receipt ownership flow. The recorder emits a
 * Blob; this module keeps that clip local until explicit consent, sends the
 * backend multipart payload, and stores the returned delete-token receipt in
 * localStorage so the user can revoke later from the upload panel.
 */
import { APP_VERSION, RECORDING_CONFIG, UPLOAD_CONSENT_VERSION } from './config.js';
import { t } from './i18n.js';
import { state } from './state.js';
import { setLog } from './utils.js';

export const RECEIPTS_STORAGE_KEY = 'ghostmaxxing-upload-receipts-v1';
const RECEIPT_HASH_PREFIX = '#receipt=';

function $(selector, root = document) {
   return root.querySelector(selector);
}

function text(value) {
   return value == null || value === '' ? 'n/a' : String(value);
}

function formatBytes(bytes) {
   if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
   if (bytes < 1024) return `${bytes} B`;
   if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
   return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

function randomToken(length = 16) {
   const alphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
   const cryptoApi = globalThis.crypto;
   const bytes = new Uint8Array(length);
   if (cryptoApi && typeof cryptoApi.getRandomValues === 'function') {
      cryptoApi.getRandomValues(bytes);
   } else {
      for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256);
   }
   return Array.from(bytes, (byte) => alphabet[byte % alphabet.length]).join('');
}

export function createReceiptCode() {
   return `GSTMXX-${randomToken(16).match(/.{1,4}/g).join('-')}`;
}

export function loadUploadReceipts() {
   try {
      const raw = localStorage.getItem(RECEIPTS_STORAGE_KEY);
      if (!raw) return [];
      const parsed = JSON.parse(raw);
      return Array.isArray(parsed) ? parsed : [];
   } catch {
      return [];
   }
}

export function persistUploadReceipts(receipts) {
   localStorage.setItem(RECEIPTS_STORAGE_KEY, JSON.stringify(receipts));
}

export function publicUrlForReceipt(uploadId, kind = 'video') {
   const path = kind === 'clipboard'
      ? `/clipboard/${uploadId}.png`
      : `/videos/${uploadId}.mp4`;
   return new URL(path, window.location.origin).href;
}

export function privateReceiptUrl(uploadId, deleteToken) {
   const payload = encodeURIComponent(`${uploadId}.${deleteToken}`);
   return `${window.location.origin}${window.location.pathname}${RECEIPT_HASH_PREFIX}${payload}`;
}

function parseReceiptHash() {
   if (!window.location.hash.startsWith(RECEIPT_HASH_PREFIX)) return null;
   const payload = decodeURIComponent(window.location.hash.slice(RECEIPT_HASH_PREFIX.length));
   const splitAt = payload.indexOf('.');
   if (splitAt < 1) return null;
   const uploadId = payload.slice(0, splitAt);
   const deleteToken = payload.slice(splitAt + 1);
   if (!uploadId || !deleteToken) return null;
   return { uploadId, deleteToken };
}

function compactMatchMetrics(detail) {
   if (!detail || typeof detail !== 'object') return null;
   const faceapi = detail.faceapi || null;
   const mediapipe = detail.mediapipe || null;
   return {
      source: detail.source || null,
      overall: detail.overall || null,
      ghostylePresent: !!detail.ghostylePresent,
      faceapi: faceapi ? {
         detectionState: faceapi.detectionState || null,
         liveMinDist: faceapi.liveMinDist ?? faceapi.distance ?? null,
         obfMinDist: faceapi.obfMinDist ?? null,
         matchedId: faceapi.matchedId ?? faceapi.liveMinId ?? faceapi.obfMinId ?? null
      } : null,
      mediapipe: mediapipe ? {
         detectionState: mediapipe.detectionState || null,
         liveMaxSim: mediapipe.liveMaxSim ?? null,
         obfMaxSim: mediapipe.obfMaxSim ?? null,
         matchedId: mediapipe.matchedId ?? mediapipe.liveMaxId ?? mediapipe.obfMaxId ?? null
      } : null
   };
}

function buildUploadFormData(clip, note) {
   const formData = new FormData();
   formData.append('video', clip.blob, clip.filename);
   formData.set('kind', 'video');
   formData.set('consent_version', UPLOAD_CONSENT_VERSION);
   formData.set('app_version', APP_VERSION);
   if (clip.ghostyleId) formData.set('ghostyle_id', clip.ghostyleId);
   if (note) formData.set('user_note', note);
   if (clip.metrics) formData.set('metrics_json', JSON.stringify(clip.metrics));
   return formData;
}

async function postUpload(clip, note) {
   const response = await fetch(RECORDING_CONFIG.uploadEndpoint, {
      method: 'POST',
      body: buildUploadFormData(clip, note)
   });
   const body = await response.json().catch(() => ({}));
   if (!response.ok || !body.ok) {
      const message = body.message || `${response.status} ${response.statusText}`;
      const err = new Error(message);
      err.status = response.status;
      throw err;
   }
   return body;
}

async function deleteUpload(receipt) {
   const response = await fetch(`${RECORDING_CONFIG.uploadEndpoint}/${encodeURIComponent(receipt.uploadId)}`, {
      method: 'DELETE',
      headers: { 'X-Delete-Token': receipt.deleteToken }
   });
   const body = await response.json().catch(() => ({}));
   if (!response.ok) {
      const err = new Error(body.message || `${response.status} ${response.statusText}`);
      err.status = response.status;
      throw err;
   }
   return body;
}

function emitQueueChanged(count) {
   state.gstmxxEvents.dispatchEvent(new CustomEvent('uploadQueueChanged', { detail: { count } }));
}

function copyText(value) {
   if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
      return navigator.clipboard.writeText(value);
   }
   return Promise.reject(new Error('Clipboard API unavailable'));
}

function addLabelRow(parent, label, value) {
   const row = document.createElement('div');
   row.className = 'upload-meta-row';
   const strong = document.createElement('strong');
   strong.textContent = label;
   const span = document.createElement('span');
   span.textContent = value;
   row.append(strong, span);
   parent.append(row);
}

export function initUploadConsentFlow() {
   const preview = $('#gm-upload-preview');
   const empty = $('#gm-upload-empty');
   const current = $('#gm-upload-current');
   const clipList = $('#gm-upload-clip-list');
   const clipMeta = $('#gm-upload-clip-meta');
   const noteInput = $('#gm-upload-note');
   const consentInput = $('#gm-upload-consent');
   const submitBtn = $('#gm-upload-submit');
   const discardBtn = $('#gm-upload-discard');
   const statusEl = $('#gm-upload-status');
   const receiptsEl = $('#gm-receipt-log');

   if (!preview || !current || !clipList || !receiptsEl) return;

   let clips = [];
   let selectedClipId = null;
   let receipts = loadUploadReceipts();
   let previewUrl = null;
   let lastMetrics = null;

   function selectedClip() {
      return clips.find((clip) => clip.id === selectedClipId) || null;
   }

   function setStatus(message, kind = '') {
      if (!statusEl) return;
      statusEl.textContent = message || '';
      statusEl.dataset.kind = kind;
   }

   function renderClipList() {
      clipList.replaceChildren();
      clips.forEach((clip) => {
         const button = document.createElement('button');
         button.type = 'button';
         button.className = 'upload-chip';
         button.classList.toggle('active', clip.id === selectedClipId);
         button.textContent = `${new Date(clip.recordedAt).toLocaleTimeString()} · ${formatBytes(clip.size)}`;
         button.addEventListener('click', () => {
            selectedClipId = clip.id;
            render();
         });
         clipList.append(button);
      });
   }

   function renderCurrentClip() {
      const clip = selectedClip();
      const hasClip = !!clip;
      if (empty) empty.hidden = hasClip;
      current.hidden = !hasClip;
      if (submitBtn) submitBtn.disabled = !hasClip || !consentInput?.checked;
      if (!clip) {
         if (previewUrl) URL.revokeObjectURL(previewUrl);
         previewUrl = null;
         preview.removeAttribute('src');
         return;
      }

      if (previewUrl) URL.revokeObjectURL(previewUrl);
      previewUrl = URL.createObjectURL(clip.blob);
      preview.src = previewUrl;

      clipMeta.replaceChildren();
      addLabelRow(clipMeta, t('upload_meta_recorded_label'), new Date(clip.recordedAt).toLocaleString());
      addLabelRow(clipMeta, t('upload_meta_size_label'), formatBytes(clip.size));
      addLabelRow(clipMeta, t('upload_meta_ghostyle_label'), text(clip.ghostyleId));
      const overall = clip.metrics?.overall || clip.metrics?.faceapi?.detectionState || null;
      addLabelRow(clipMeta, t('upload_meta_result_label'), text(overall));
   }

   function receiptStatusLabel(receipt) {
      if (receipt.status === 'deleted') return t('receipt_status_deleted');
      if (receipt.status === 'delete-failed') return t('receipt_status_delete_failed');
      if (receipt.status === 'imported') return t('receipt_status_imported');
      return t('receipt_status_pending');
   }

   function renderReceipts() {
      receiptsEl.replaceChildren();
      if (!receipts.length) {
         const p = document.createElement('p');
         p.className = 'upload-empty';
         p.textContent = t('receipt_log_empty');
         receiptsEl.append(p);
         return;
      }

      receipts.forEach((receipt) => {
         const article = document.createElement('article');
         article.className = 'receipt-card';

         const head = document.createElement('div');
         head.className = 'receipt-card__head';
         const title = document.createElement('h3');
         title.textContent = receipt.receiptCode || receipt.uploadId;
         const status = document.createElement('span');
         status.className = 'receipt-status';
         status.textContent = receiptStatusLabel(receipt);
         head.append(title, status);

         const meta = document.createElement('div');
         meta.className = 'upload-meta';
         addLabelRow(meta, t('receipt_upload_id_label'), receipt.uploadId);
         addLabelRow(meta, t('receipt_consent_label'), receipt.consentVersion || UPLOAD_CONSENT_VERSION);
         addLabelRow(meta, t('upload_meta_ghostyle_label'), text(receipt.ghostyleId));

         const actions = document.createElement('div');
         actions.className = 'receipt-actions';

         const publicLink = document.createElement('a');
         publicLink.className = 'secondary-btn';
         publicLink.href = receipt.publicUrl;
         publicLink.target = '_blank';
         publicLink.rel = 'noopener noreferrer';
         publicLink.textContent = t('receipt_open_public_button');

         const copy = document.createElement('button');
         copy.type = 'button';
         copy.className = 'secondary-btn';
         copy.textContent = t('receipt_copy_private_button');
         copy.addEventListener('click', async () => {
            try {
               await copyText(receipt.privateUrl);
               setStatus(t('receipt_copied_status'), 'ok');
            } catch (err) {
               setStatus(t('receipt_copy_failed_status'), 'error');
            }
         });

         const revoke = document.createElement('button');
         revoke.type = 'button';
         revoke.className = 'secondary-btn warn';
         revoke.disabled = receipt.status === 'deleted';
         revoke.textContent = t('receipt_revoke_button');
         revoke.addEventListener('click', async () => {
            revoke.disabled = true;
            setStatus(t('receipt_revoke_working_status'), 'working');
            try {
               await deleteUpload(receipt);
               receipt.status = 'deleted';
               receipt.deletedAt = new Date().toISOString();
               persistUploadReceipts(receipts);
               setStatus(t('receipt_revoke_done_status'), 'ok');
            } catch (err) {
               receipt.status = err.status === 410 ? 'deleted' : 'delete-failed';
               receipt.lastError = err.message;
               persistUploadReceipts(receipts);
               setStatus(t('receipt_revoke_failed_status', { message: err.message }), 'error');
            }
            renderReceipts();
         });

         actions.append(publicLink, copy, revoke);
         article.append(head, meta, actions);
         receiptsEl.append(article);
      });
   }

   function render() {
      renderClipList();
      renderCurrentClip();
      renderReceipts();
      emitQueueChanged(clips.length);
   }

   function importReceiptFromHash() {
      const imported = parseReceiptHash();
      if (!imported) return;
      if (receipts.some((receipt) => receipt.uploadId === imported.uploadId)) return;
      const receipt = {
         id: imported.uploadId,
         uploadId: imported.uploadId,
         deleteToken: imported.deleteToken,
         receiptCode: createReceiptCode(),
         kind: 'video',
         consentVersion: UPLOAD_CONSENT_VERSION,
         publicUrl: publicUrlForReceipt(imported.uploadId, 'video'),
         privateUrl: privateReceiptUrl(imported.uploadId, imported.deleteToken),
         status: 'imported',
         createdAt: new Date().toISOString()
      };
      receipts.unshift(receipt);
      persistUploadReceipts(receipts);
   }

   state.gstmxxEvents.addEventListener('matchStateChanged', (event) => {
      lastMetrics = compactMatchMetrics(event.detail);
   });

   state.gstmxxEvents.addEventListener('clipRecorded', (event) => {
      const clip = {
         ...event.detail,
         metrics: lastMetrics
      };
      clips.unshift(clip);
      selectedClipId = clip.id;
      if (consentInput) consentInput.checked = false;
      setStatus(t('upload_clip_ready_status'), 'ok');
      render();
   });

   consentInput?.addEventListener('change', renderCurrentClip);

   discardBtn?.addEventListener('click', () => {
      const clip = selectedClip();
      if (!clip) return;
      clips = clips.filter((item) => item.id !== clip.id);
      selectedClipId = clips[0]?.id || null;
      setStatus(t('upload_clip_discarded_status'), 'ok');
      render();
   });

   submitBtn?.addEventListener('click', async () => {
      const clip = selectedClip();
      if (!clip || !consentInput?.checked) return;
      const note = (noteInput?.value || '').trim();
      submitBtn.disabled = true;
      setStatus(t('video_uploading_log'), 'working');
      try {
         const body = await postUpload(clip, note);
         const receipt = {
            id: body.uploadId,
            uploadId: body.uploadId,
            deleteToken: body.deleteToken,
            receiptCode: createReceiptCode(),
            kind: 'video',
            consentVersion: UPLOAD_CONSENT_VERSION,
            ghostyleId: clip.ghostyleId || null,
            appVersion: APP_VERSION,
            userNote: note,
            publicUrl: publicUrlForReceipt(body.uploadId, 'video'),
            privateUrl: privateReceiptUrl(body.uploadId, body.deleteToken),
            status: 'pending',
            createdAt: new Date().toISOString()
         };
         receipts.unshift(receipt);
         persistUploadReceipts(receipts);
         clips = clips.filter((item) => item.id !== clip.id);
         selectedClipId = clips[0]?.id || null;
         if (noteInput) noteInput.value = '';
         if (consentInput) consentInput.checked = false;
         setLog(t('video_upload_done_log', { status: 201, filename: clip.filename }));
         setStatus(t('upload_receipt_created_status'), 'ok');
      } catch (err) {
         setStatus(t('video_upload_network_error_log', { message: err.message }), 'error');
      }
      render();
   });

   importReceiptFromHash();
   render();
}