/* global */
// Real backend wiring. Reads runtime config from <meta> tags so deploy can
// rewrite values without a rebuild. When config is missing the app falls back
// to the mock data baked into data.jsx — useful for local pixel-perfect dev.

(function () {
  const meta = (name) => document.querySelector(`meta[name="${name}"]`)?.content?.trim() || '';

  const CFG = {
    apiBase:        meta('cac-api-base')         || '/api',
    cognitoRegion:  meta('cac-cognito-region')   || 'eu-west-1',
    cognitoClient:  meta('cac-cognito-client-id'),
    // Billing flag: only enable Subscribe/upgrade flows once Stripe is wired.
    // Defaults to disabled so a misconfigured deploy never shows broken CTAs.
    billingEnabled: meta('cac-billing-enabled').toLowerCase() === 'true',
  };
  const HAS_BACKEND = Boolean(CFG.cognitoClient);

  /* ── token storage ───────────────────────────────────────────────────── */
  const TOKEN_KEY = 'cac.tokens';
  const loadTokens = () => {
    try { return JSON.parse(sessionStorage.getItem(TOKEN_KEY) || 'null'); } catch { return null; }
  };
  const saveTokens = (t) => {
    if (t) sessionStorage.setItem(TOKEN_KEY, JSON.stringify(t));
    else sessionStorage.removeItem(TOKEN_KEY);
  };

  /* ── Cognito raw calls ───────────────────────────────────────────────── */
  const COG_ENDPOINT = `https://cognito-idp.${CFG.cognitoRegion}.amazonaws.com/`;
  // User-aimed messages only. Anything not in this map gets a generic
  // fallback — we never surface raw AWS error text to the form (it can
  // leak resource IDs like user pool client IDs).
  const ERR_MSG = {
    NotAuthorizedException: 'Incorrect email or password.',
    UserNotFoundException: 'No account found with this email.',
    UserNotConfirmedException: "Your email isn't verified yet.",
    UsernameExistsException: 'An account with this email already exists.',
    InvalidPasswordException: 'Password must be 8+ chars with upper, lower, and a digit.',
    InvalidParameterException: 'Please check the details you entered and try again.',
    CodeMismatchException: 'That code is invalid or has expired.',
    ExpiredCodeException: 'That code has expired. Request a new one.',
    LimitExceededException: 'Too many attempts. Please try again in a few minutes.',
    TooManyRequestsException: 'Too many attempts. Please try again in a few minutes.',
    TooManyFailedAttemptsException: 'Too many failed attempts. Please try again later.',
    PasswordResetRequiredException: 'Please reset your password to continue.',
    UserLambdaValidationException: 'We could not complete that request. Please try again.',
  };
  const GENERIC_AUTH_ERR = 'Something went wrong. Please try again.';

  async function cognito(action, body) {
    let res, data;
    try {
      res = await fetch(COG_ENDPOINT, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-amz-json-1.1',
          'X-Amz-Target': `AWSCognitoIdentityProviderService.${action}`,
        },
        body: JSON.stringify(body),
      });
      data = await res.json().catch(() => ({}));
    } catch {
      const e = new Error('Network error. Please check your connection and try again.');
      e.code = 'NetworkError'; throw e;
    }
    if (!res.ok) {
      const code = (data.__type || '').split('#').pop() || 'AuthError';
      const message = ERR_MSG[code] || GENERIC_AUTH_ERR;
      const e = new Error(message); e.code = code; throw e;
    }
    return data;
  }

  async function signIn(email, password) {
    if (!HAS_BACKEND) throw new Error('Backend not configured');
    const r = await cognito('InitiateAuth', {
      AuthFlow: 'USER_PASSWORD_AUTH',
      ClientId: CFG.cognitoClient,
      AuthParameters: { USERNAME: email, PASSWORD: password },
    });
    const a = r.AuthenticationResult;
    const tokens = {
      idToken: a.IdToken, accessToken: a.AccessToken, refreshToken: a.RefreshToken,
      expiresAt: Date.now() + a.ExpiresIn * 1000,
    };
    saveTokens(tokens);
    return tokens;
  }

  async function signUp(email, password, profile = {}) {
    if (!HAS_BACKEND) throw new Error('Backend not configured');
    const attrs = [{ Name: 'email', Value: email }];
    if (profile.givenName)  attrs.push({ Name: 'given_name',  Value: profile.givenName });
    if (profile.familyName) attrs.push({ Name: 'family_name', Value: profile.familyName });
    await cognito('SignUp', {
      ClientId: CFG.cognitoClient,
      Username: email,
      Password: password,
      UserAttributes: attrs,
    });
  }

  // Updates given_name / family_name on the signed-in user, then refreshes
  // tokens so the new values appear in the ID-token claims used by the UI.
  async function updateProfile({ givenName, familyName }) {
    if (!HAS_BACKEND) throw new Error('Backend not configured');
    const t = loadTokens();
    if (!t?.accessToken) throw new Error('Not authenticated');
    const attrs = [];
    if (typeof givenName  === 'string') attrs.push({ Name: 'given_name',  Value: givenName });
    if (typeof familyName === 'string') attrs.push({ Name: 'family_name', Value: familyName });
    if (attrs.length === 0) return;
    await cognito('UpdateUserAttributes', { AccessToken: t.accessToken, UserAttributes: attrs });
    await refresh();
    invalidate('user');
  }

  async function sendPasswordReset(email) {
    if (!HAS_BACKEND) throw new Error('Backend not configured');
    await cognito('ForgotPassword', { ClientId: CFG.cognitoClient, Username: email });
  }

  async function refresh() {
    const t = loadTokens();
    if (!t?.refreshToken) return null;
    const r = await cognito('InitiateAuth', {
      AuthFlow: 'REFRESH_TOKEN_AUTH',
      ClientId: CFG.cognitoClient,
      AuthParameters: { REFRESH_TOKEN: t.refreshToken },
    }).catch(() => null);
    if (!r?.AuthenticationResult) { saveTokens(null); return null; }
    const a = r.AuthenticationResult;
    const next = { ...t, idToken: a.IdToken, accessToken: a.AccessToken, expiresAt: Date.now() + a.ExpiresIn * 1000 };
    saveTokens(next);
    return next;
  }

  function signOut() {
    const t = loadTokens();
    if (t?.accessToken) {
      cognito('GlobalSignOut', { AccessToken: t.accessToken }).catch(() => {});
    }
    saveTokens(null);
    // Wipe the in-memory cache so the next sign-in (potentially a different
    // user) doesn't read the previous session's /user, /preferences, etc.
    _cache.clear();
  }

  /* ── REST API wrapper ────────────────────────────────────────────────── */
  async function request(path, opts = {}) {
    let tokens = loadTokens();
    if (tokens && tokens.expiresAt - Date.now() < 60_000) {
      tokens = (await refresh()) || tokens;
    }
    if (!tokens?.idToken) { const e = new Error('Not authenticated'); e.status = 401; throw e; }
    const res = await fetch(`${CFG.apiBase}${path}`, {
      ...opts,
      headers: {
        'Content-Type': 'application/json',
        'Authorization': tokens.idToken,
        ...(opts.headers || {}),
      },
    });
    if (res.status === 401) { saveTokens(null); const e = new Error('Session expired'); e.status = 401; throw e; }
    if (!res.ok) {
      // Read+discard the body so the connection can be reused, but never
      // surface raw server text — it can include stack traces or AWS IDs.
      // Callers that need a user-aimed message should map by status code.
      const raw = await res.text().catch(() => '');
      let userMsg;
      if (res.status === 403) userMsg = "You don't have access to do that.";
      else if (res.status === 404) userMsg = 'We could not find that.';
      else if (res.status === 409) userMsg = 'That conflicts with the current state. Please refresh and try again.';
      else if (res.status === 429) userMsg = 'Too many requests. Please try again in a moment.';
      else if (res.status >= 500) userMsg = 'The service is having trouble right now. Please try again.';
      else userMsg = 'Request failed. Please try again.';
      const e = new Error(userMsg); e.status = res.status; e.raw = raw; throw e;
    }
    return res.json();
  }
  const apiGet  = (p)    => request(p, { method: 'GET' });
  const apiPost = (p, b) => request(p, { method: 'POST', body: JSON.stringify(b ?? {}) });
  const apiDel  = (p)    => request(p, { method: 'DELETE' });

  /* ── In-memory response cache ────────────────────────────────────────────
   * Map keyed by request signature. Entries carry a TTL and a tag set so
   * mutations can invalidate everything they touch (e.g. sending a chat
   * message busts both the conversations list and that conversation row).
   * In-flight requests are deduped via the same map so two near-simultaneous
   * callers share a single fetch.
   */
  const _cache = new Map();
  const _now = () => Date.now();

  function _cacheGet(key) {
    const hit = _cache.get(key);
    if (!hit) return null;
    if (hit.promise) return { pending: hit.promise };
    if (hit.expiresAt > _now()) return { value: hit.value };
    _cache.delete(key);
    return null;
  }
  function _cacheSet(key, value, ttlMs, tags) {
    _cache.set(key, { value, expiresAt: _now() + ttlMs, tags: tags || [] });
  }
  function _cachePending(key, promise, tags) {
    _cache.set(key, { promise, tags: tags || [] });
  }
  function invalidate(...tags) {
    if (tags.length === 0) { _cache.clear(); return; }
    const wanted = new Set(tags);
    for (const [k, v] of _cache) {
      if (v.tags && v.tags.some(t => wanted.has(t))) _cache.delete(k);
    }
  }
  // Wrap a GET so repeat callers within `ttlMs` reuse the cached value, and
  // concurrent callers share the in-flight promise. `tags` link this entry
  // to invalidation triggers fired by mutations.
  async function cachedGet(key, ttlMs, tags, fetcher) {
    const hit = _cacheGet(key);
    if (hit?.value !== undefined) return hit.value;
    if (hit?.pending) return hit.pending;
    const promise = (async () => {
      try {
        const value = await fetcher();
        _cacheSet(key, value, ttlMs, tags);
        return value;
      } catch (err) {
        _cache.delete(key);
        throw err;
      }
    })();
    _cachePending(key, promise, tags);
    return promise;
  }

  /* ── Endpoints ───────────────────────────────────────────────────────── */
  // /analyses/{id} only exists for live-pipeline polling during a chat run;
  // user-visible CRUD lives under /conversations.
  // Polling endpoints are intentionally uncached — callers expect fresh state.
  const getAnalysis     = (id)         => apiGet(`/analyses/${id}`);
  const getReview       = (id)         => apiPost(`/analyses/${id}/review`, { analysisId: id });
  // Diagram row contains presigned URLs (900s TTL); cache 5m, well under expiry.
  const getDiagram      = (id)         => cachedGet(
    `GET /analyses/${id}/diagram`, 5 * 60_000, [`diagram:${id}`],
    () => apiGet(`/analyses/${id}/diagram`),
  );
  const exportPdf       = (id)         => apiGet(`/analyses/${id}/export`);
  // Diagram feedback — write-only, no caching. Mutates DDB but doesn't
  // affect any cached read so no invalidation needed.
  const submitDiagramFeedback = (id, rating, comment) =>
    apiPost(`/analyses/${id}/diagram/feedback`, { rating, comment });

  // Polling helpers wrap the async pipeline (parser → diagram → review).
  async function waitForParse(id, { interval = 2000, max = 60 } = {}) {
    for (let i = 0; i < max; i++) {
      await new Promise(r => setTimeout(r, interval));
      let a;
      try {
        a = await getAnalysis(id);
      } catch (e) {
        // Parser worker hasn't written the row yet — keep polling.
        if (e?.status === 404) continue;
        throw e;
      }
      if (a.status === 'parsed' || a.status === 'complete') return a;
      if (a.status === 'failed') throw new Error('Parse failed');
    }
    throw new Error('Parse timed out');
  }
  async function waitForDiagram(id, { interval = 2000, max = 60 } = {}) {
    for (let i = 0; i < max; i++) {
      await new Promise(r => setTimeout(r, interval));
      let d;
      try {
        // Bypass the 5-minute getDiagram cache — polling needs to see the
        // status flip from `processing` → `completed`, but the first
        // cached `processing` response would otherwise stick for 5 minutes
        // and never refresh, leaving the diagram stuck on "rendering…".
        d = await apiGet(`/analyses/${id}/diagram`);
      } catch (e) {
        // Diagram worker hasn't written the row yet — keep polling.
        if (e?.status === 404) continue;
        throw e;
      }
      if (d.status === 'completed') {
        // Seed the shared cache so the next non-polling caller (e.g. the
        // detail screen reload) gets a hit without re-fetching.
        invalidate(`diagram:${id}`);
        return d;
      }
      if (d.status === 'failed') throw new Error(d.reason || 'Diagram failed');
    }
    throw new Error('Diagram timed out');
  }

  /* ── Billing (Stripe) ─────────────────────────────────────────────────── */
  // Both endpoints return `{ url }`. Caller redirects window.location.assign
  // — never opens a new tab; Stripe Checkout/Portal redirects back via
  // success_url / return_url.
  const startCheckout      = async (tier, interval) => {
    const result = await apiPost('/billing/checkout', { tier, interval });
    return result;
  };
  const openBillingPortal  = async ()              => {
    const result = await apiPost('/billing/portal');
    return result;
  };

  /* ── User profile / preferences / subscription ──────────────────────── */
  const getMe              = ()      => cachedGet('GET /user', 5 * 60_000, ['user'], () => apiGet('/user'));
  const getPreferences     = ()      => cachedGet('GET /user/preferences', 5 * 60_000, ['preferences'], () => apiGet('/user/preferences'));
  const savePreferences    = async (prefs) => {
    const result = await request('/user/preferences', {
      method: 'PUT',
      body: JSON.stringify({ preferences: prefs }),
    });
    invalidate('preferences');
    return result;
  };

  /* ── Conversations ───────────────────────────────────────────────────── */
  // Conversation versions are immutable per (id, v); cache 5m.
  // Conversation row + list mutate on chat/create/delete; cache 30s for the list,
  // 60s for a single row — short enough to feel live, long enough to dedupe re-mounts.
  const listConversations      = (limit = 50)   => cachedGet(
    `GET /conversations?limit=${limit}`, 30_000, ['conversations'],
    () => apiGet(`/conversations?limit=${limit}`),
  );
  const createConversation     = async (title) => {
    const result = await apiPost('/conversations', title ? { title } : {});
    invalidate('conversations');
    return result;
  };
  const getConversation        = (id)           => cachedGet(
    `GET /conversations/${id}`, 60_000, ['conversations', `conversation:${id}`],
    () => apiGet(`/conversations/${id}`),
  );
  const getConversationVersion = (id, v)        => cachedGet(
    `GET /conversations/${id}/versions/${v}`, 5 * 60_000, [`conversation:${id}`],
    () => apiGet(`/conversations/${id}/versions/${v}`),
  );
  const sendChatMessage        = async (id, content) => {
    const result = await apiPost(`/conversations/${id}/messages`, { content });
    invalidate('conversations', `conversation:${id}`);
    return result;
  };
  const deleteConversation     = async (id) => {
    const result = await apiDel(`/conversations/${id}`);
    invalidate('conversations', `conversation:${id}`);
    return result;
  };

  /* ── Admin (tier === 'admin' only — handler enforces) ──────────────────── */
  const adminListUsers    = ()                  => apiGet('/admin/users');
  const adminSetTier      = async (id, tier)    => {
    const result = await request(`/admin/users/${id}/tier`, {
      method: 'PUT', body: JSON.stringify({ tier }),
    });
    invalidate('user', 'admin-users');
    return result;
  };
  const adminDisableUser  = (id, username)      => apiPost(
    `/admin/users/${id}/disable?username=${encodeURIComponent(username)}`,
  );
  const adminDeleteUser   = async (id, username) => {
    const result = await request(
      `/admin/users/${id}?username=${encodeURIComponent(username)}`,
      { method: 'DELETE' },
    );
    invalidate('admin-users', 'admin-analyses', 'admin-usage');
    return result;
  };
  const adminListAnalyses = (limit = 50)        => apiGet(`/admin/analyses?limit=${limit}`);
  const adminGetUsage     = (month)             => apiGet(`/admin/usage${month ? `?month=${month}` : ''}`);
  const adminGetHealth    = ()                  => apiGet('/admin/health');
  const adminGetDiagramQuality = ()             => apiGet('/admin/diagram-quality');
  const adminSaveDiagramQualityBaseline = (note = '') =>
    apiPost('/admin/diagram-quality/baseline', { note });
  const adminGetDiagramQualityCorpusEntry = (hash) =>
    apiGet(`/admin/diagram-quality/corpus/${encodeURIComponent(hash)}`);

  /* ── Identity helpers ────────────────────────────────────────────────── */
  function decodeJwt(token) {
    try {
      const payload = token.split('.')[1];
      const b64 = payload.replace(/-/g, '+').replace(/_/g, '/');
      const json = decodeURIComponent(
        atob(b64).split('').map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('')
      );
      return JSON.parse(json);
    } catch { return null; }
  }

  function titleCase(s) {
    return s.replace(/[._-]+/g, ' ').replace(/\d+/g, '').trim()
      .split(/\s+/).filter(Boolean)
      .map(w => w[0].toUpperCase() + w.slice(1).toLowerCase())
      .join(' ');
  }

  // Returns { email, name, firstName, initials } or null if not signed in.
  function currentUser() {
    const t = loadTokens();
    if (!t?.idToken) return null;
    const c = decodeJwt(t.idToken) || {};
    const email = c.email || c['cognito:username'] || '';
    const local = email.split('@')[0] || '';
    const given = c.given_name || '';
    const family = c.family_name || '';
    const fullFromClaims = (c.name && c.name.trim())
      || [given, family].filter(Boolean).join(' ').trim();
    const name = fullFromClaims || titleCase(local) || email;
    const firstName = given || (fullFromClaims ? fullFromClaims.split(/\s+/)[0] : '') || titleCase(local).split(' ')[0] || '';
    const parts = name.split(/\s+/).filter(Boolean);
    const initials = (parts.length >= 2
      ? parts[0][0] + parts[parts.length - 1][0]
      : (name[0] || email[0] || '?').toUpperCase() + (name[1] || '').toUpperCase()
    ).toUpperCase().slice(0, 2);
    return { email, name, firstName, initials };
  }

  // Wipe the response cache on sign-out so the next session never sees the
  // previous user's conversation list.
  function signOutAndWipe() { invalidate(); signOut(); }

  /* ── useResource hook ───────────────────────────────────────────────────
   * Opt-in SWR-style hook for screens that want declarative fetching on top
   * of the cached API. `fetcher` returns a Promise (typically just calls one
   * of the cached API methods above) — the cache lives inside those methods,
   * so this hook is just React-state plumbing. `invalidateTags` are passed
   * to invalidate() on refresh(); they must match the tags the cached API
   * method registered (e.g. 'conversations', 'user', 'preferences', or
   * `conversation:${id}` / `diagram:${id}`). Existing imperative call sites
   * keep working unchanged — they get cache hits transparently.
   */
  function useResource(fetcher, deps, invalidateTags) {
    const React = window.React;
    const depsArr = deps || [];
    const tags = invalidateTags || [];
    const [state, setState] = React.useState({ data: null, error: null, loading: true });
    const tick = React.useRef(0);

    const run = React.useCallback(() => {
      const myTick = ++tick.current;
      setState(s => ({ ...s, loading: true, error: null }));
      Promise.resolve()
        .then(fetcher)
        .then(data => { if (tick.current === myTick) setState({ data, error: null, loading: false }); })
        .catch(err => { if (tick.current === myTick) setState({ data: null, error: err, loading: false }); });
    }, depsArr); // eslint-disable-line react-hooks/exhaustive-deps

    React.useEffect(() => { run(); return () => { tick.current++; }; }, [run]);

    const refresh = React.useCallback(() => {
      if (tags.length) invalidate(...tags);
      run();
    }, [run]); // eslint-disable-line react-hooks/exhaustive-deps
    return { ...state, refresh };
  }

  window.CAC_CONFIG = { ...CFG, hasBackend: HAS_BACKEND };
  window.API = {
    hasBackend: HAS_BACKEND,
    billingEnabled: CFG.billingEnabled,
    isAuthed: () => Boolean(loadTokens()?.idToken),
    tokens: loadTokens,
    currentUser,
    signIn, signUp, signOut: signOutAndWipe, sendPasswordReset, refresh, updateProfile,
    getMe, getPreferences, savePreferences,
    startCheckout, openBillingPortal,
    getAnalysis, waitForParse, waitForDiagram,
    getReview, getDiagram, exportPdf, submitDiagramFeedback,
    listConversations, createConversation, getConversation, getConversationVersion,
    sendChatMessage, deleteConversation,
    admin: {
      listUsers: adminListUsers,
      setTier: adminSetTier,
      disableUser: adminDisableUser,
      deleteUser: adminDeleteUser,
      listAnalyses: adminListAnalyses,
      getUsage: adminGetUsage,
      getHealth: adminGetHealth,
      getDiagramQuality: adminGetDiagramQuality,
      saveDiagramQualityBaseline: adminSaveDiagramQualityBaseline,
      getDiagramQualityCorpusEntry: adminGetDiagramQualityCorpusEntry,
    },
    invalidate, useResource,
  };
})();
