// ws.js — thin storage adapter over generic KV backend (HTTP + WebSocket).
// Passthrough: no shape translation. Backend = firebase mirror.
// Implements same interface as memory.js. Tested by storage contract vs running server.

'use strict';

// Native browser WebSocket if present, else ws pkg (Node/jest).
// Lazy load ws pkg so CRA prod build (ESM) doesn't choke on require().
let WebSocketImpl;
if (typeof WebSocket !== 'undefined') {
  WebSocketImpl = WebSocket;
}

function createWsStorage({ baseUrl, wsUrl } = {}) {
  // Same-origin by default: empty baseUrl = relative fetch (Caddy/proxy).
  // Fallback to localhost for bare `npm start` dev without proxy.
  const API = (baseUrl || (typeof window !== 'undefined' && window.location ? '' : 'http://127.0.0.1:4001')).replace(/\/$/, '');
  let WS;
  if (wsUrl) {
    WS = wsUrl;
  } else if (typeof window !== 'undefined' && window.location) {
    // derive from current origin (http→ws, https→wss), same host/port.
    const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    WS = `${proto}//${window.location.host}/ws`;
  } else {
    WS = 'ws://127.0.0.1:4001/ws';
  }

  // App passes firebase-prefixed paths: artifacts/{APP_ID}/public/data/campaigns/...
  // Backend uses canonical paths. Strip prefix.
  function norm(p) {
    if (!p) return p;
    return p.replace(/^[\s\S]*\/public\/data\//, '');
  }

  const docSubs = new Map();      // path -> Set<cb>
  const collSubs = new Map();     // collPath -> Set<cb>
  let ws = null;
  let wsReady = null;

  let disposed = false;
  let reconnectTimer = null;
  let everConnected = false;
  const RECONNECT_DELAY = 500;

  function ensureWs() {
    if (wsReady) return wsReady;
    wsReady = new Promise((resolve, reject) => {
      (async () => {
        // Node/jest only: load ws pkg via dynamic import. Browser uses global
        // WebSocket. Avoids require() in CRA prod ESM bundle (webpack crash).
        let WsClass = WebSocketImpl;
        if (!WsClass) {
          const wsPkg = await import('ws');
          WsClass = wsPkg.WebSocket;
        }
        ws = new WsClass(WS);
      const onOpen = () => {
        const isReconnect = everConnected;
        everConnected = true;
        // resubscribe all existing subscribers after (re)connect
        for (const p of docSubs.keys()) {
          ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p }));
        }
        for (const p of collSubs.keys()) {
          ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
        }
        // On RECONNECT only: re-fetch current values — catches writes that
        // happened while disconnected (broadcast missed). Skip on first connect
        // (initial REST fetch in subscribeDoc/subscribeCollection already did).
        if (isReconnect) {
          for (const [p, cbs] of docSubs) {
            storage.getDoc(p).then(doc => { cbs.forEach(cb => cb(doc)); }).catch(() => {});
          }
          for (const [p, cbs] of collSubs) {
            storage.getCollection(p).then(docs => { cbs.forEach(cb => cb(docs)); }).catch(() => {});
          }
        }
        resolve(ws);
      };
      const onError = (err) => { wsReady = null; reject(err instanceof Event ? new Error('ws error') : err); };
      const onClose = () => {
        wsReady = null;
        ws = null;
        if (disposed) return;
        // auto-reconnect (BUG-8): try again after delay. ensureWs() re-arms.
        if (reconnectTimer) clearTimeout(reconnectTimer);
        reconnectTimer = setTimeout(() => {
          reconnectTimer = null;
          if (!disposed) ensureWs().catch(() => {});
        }, RECONNECT_DELAY);
        if (reconnectTimer && typeof reconnectTimer.unref === 'function') reconnectTimer.unref();
      };
      const onMessage = (ev) => {
        const raw = typeof ev === 'string' ? ev : (ev.data !== undefined ? ev.data : ev);
        let msg; try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); } catch { return; }
        handleMessage(msg);
      };
      ws.onopen = onOpen;
      ws.onerror = onError;
      ws.onclose = onClose;
      ws.onmessage = onMessage;
      if (typeof ws.addEventListener === 'function') {
        ws.addEventListener('open', onOpen);
        ws.addEventListener('error', onError);
        ws.addEventListener('close', onClose);
        ws.addEventListener('message', onMessage);
      }
      })();
    });
    return wsReady;
  }

  // Backend pushes change notices keyed by path. Re-fetch affected subscribers.
  async function handleMessage(msg) {
    if (msg.type !== 'change' || !msg.change) return;
    const c = msg.change;
    // doc subscriber at exact changed path
    const docCbs = docSubs.get(c.path);
    if (docCbs) {
      const doc = await storage.getDoc(c.path);
      docCbs.forEach(cb => cb(doc));
    }
    // collection subscribers at parent path (doc belongs to this collection)
    if (c.parent) {
      const collCbs = collSubs.get(c.parent);
      if (collCbs) {
        const docs = await storage.getCollection(c.parent);
        collCbs.forEach(cb => cb(docs));
      }
    }
  }

  async function api(method, path, query, body) {
    let url = `${API}${path}`;
    if (query) {
      const qs = new URLSearchParams(query).toString();
      url += `?${qs}`;
    }
    const res = await fetch(url, {
      method,
      headers: body ? { 'Content-Type': 'application/json' } : undefined,
      body: body ? JSON.stringify(body) : undefined,
    });
    if (!res.ok) {
      const t = await res.text().catch(() => '');
      throw new Error(`API ${method} ${path} ${res.status}: ${t}`);
    }
    const text = await res.text();
    return text ? JSON.parse(text) : null;
  }

  const storage = {
    async getDoc(rawPath) {
      const p = norm(rawPath);
      const res = await api('GET', '/api/doc', { path: p });
      return res && res.data !== undefined ? res.data : null;
    },

    async setDoc(rawPath, data) {
      const p = norm(rawPath);
      await api('PUT', '/api/doc', null, { path: p, data });
    },

    async updateDoc(rawPath, patch) {
      const p = norm(rawPath);
      await api('PATCH', '/api/doc', null, { path: p, patch });
    },

    async deleteDoc(rawPath) {
      const p = norm(rawPath);
      await api('DELETE', '/api/doc', { path: p });
    },

    async addDoc(rawCollPath, data) {
      const p = norm(rawCollPath);
      const res = await api('POST', '/api/collection', null, { path: p, data });
      return { id: res.id, path: res.path };
    },

    async getCollection(rawCollPath) {
      const p = norm(rawCollPath);
      return await api('GET', '/api/collection', { path: p });
    },

    async batchWrite(ops) {
      const normOps = ops.map(op => ({ ...op, path: norm(op.path) }));
      await api('POST', '/api/batch', null, { ops: normOps });
    },

    subscribeDoc(rawPath, cb) {
      const p = norm(rawPath);
      // Initial value via REST (independent of WS connect).
      storage.getDoc(p).then(cb).catch(() => {});
      // WS only for subsequent change notifications.
      ensureWs().then(() => {
        ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p }));
      }).catch(() => {});
      if (!docSubs.has(p)) docSubs.set(p, new Set());
      docSubs.get(p).add(cb);
      return () => { docSubs.get(p)?.delete(cb); };
    },

    subscribeCollection(rawCollPath, cb) {
      const p = norm(rawCollPath);
      // Initial value via REST (independent of WS connect).
      storage.getCollection(p).then(cb).catch(() => {});
      // WS only for subsequent change notifications.
      ensureWs().then(() => {
        ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
      }).catch(() => {});
      if (!collSubs.has(p)) collSubs.set(p, new Set());
      collSubs.get(p).add(cb);
      return () => { collSubs.get(p)?.delete(cb); };
    },

    dispose(cb) {
      disposed = true;
      if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
      if (ws) ws.close();
      docSubs.clear(); collSubs.clear();
      if (typeof cb === 'function') cb();
    },

    _api: api,
    _test: {
      getWs: () => ws,
      forceDrop: () => { if (ws) ws.close(); },
      getReady: () => wsReady,
      docSubs, collSubs,
    },
  };

  return storage;
}

export { createWsStorage };
