/**
 * data-stream — attribute-driven data ingestion, signal-backed.
 *
 * Architecture (signal-based, refcounted):
 *
 *   ┌──────────────────────────────────────────────────────────┐
 *   │ STREAMS: Map<streamId, { signal, refs, transport, opts }>│
 *   └──────────────────────────────────────────────────────────┘
 *                  │ first ref creates transport
 *                  │ each tick / message: signal.value = parsed
 *                  │ last ref ends transport
 *                  ▼
 *   ┌──────────────────────────────────────────────────────────┐
 *   │ Per-element effect:                                      │
 *   │   effect(() => applyData(el, stream.signal.value, opts)) │
 *   │ Subscribes to the shared signal — fires on every update. │
 *   └──────────────────────────────────────────────────────────┘
 *
 * Stream identity:
 *   - Explicit `data-stream-id="..."` shares across elements.
 *   - When unset, the id is a stable hash of (src, mode, interval,
 *     method, body, headers, format, event). Two elements with
 *     attribute-identical configs share one transport automatically.
 *
 * Universal applicability — any element with a settable property:
 *   <chart-ui    data-stream-src="/api/series" data-stream-path="data">
 *   <table-ui    data-stream-src="/api/orders" data-stream-interval="5000">
 *   <stat-ui     data-stream-src="/api/kpi"    data-stream-target="*">
 *   <text-ui     data-stream-src="/api/range"  data-stream-target="textContent">
 *   <heatmap-ui  data-stream-src="/sse/cells"  data-stream-mode="sse">
 *
 * Programmatic access: `streams` is exported as a read-only Map of
 * stream-id → signal so app code can subscribe with `effect()` or read
 * `.value` directly.
 *
 * Events (all bubble from the element):
 *   stream-load   — first signal value received for this element
 *   stream-update — each subsequent value, detail.data = the new value
 *   stream-error  — transport-level error, detail.error = message
 */

import { signal, effect, untracked } from './signals.js';

const ATTRS = {
  src:       'data-stream-src',
  mode:      'data-stream-mode',
  interval:  'data-stream-interval',
  path:      'data-stream-path',
  event:     'data-stream-event',
  method:    'data-stream-method',
  headers:   'data-stream-headers',
  body:      'data-stream-body',
  target:    'data-stream-target',
  merge:     'data-stream-merge',
  format:    'data-stream-format',
  id:        'data-stream-id',
};

const STREAMS = new Map();           /* streamId → { signal, refs, transport, opts } */
const ELEMENT_STREAMS = new WeakMap(); /* el → { streamId, dispose, loaded } */
const PENDING = new Map();           /* streamId → array of whenStream resolvers */
const WARNED = new WeakSet();

/* Read-only public view of the streams registry. App code can do:
   `import { streams } from '@adia-ai/web-components/core/data-stream';
    streams.get('rev').signal.value` to read; subscribe via effect(). */
export const streams = {
  get(id) { return STREAMS.get(id); },
  has(id) { return STREAMS.has(id); },
  keys()  { return STREAMS.keys(); },
  size()  { return STREAMS.size; },
};

/* Resolves with the stream entry once it's registered. If the stream
   already exists, resolves synchronously on the next microtask; if not,
   resolves the moment the first DOM consumer (or any caller of
   acquireStream) creates it. Useful for cross-package adapters
   (streams-bridge, A2UI surfaces) that may run before the DOM source
   has connected. The promise never rejects — pair with AbortSignal at
   the caller if you need cancellation. */
export function whenStream(id) {
  if (STREAMS.has(id)) return Promise.resolve(STREAMS.get(id));
  return new Promise((resolve) => {
    if (!PENDING.has(id)) PENDING.set(id, []);
    PENDING.get(id).push(resolve);
  });
}

function attr(el, key) { return el.getAttribute(ATTRS[key]) ?? ''; }
function dispatch(el, name, detail) {
  el.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
}
function warnOnce(el, msg) {
  if (WARNED.has(el)) return;
  WARNED.add(el);
  console.warn(`[data-stream] ${msg}`);
}

/* Stable string hash of a config for auto-id. djb2-style; collisions
   would be benign (two streams share a transport that fetches the same
   thing) but practically impossible for distinct configs. */
function configHash(parts) {
  const s = parts.join('|');
  let h = 5381;
  for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
  return `auto:${(h >>> 0).toString(36)}`;
}

function streamIdFor(el) {
  const explicit = attr(el, 'id');
  if (explicit) return `id:${explicit}`;
  return configHash([
    attr(el, 'src'), attr(el, 'mode'), attr(el, 'interval'),
    attr(el, 'method'), attr(el, 'body'), attr(el, 'headers'),
    attr(el, 'format'), attr(el, 'event'),
  ]);
}

function streamConfigFor(el) {
  return {
    src:      attr(el, 'src'),
    mode:     (attr(el, 'mode') || 'http').toLowerCase(),
    interval: parseInt(attr(el, 'interval'), 10) || 0,
    method:   attr(el, 'method') || 'GET',
    body:     attr(el, 'body') || undefined,
    headers:  parseHeaders(attr(el, 'headers')),
    format:   attr(el, 'format'),
    event:    attr(el, 'event') || 'message',
  };
}

function parseHeaders(raw) {
  if (!raw) return undefined;
  try { return JSON.parse(raw); } catch { return undefined; }
}

/* ── Format parsing (CSV / TSV / JSONL / text / json) ──────────────── */

function detectFormat(src, contentType) {
  const url = src.toLowerCase().split('?')[0];
  if (url.endsWith('.csv'))   return 'csv';
  if (url.endsWith('.tsv'))   return 'tsv';
  if (url.endsWith('.jsonl') || url.endsWith('.ndjson')) return 'jsonl';
  if (url.endsWith('.txt'))   return 'text';
  const ct = (contentType || '').toLowerCase();
  if (ct.includes('text/csv'))                  return 'csv';
  if (ct.includes('text/tab-separated-values')) return 'tsv';
  if (ct.includes('application/x-ndjson') ||
      ct.includes('application/jsonl'))         return 'jsonl';
  if (ct.includes('text/plain') && !ct.includes('json')) return 'text';
  return 'json';
}

function parseDelimited(text, delimiter) {
  const rows = []; let row = []; let cell = ''; let inQuote = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (inQuote) {
      if (c === '"') { if (text[i + 1] === '"') { cell += '"'; i++; } else inQuote = false; }
      else cell += c;
    } else {
      if (c === '"' && cell === '') inQuote = true;
      else if (c === delimiter) { row.push(cell); cell = ''; }
      else if (c === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; }
      else if (c === '\r') { /* skip */ }
      else cell += c;
    }
  }
  if (cell !== '' || row.length > 0) { row.push(cell); rows.push(row); }
  if (rows.length === 0) return [];
  const header = rows.shift();
  return rows
    .filter(r => !(r.length === 1 && r[0] === ''))
    .filter(r => r.length === header.length)
    .map(r => {
      const obj = {};
      for (let i = 0; i < header.length; i++) {
        const v = r[i]; const n = +v;
        obj[header[i]] = (v !== '' && Number.isFinite(n) && /^-?\d/.test(v)) ? n : v;
      }
      return obj;
    });
}

function parseJSONL(text) {
  const out = [];
  for (const line of text.split('\n')) {
    const t = line.trim();
    if (!t || t.startsWith('//')) continue;
    try { out.push(JSON.parse(t)); } catch { /* skip */ }
  }
  return out;
}

function parseBody(text, format) {
  switch (format) {
    case 'json':  return JSON.parse(text);
    case 'csv':   return parseDelimited(text, ',');
    case 'tsv':   return parseDelimited(text, '\t');
    case 'jsonl': return parseJSONL(text);
    case 'text':  return text;
    default:      return JSON.parse(text);
  }
}

/* ── Transports (HTTP / SSE / WS) — write to stream.signal ─────────── */

function startTransport(stream) {
  const { src, mode } = stream.opts;
  if (!src) return { stop() {} };
  if (mode === 'http') return startHTTP(stream);
  if (mode === 'sse')  return startSSE(stream);
  if (mode === 'ws' || mode === 'websocket') return startWS(stream);
  console.warn(`[data-stream] Unknown mode "${mode}". Use http, sse, or ws.`);
  return { stop() {} };
}

function startHTTP(stream) {
  const { src, interval, method, body, headers, format: explicit } = stream.opts;
  const abort = new AbortController();
  let timer = null;
  let stopped = false;

  const tick = async () => {
    if (stopped) return;
    try {
      const r = await fetch(src, { method, headers, body, signal: abort.signal });
      if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
      const text = await r.text();
      if (stopped) return;
      const ct = r.headers.get('content-type') || '';
      const format = explicit || detectFormat(src, ct);
      let data;
      try { data = parseBody(text, format); }
      catch (e) { stream.lastError = `Parse error (${format}): ${e.message}`; return; }
      stream.signal.value = data;
    } catch (e) {
      if (e.name === 'AbortError') return;
      stream.lastError = e.message;
      stream.errorTick = (stream.errorTick || 0) + 1;
      /* Bump error counter signal so subscribers see it too. */
      if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
    }
  };

  tick();
  if (interval > 0) timer = setInterval(tick, interval);

  return {
    stop() {
      stopped = true;
      abort.abort();
      if (timer) clearInterval(timer);
    },
  };
}

function startSSE(stream) {
  const { src, event: eventName, format: explicit } = stream.opts;
  if (typeof EventSource === 'undefined') {
    console.warn('[data-stream] EventSource not available; SSE mode is a no-op here.');
    return { stop() {} };
  }
  const es = new EventSource(src);
  es.addEventListener(eventName, (e) => {
    try {
      const format = explicit || 'json';
      stream.signal.value = parseBody(e.data, format);
    } catch (err) {
      stream.lastError = `SSE parse error: ${err.message}`;
      stream.errorTick = (stream.errorTick || 0) + 1;
      if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
    }
  });
  es.addEventListener('error', () => {
    stream.lastError = 'SSE connection error';
    stream.errorTick = (stream.errorTick || 0) + 1;
    if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
  });
  return { stop() { es.close(); } };
}

function startWS(stream) {
  const { src, format: explicit } = stream.opts;
  if (typeof WebSocket === 'undefined') {
    console.warn('[data-stream] WebSocket not available; ws mode is a no-op here.');
    return { stop() {} };
  }
  const ws = new WebSocket(src);
  ws.addEventListener('message', (e) => {
    try {
      const format = explicit || 'json';
      stream.signal.value = parseBody(typeof e.data === 'string' ? e.data : String(e.data), format);
    } catch (err) {
      stream.lastError = `WebSocket parse error: ${err.message}`;
      stream.errorTick = (stream.errorTick || 0) + 1;
      if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
    }
  });
  ws.addEventListener('error', () => {
    stream.lastError = 'WebSocket error';
    stream.errorTick = (stream.errorTick || 0) + 1;
    if (stream.errorSignal) stream.errorSignal.value = stream.errorTick;
  });
  return { stop() { ws.close(); } };
}

/* ── Stream registry ───────────────────────────────────────────────── */

function acquireStream(id, opts) {
  let stream = STREAMS.get(id);
  if (stream) {
    stream.refs++;
    return stream;
  }
  stream = {
    id,
    opts,
    signal: signal(null),
    errorSignal: signal(0),  /* monotonic — bumps on each transport-level error */
    refs: 1,
    transport: null,
    lastError: null,
    errorTick: 0,
  };
  STREAMS.set(id, stream);
  stream.transport = startTransport(stream);
  if (PENDING.has(id)) {
    const resolvers = PENDING.get(id);
    PENDING.delete(id);
    for (const r of resolvers) r(stream);
  }
  return stream;
}

function releaseStream(stream) {
  stream.refs--;
  if (stream.refs <= 0) {
    stream.transport?.stop();
    STREAMS.delete(stream.id);
  }
}

/* ── Apply incoming data to the element ────────────────────────────── */

function resolvePath(obj, path) {
  if (!path) return obj;
  let cursor = obj;
  for (const seg of path.split('.')) {
    if (cursor == null) return null;
    cursor = cursor[seg];
  }
  return cursor;
}

function applyData(el, raw, opts) {
  let data = resolvePath(raw, opts.path);
  if (data == null) return;

  const target = opts.target || 'data';
  const merge = opts.merge || 'replace';

  if (target === '*') {
    if (data && typeof data === 'object' && !Array.isArray(data)) {
      Object.assign(el, data);
      dispatch(el, 'stream-update', { data });
      return;
    }
    el.data = data;
    dispatch(el, 'stream-update', { data });
    return;
  }

  if ((merge === 'append' || merge === 'prepend') && Array.isArray(data) && Array.isArray(el[target])) {
    el[target] = merge === 'append'
      ? [...el[target], ...data]
      : [...data, ...el[target]];
  } else {
    el[target] = data;
  }
  dispatch(el, 'stream-update', { data });
}

/* ── Per-element lifecycle ─────────────────────────────────────────── */

export function start(el) {
  stop(el);
  if (!el.isConnected) return;
  const src = attr(el, 'src');
  if (!src) return;

  const id = streamIdFor(el);
  const opts = streamConfigFor(el);
  const stream = acquireStream(id, opts);

  /* Per-element apply options — read fresh each effect run so live attr
     edits to path/target/merge take effect without restart. */
  const elState = { streamId: id, loaded: false, dispose: null };
  ELEMENT_STREAMS.set(el, elState);

  /* Subscribe to data + error signals via two effects so each can dispose
     independently. The data effect short-circuits on initial null. */
  const dataDispose = effect(() => {
    const value = stream.signal.value;
    if (value == null) return;
    untracked(() => {
      if (!elState.loaded) {
        elState.loaded = true;
        dispatch(el, 'stream-load', {});
      }
      const applyOpts = {
        path:   attr(el, 'path'),
        target: attr(el, 'target') || 'data',
        merge:  attr(el, 'merge')  || 'replace',
      };
      applyData(el, value, applyOpts);
    });
  });

  const errorDispose = effect(() => {
    const tick = stream.errorSignal.value;
    if (tick === 0) return;
    untracked(() => {
      if (stream.lastError) dispatch(el, 'stream-error', { error: stream.lastError });
    });
  });

  elState.dispose = () => { dataDispose(); errorDispose(); };
}

export function stop(el) {
  const entry = ELEMENT_STREAMS.get(el);
  if (!entry) return;
  entry.dispose?.();
  const stream = STREAMS.get(entry.streamId);
  if (stream) releaseStream(stream);
  ELEMENT_STREAMS.delete(el);
}

/* ── Document-level observer ───────────────────────────────────────── */

const ATTR_FILTER = Object.values(ATTRS);

function isStreamingEl(node) {
  return node && node.nodeType === 1 && node.hasAttribute && node.hasAttribute(ATTRS.src);
}

function visitSubtree(root, fn) {
  if (root.nodeType !== 1) return;
  if (isStreamingEl(root)) fn(root);
  if (root.querySelectorAll) {
    root.querySelectorAll(`[${ATTRS.src}]`).forEach(fn);
  }
}

const observer = new MutationObserver((mutations) => {
  for (const m of mutations) {
    if (m.type === 'attributes' && ATTR_FILTER.includes(m.attributeName)) {
      const el = m.target;
      if (isStreamingEl(el)) start(el);
      else stop(el);
      continue;
    }
    if (m.type === 'childList') {
      m.addedNodes.forEach(n => visitSubtree(n, start));
      m.removedNodes.forEach(n => visitSubtree(n, stop));
    }
  }
});

function bootstrap() {
  if (typeof document === 'undefined') return;
  observer.observe(document.documentElement, {
    childList: true, subtree: true,
    attributes: true, attributeFilter: ATTR_FILTER,
  });
  document.querySelectorAll(`[${ATTRS.src}]`).forEach(start);
}

if (typeof document !== 'undefined') {
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
  } else {
    bootstrap();
  }
}
