/**
 * Account & API-key management routes on the local CLI server.
 *
 * These are thin proxies over the autopilot-backend's `/auth/*` and
 * `/account/*` endpoints. The local server holds the user's JWT in
 * `lastAuthResult.jwt` (encrypted on disk via authCacheStore) and
 * forwards it as a `Authorization: Bearer` to the backend. The web-ui
 * never sees the JWT after the initial OAuth landing — keeping it on
 * the local-server side means:
 *
 *   1. No CORS dance between the browser and Azure backend.
 *   2. The web-ui can talk to one origin (its own).
 *   3. A compromised renderer cannot exfiltrate the bearer in plaintext
 *      (it lives in localStorage, but everything that NEEDS it goes
 *      through the local server).
 *   4. Symmetric with the existing marketplaceApi pattern.
 *
 * Endpoints (mounted by webServer.js at boot):
 *   GET    /api/account/profile         — proxies /auth/me
 *   GET    /api/account/balance         — proxies /account/balance (ledger)
 *   GET    /api/account/keys            — proxies /auth/apikeys
 *   POST   /api/account/keys            — proxies /auth/apikey  (one-time-reveal body)
 *   DELETE /api/account/keys/:id        — proxies /auth/apikey/:id
 *   POST   /api/account/handoff         — mints + returns a portal handoff URL
 *
 * Auth: each route requires `lastAuthResult.jwt` to be present. When
 * the user hasn't OAuth'd yet, we return 401 with a clear `code` the
 * FE can branch on ('not_signed_in') so it can prompt for sign-in
 * rather than show a generic error.
 *
 * Security guarantees:
 *   - The local server never returns the JWT to the renderer.
 *   - The API-key create endpoint returns the `lx_*` value exactly
 *     once (mirroring the backend); subsequent list calls show only
 *     metadata + a masked key.
 *   - The handoff URL embeds a single-use short-lived token, NOT the
 *     long-lived JWT.
 *   - All errors are normalised to `{ success: false, error, code? }`
 *     so the FE never has to special-case axios vs fetch envelopes.
 *
 * Testable design:
 *   `createAccountRouter()` accepts every dependency it needs (fetch
 *   impl, backend URL, JWT getter, logger). Production builds inject
 *   the real ones; tests inject stubs. See accountRoutes.test.js.
 */

import express from 'express';

/**
 * Default backend URL — overridable via the same `config.backend.baseUrl`
 * the existing /auth routes use, so a single env-config change moves
 * every backend call to staging or local-dev.
 */
const DEFAULT_BACKEND_URL = 'https://autopilot-api.azurewebsites.net';

/**
 * Portal URL per brand. The handoff endpoint uses this to construct
 * the deep-link the FE opens in the system browser. Two reasons we
 * pick the brand at construction time, not request time:
 *   - The local server already knows its brand (loaded at boot).
 *   - The renderer should never get to influence which portal we send
 *     the user to (mitigation against open-redirect via a tampered
 *     request body).
 */
const PORTAL_URLS_BY_BRAND = Object.freeze({
  autopilot: 'https://autopilot.loxia.ai',
  onbuzz:    'https://onbuzz.loxia.ai',
});

/**
 * Codes the FE branches on. Keep this list in lockstep with web-ui's
 * account API client. New codes must be added to the FE's switch.
 */
export const ERROR_CODES = Object.freeze({
  NOT_SIGNED_IN:    'not_signed_in',
  BACKEND_ERROR:    'backend_error',
  NETWORK_ERROR:    'network_error',
  BAD_REQUEST:      'bad_request',
  NOT_FOUND:        'not_found',
});

/**
 * Build the account router. Pure DI — every external concern is a
 * function the caller passes in.
 *
 * @param {Object} deps
 * @param {() => string|null} deps.getJwt
 *   Returns the user's current JWT or null when unauthenticated.
 *   Caller hooks this to `() => webServer.lastAuthResult?.jwt`.
 * @param {() => string} deps.getBrand
 *   Returns 'autopilot' | 'onbuzz'. Used to pick portal URL.
 * @param {string} [deps.backendBaseUrl]
 * @param {typeof fetch} [deps.fetchImpl]
 * @param {Object} [deps.logger]
 */
export function createAccountRouter({
  getJwt,
  getBrand,
  backendBaseUrl = DEFAULT_BACKEND_URL,
  fetchImpl = fetch,
  logger = console,
} = {}) {
  if (typeof getJwt !== 'function') throw new Error('createAccountRouter: getJwt is required');
  if (typeof getBrand !== 'function') throw new Error('createAccountRouter: getBrand is required');

  const router = express.Router();
  const baseUrl = backendBaseUrl.replace(/\/$/, '');

  // Last-known-good balance cache, per JWT (so a fast user-switch
  // doesn't show the previous user's balance). Stays in memory only —
  // a server restart wipes it, by design: stale-on-disk balance would
  // be more dangerous than no balance.
  /** @type {Map<string, { balance: number, plan: string|null, savedAt: string }>} */
  const balanceCache = new Map();
  function cacheKey(jwt) {
    if (!jwt) return null;
    // Hash the JWT to keep memory footprint sane (JWTs are ~240 chars
    // each). The hash is a fingerprint, not used as auth.
    let h = 0; for (let i = 0; i < jwt.length; i++) { h = ((h << 5) - h + jwt.charCodeAt(i)) | 0; }
    return String(h);
  }

  /**
   * Coerce whatever the upstream put under `body.error` into a
   * human-readable string. Different upstream services use different
   * envelope shapes — string, `{message,code}`, or even nested objects.
   * Always returns a string so the FE's `title="..."` tooltip never
   * shows `[object Object]`.
   */
  function extractErrorMessage(body) {
    if (!body) return '';
    const err = body.error;
    if (typeof err === 'string') return err;
    if (err && typeof err === 'object') {
      if (typeof err.message === 'string') return err.message;
      if (typeof err.code === 'string')    return err.code;
      try { return JSON.stringify(err); } catch { return ''; }
    }
    if (typeof body.message === 'string') return body.message;
    return '';
  }

  /**
   * Forward a request to the backend with the user's JWT. Returns a
   * { status, body } tuple the route handlers can hand straight back
   * to Express, normalising every transport failure into the same
   * envelope.
   */
  async function proxyToBackend(method, path, body) {
    const jwt = getJwt();
    if (!jwt) {
      return {
        status: 401,
        body: { success: false, error: 'Sign in to manage your account', code: ERROR_CODES.NOT_SIGNED_IN },
      };
    }
    const url = `${baseUrl}${path}`;
    try {
      const t0 = Date.now();
      const res = await fetchImpl(url, {
        method,
        headers: {
          'Authorization': `Bearer ${jwt}`,
          'Content-Type': 'application/json',
          'Connection': 'close',
        },
        body: body !== undefined ? JSON.stringify(body) : undefined,
      });

      const text = await res.text();
      let parsed;
      try { parsed = text ? JSON.parse(text) : {}; } catch { parsed = { raw: text }; }

      // Per-request debug log so a flaky upstream is diagnosable
      // without reproducing in a separate process. Worth the noise
      // because the account proxy currently has an unsolved
      // discrepancy: standalone Node script gets 200s, the in-process
      // fetch gets 5xxs against the same URL with the same JWT.
      logger?.info?.('[accountRoutes] upstream', {
        url,
        method,
        status: res.status,
        ms: Date.now() - t0,
        bodyPreview: typeof text === 'string' ? text.slice(0, 200) : null,
        contentType: res.headers?.get?.('content-type') || null,
      });

      if (!res.ok) {
        return {
          status: res.status,
          body: {
            success: false,
            error: extractErrorMessage(parsed) || `Backend returned ${res.status}`,
            code: res.status === 404 ? ERROR_CODES.NOT_FOUND : ERROR_CODES.BACKEND_ERROR,
          },
        };
      }
      return { status: res.status, body: parsed };
    } catch (err) {
      logger?.warn?.('[accountRoutes] backend fetch failed', { method, path, error: err.message });
      return {
        status: 503,
        body: {
          success: false,
          error: 'Could not reach the account service. Try again in a moment.',
          code: ERROR_CODES.NETWORK_ERROR,
        },
      };
    }
  }

  // ── Debug ──────────────────────────────────────────────────────────
  // Temporary diagnostic route. Returns the raw responses from each
  // upstream call the cascade makes so we can see in the browser why
  // /balance falls through to degraded when standalone fetch from the
  // same machine works fine. Remove when the balance issue is closed.
  router.get('/_debug', async (_req, res) => {
    const jwt = getJwt();
    if (!jwt) return res.status(401).json({ error: 'not signed in' });
    const probe = async (path) => {
      const url = `${baseUrl}${path}`;
      const t0 = Date.now();
      try {
        const r = await fetchImpl(url, {
          method: 'GET',
          headers: {
            'Authorization': `Bearer ${jwt}`,
            'Content-Type': 'application/json',
            'Connection': 'close',
          },
        });
        const text = await r.text();
        return {
          path, url, status: r.status, ms: Date.now() - t0,
          contentType: r.headers?.get?.('content-type') || null,
          bodyPreview: text.slice(0, 400),
        };
      } catch (err) {
        return { path, url, ms: Date.now() - t0, error: err.message, errCode: err.code, errName: err.name };
      }
    };
    res.json({
      baseUrl,
      jwtLen: jwt.length,
      probes: await Promise.all([
        probe('/subscription/status'),
        probe('/account/profile'),
        probe('/auth/me'),
      ]),
    });
  });

  // ── Profile ────────────────────────────────────────────────────────
  router.get('/profile', async (_req, res) => {
    const r = await proxyToBackend('GET', '/auth/me');
    // /auth/me returns either `{ user: {...} }` or `{...userFields}`.
    // Normalise so the FE always sees `{ user: {...} }`.
    if (r.status === 200) {
      const u = r.body?.user || r.body;
      return res.json({ success: true, user: u });
    }
    res.status(r.status).json(r.body);
  });

  // ── Balance ────────────────────────────────────────────────────────
  // Reads `{ plan, balance, ... }` from the backend.
  //
  // Endpoint cascade — first one that returns 2xx wins:
  //   1. /subscription/status — returns { plan, balance, monthlyAllocation,
  //      subscriptionStatus, currentPeriodEnd }. Reads from User.creditBalance
  //      (cached / DB-direct) so it works even when the ledger is down.
  //      This is the PRIMARY because of that resilience.
  //   2. /account/profile — bundles plan + balance from the ledger.
  //      Richer data, but 500s if LEDGER_SERVICE_URL is missing.
  //   3. /auth/me — no balance, but at least we can return plan + null.
  //
  // The FE never sees an error here — degraded data + a `degraded: true`
  // flag is the right UX, not an error toast.
  router.get('/balance', async (_req, res) => {
    // Balance is a per-user query — needs a JWT just like every other
    // /api/account/* route. Early-exit with the same not_signed_in
    // envelope so the FE branches the same way.
    const jwt = getJwt();
    if (!jwt) {
      return res.status(401).json({
        success: false,
        error: 'Sign in to manage your account',
        code: ERROR_CODES.NOT_SIGNED_IN,
      });
    }
    const ck = cacheKey(jwt);

    const subscription = await proxyToBackend('GET', '/subscription/status');
    if (subscription.status >= 200 && subscription.status < 300) {
      const body = subscription.body || {};
      if (typeof body.balance === 'number') {
        balanceCache.set(ck, { balance: body.balance, plan: body.plan || null, savedAt: new Date().toISOString() });
      }
      return res.status(subscription.status).json(body);
    }
    const profile = await proxyToBackend('GET', '/account/profile');
    if (profile.status >= 200 && profile.status < 300) {
      const body = profile.body || {};
      if (typeof body.balance === 'number') {
        balanceCache.set(ck, { balance: body.balance, plan: body.plan || body.accountType || null, savedAt: new Date().toISOString() });
      }
      return res.status(profile.status).json(body);
    }
    // Both balance-bearing endpoints failed. Hand back the last-known
    // good value (if any) with a `stale: true` flag so the FE shows a
    // number instead of "— CP". The user still gets a visual cue that
    // it might not be current.
    const cached = balanceCache.get(ck);
    const me = await proxyToBackend('GET', '/auth/me');
    const u = me.status >= 200 && me.status < 300 ? (me.body?.user || me.body || {}) : {};

    // The internal ECONNREFUSED string is useful for debugging but
    // not for users — log it once at warn, surface a friendly reason.
    const technical = extractErrorMessage(subscription.body) || extractErrorMessage(profile.body) || '';
    if (technical) logger?.warn?.('[accountRoutes] balance degraded', { technical });

    res.json({
      balance: cached ? cached.balance : (typeof u.creditBalance === 'number' ? u.creditBalance : null),
      plan:    cached?.plan || u.accountType || null,
      degraded: true,
      stale:   !!cached,
      lastUpdatedAt: cached?.savedAt || null,
      reason:  'Balance service is temporarily unavailable',
    });
  });

  // ── API keys list ──────────────────────────────────────────────────
  router.get('/keys', async (_req, res) => {
    const r = await proxyToBackend('GET', '/auth/apikeys');
    res.status(r.status).json(r.body);
  });

  // ── API key create ────────────────────────────────────────────────
  router.post('/keys', express.json(), async (req, res) => {
    const { name, expiresIn } = req.body || {};
    if (!name || typeof name !== 'string' || !name.trim()) {
      return res.status(400).json({
        success: false,
        error: 'Provide a name to identify the key',
        code: ERROR_CODES.BAD_REQUEST,
      });
    }
    const payload = { name: name.trim() };
    if (expiresIn != null) payload.expiresIn = expiresIn;
    const r = await proxyToBackend('POST', '/auth/apikey', payload);
    res.status(r.status).json(r.body);
  });

  // ── API key revoke ────────────────────────────────────────────────
  router.delete('/keys/:id', async (req, res) => {
    const id = req.params.id;
    if (!id || !/^[a-zA-Z0-9_-]+$/.test(id)) {
      return res.status(400).json({
        success: false,
        error: 'Invalid key id',
        code: ERROR_CODES.BAD_REQUEST,
      });
    }
    const r = await proxyToBackend('DELETE', `/auth/apikey/${encodeURIComponent(id)}`);
    res.status(r.status).json(r.body);
  });

  // ── Portal handoff (Buy Credits) ──────────────────────────────────
  router.post('/handoff', async (req, res) => {
    const { destination } = req.body || {};
    // Closed allowlist — never let the renderer drive arbitrary portal
    // paths. New destinations must be added here intentionally.
    const ALLOWED_DESTINATIONS = ['payment', 'subscription', 'api-keys', 'account'];
    const path = destination && ALLOWED_DESTINATIONS.includes(destination) ? destination : 'payment';

    const r = await proxyToBackend('POST', '/auth/handoff-token', { purpose: `portal-${path}` });
    if (r.status !== 200 && r.status !== 201) {
      return res.status(r.status).json(r.body);
    }
    const handoff = r.body?.handoff || r.body?.token;
    if (!handoff) {
      return res.status(502).json({
        success: false,
        error: 'Backend did not return a handoff token',
        code: ERROR_CODES.BACKEND_ERROR,
      });
    }
    const brand = getBrand();
    const portalBase = PORTAL_URLS_BY_BRAND[brand] || PORTAL_URLS_BY_BRAND.autopilot;
    const url = `${portalBase}/${path}?handoff=${encodeURIComponent(handoff)}`;
    res.json({ success: true, url });
  });

  return router;
}

// Exported so tests + future routes can re-use it.
export const __test__ = { PORTAL_URLS_BY_BRAND, DEFAULT_BACKEND_URL };
