/**
 * Tests for the account-proxy router on the local CLI server.
 *
 * These tests cover three categories of behavior:
 *
 *   1. Auth gating — every route MUST return 401 + `not_signed_in`
 *      when there's no JWT, never silently fall through to the
 *      backend with no auth.
 *
 *   2. JWT forwarding + envelope normalisation — when a JWT is
 *      present, the backend is called with a Bearer header, and
 *      every response (200, 404, 503, JSON-shape variations) lands
 *      in our standard `{ success, error?, code? }` envelope.
 *
 *   3. Handoff URL building — the FE never gets to pick which
 *      portal we redirect to, and the handoff is only ever the
 *      short-lived token, never the long-lived JWT.
 *
 * The router is constructed with injected `getJwt`, `getBrand`, and
 * `fetchImpl` — no real HTTP, no real `lastAuthResult` plumbing.
 */

import { describe, test, expect, jest } from '@jest/globals';
import express from 'express';
import { createServer } from 'http';
import { createAccountRouter, ERROR_CODES, __test__ } from '../accountRoutes.js';

const SAMPLE_JWT = 'eyJ.sample.jwt';
const BACKEND_URL = 'https://backend.test';

async function startApp(handler) {
  const app = express();
  app.use(express.json());
  handler(app);
  const server = createServer(app);
  await new Promise(r => server.listen(0, r));
  return { server, baseUrl: `http://127.0.0.1:${server.address().port}` };
}

async function stop(server) { if (server) await new Promise(r => server.close(r)); }

function mountRouter({ jwt = SAMPLE_JWT, brand = 'autopilot', fetchImpl, logger = { warn: () => {} } } = {}) {
  const router = createAccountRouter({
    getJwt: () => jwt,
    getBrand: () => brand,
    backendBaseUrl: BACKEND_URL,
    fetchImpl,
    logger,
  });
  return async () => {
    return startApp(app => app.use('/api/account', router));
  };
}

// ────────────────────────────────────────────────────────────────────
// Constructor guards
// ────────────────────────────────────────────────────────────────────
describe('createAccountRouter — constructor', () => {
  test('throws when getJwt is missing', () => {
    expect(() => createAccountRouter({ getBrand: () => 'autopilot' })).toThrow(/getJwt/);
  });
  test('throws when getBrand is missing', () => {
    expect(() => createAccountRouter({ getJwt: () => null })).toThrow(/getBrand/);
  });
});

// ────────────────────────────────────────────────────────────────────
// Auth gating
// ────────────────────────────────────────────────────────────────────
describe('account routes — auth gating', () => {
  test.each([
    ['GET',    '/api/account/profile', undefined],
    ['GET',    '/api/account/balance', undefined],
    ['GET',    '/api/account/keys',    undefined],
    ['POST',   '/api/account/keys',    { name: 'CI key' }],
    ['DELETE', '/api/account/keys/some-id', undefined],
    ['POST',   '/api/account/handoff', {}],
  ])('%s %s returns 401 not_signed_in when JWT is absent', async (method, path, body) => {
    const startServer = mountRouter({ jwt: null, fetchImpl: jest.fn() });
    const { server, baseUrl } = await startServer();
    try {
      const r = await fetch(`${baseUrl}${path}`, {
        method,
        headers: { 'Content-Type': 'application/json' },
        body: body !== undefined ? JSON.stringify(body) : undefined,
      });
      expect(r.status).toBe(401);
      const json = await r.json();
      expect(json.success).toBe(false);
      expect(json.code).toBe(ERROR_CODES.NOT_SIGNED_IN);
    } finally {
      await stop(server);
    }
  });

  test('backend is never called when JWT is absent', async () => {
    const fetchImpl = jest.fn();
    const startServer = mountRouter({ jwt: null, fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      await fetch(`${baseUrl}/api/account/profile`);
      expect(fetchImpl).not.toHaveBeenCalled();
    } finally {
      await stop(server);
    }
  });
});

// ────────────────────────────────────────────────────────────────────
// Profile + balance + keys list
// ────────────────────────────────────────────────────────────────────
describe('GET /api/account/profile', () => {
  test('forwards JWT and returns normalized { user } even when backend returns flat user object', async () => {
    const fetchImpl = jest.fn().mockResolvedValue({
      ok: true, status: 200,
      text: async () => JSON.stringify({ id: 'u-1', email: 'a@b.com' }),
    });
    const startServer = mountRouter({ fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      const r = await fetch(`${baseUrl}/api/account/profile`);
      expect(r.status).toBe(200);
      const json = await r.json();
      expect(json).toEqual({ success: true, user: { id: 'u-1', email: 'a@b.com' } });
      expect(fetchImpl).toHaveBeenCalledTimes(1);
      const [url, opts] = fetchImpl.mock.calls[0];
      expect(url).toBe(`${BACKEND_URL}/auth/me`);
      expect(opts.headers.Authorization).toBe(`Bearer ${SAMPLE_JWT}`);
    } finally {
      await stop(server);
    }
  });

  test('passes nested { user } through unchanged', async () => {
    const fetchImpl = jest.fn().mockResolvedValue({
      ok: true, status: 200,
      text: async () => JSON.stringify({ user: { id: 'u-2', email: 'c@d.com' } }),
    });
    const startServer = mountRouter({ fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      const json = await (await fetch(`${baseUrl}/api/account/profile`)).json();
      expect(json.user).toEqual({ id: 'u-2', email: 'c@d.com' });
    } finally {
      await stop(server);
    }
  });
});

describe('GET /api/account/balance', () => {
  test('primary path: hits /subscription/status and returns body verbatim', async () => {
    const fetchImpl = jest.fn().mockResolvedValue({
      ok: true, status: 200,
      text: async () => JSON.stringify({ balance: 12.34, plan: 'pro' }),
    });
    const startServer = mountRouter({ fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      const r = await fetch(`${baseUrl}/api/account/balance`);
      const json = await r.json();
      expect(json).toEqual({ balance: 12.34, plan: 'pro' });
      expect(fetchImpl.mock.calls[0][0]).toBe(`${BACKEND_URL}/subscription/status`);
      // Cascade short-circuits on first success — fallback endpoints
      // are not contacted.
      expect(fetchImpl).toHaveBeenCalledTimes(1);
    } finally {
      await stop(server);
    }
  });

  test('cascade: falls back to /account/profile when /subscription/status fails', async () => {
    const fetchImpl = jest.fn()
      .mockResolvedValueOnce({ ok: false, status: 503, text: async () => JSON.stringify({ error: 'subs down' }) })
      .mockResolvedValueOnce({ ok: true,  status: 200, text: async () => JSON.stringify({ balance: 7.7, plan: 'free' }) });
    const startServer = mountRouter({ fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      const r = await fetch(`${baseUrl}/api/account/balance`);
      const json = await r.json();
      expect(json).toEqual({ balance: 7.7, plan: 'free' });
      expect(fetchImpl.mock.calls[1][0]).toBe(`${BACKEND_URL}/account/profile`);
    } finally {
      await stop(server);
    }
  });

  test('cascade: final fallback uses /auth/me when both fail; returns degraded:true', async () => {
    const fetchImpl = jest.fn()
      .mockResolvedValueOnce({ ok: false, status: 503, text: async () => JSON.stringify({ error: 'subs down' }) })
      .mockResolvedValueOnce({ ok: false, status: 500, text: async () => JSON.stringify({ error: { message: 'ledger down', code: 'ECONNREFUSED' } }) })
      .mockResolvedValueOnce({ ok: true,  status: 200, text: async () => JSON.stringify({ user: { creditBalance: 5, accountType: 'free' } }) });
    const startServer = mountRouter({ fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      const r = await fetch(`${baseUrl}/api/account/balance`);
      const json = await r.json();
      expect(json.balance).toBe(5);
      expect(json.plan).toBe('free');
      expect(json.degraded).toBe(true);
      // The reason field MUST be a string — not an object — so
      // FE `title="..."` tooltips don't render "[object Object]".
      expect(typeof json.reason).toBe('string');
      expect(json.reason.length).toBeGreaterThan(0);
    } finally {
      await stop(server);
    }
  });

  test('cascade: every upstream fails → returns degraded with null balance, never 5xx', async () => {
    const fetchImpl = jest.fn().mockResolvedValue({
      ok: false, status: 500, text: async () => JSON.stringify({ error: 'everything is broken' }),
    });
    const startServer = mountRouter({ fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      const r = await fetch(`${baseUrl}/api/account/balance`);
      expect(r.status).toBe(200);  // ← graceful degrade, NOT 500
      const json = await r.json();
      expect(json.balance).toBeNull();
      expect(json.degraded).toBe(true);
    } finally {
      await stop(server);
    }
  });
});

describe('GET /api/account/keys', () => {
  test('returns the backend response verbatim', async () => {
    const fetchImpl = jest.fn().mockResolvedValue({
      ok: true, status: 200,
      text: async () => JSON.stringify({ apiKeys: [{ id: 'k1', name: 'CI', status: 'active' }] }),
    });
    const startServer = mountRouter({ fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      const r = await fetch(`${baseUrl}/api/account/keys`);
      const json = await r.json();
      expect(json.apiKeys).toHaveLength(1);
      expect(fetchImpl.mock.calls[0][0]).toBe(`${BACKEND_URL}/auth/apikeys`);
    } finally {
      await stop(server);
    }
  });
});

// ────────────────────────────────────────────────────────────────────
// Key create — input validation + one-time-reveal pass-through
// ────────────────────────────────────────────────────────────────────
describe('POST /api/account/keys', () => {
  test('400 + bad_request when name is missing', async () => {
    const fetchImpl = jest.fn();
    const startServer = mountRouter({ fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      const r = await fetch(`${baseUrl}/api/account/keys`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({}),
      });
      expect(r.status).toBe(400);
      const json = await r.json();
      expect(json.code).toBe(ERROR_CODES.BAD_REQUEST);
      expect(fetchImpl).not.toHaveBeenCalled();
    } finally {
      await stop(server);
    }
  });

  test('400 + bad_request when name is whitespace-only', async () => {
    const startServer = mountRouter({ fetchImpl: jest.fn() });
    const { server, baseUrl } = await startServer();
    try {
      const r = await fetch(`${baseUrl}/api/account/keys`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: '   ' }),
      });
      expect(r.status).toBe(400);
    } finally {
      await stop(server);
    }
  });

  test('forwards trimmed name + optional expiresIn, returns one-time key', async () => {
    const fetchImpl = jest.fn().mockResolvedValue({
      ok: true, status: 201,
      text: async () => JSON.stringify({
        message: 'API key generated successfully',
        apiKey: { id: 'k-new', name: 'mykey', key: 'lx_secretvalue', createdAt: '2026-05-11', expiresAt: null },
      }),
    });
    const startServer = mountRouter({ fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      const r = await fetch(`${baseUrl}/api/account/keys`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: '  mykey  ', expiresIn: 30 }),
      });
      expect(r.status).toBe(201);
      const json = await r.json();
      expect(json.apiKey.key).toBe('lx_secretvalue');
      const sentBody = JSON.parse(fetchImpl.mock.calls[0][1].body);
      expect(sentBody.name).toBe('mykey');
      expect(sentBody.expiresIn).toBe(30);
    } finally {
      await stop(server);
    }
  });
});

// ────────────────────────────────────────────────────────────────────
// Key revoke
// ────────────────────────────────────────────────────────────────────
describe('DELETE /api/account/keys/:id', () => {
  test('rejects an id with unsafe characters before hitting the backend', async () => {
    const fetchImpl = jest.fn();
    const startServer = mountRouter({ fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      // Express routes won't let `..` through directly, but
      // `<script>` would. Either way, our regex blocks it.
      const r = await fetch(`${baseUrl}/api/account/keys/bad%20id%21`, { method: 'DELETE' });
      expect(r.status).toBe(400);
      expect(fetchImpl).not.toHaveBeenCalled();
    } finally {
      await stop(server);
    }
  });

  test('happy path proxies the revoke', async () => {
    const fetchImpl = jest.fn().mockResolvedValue({
      ok: true, status: 200,
      text: async () => JSON.stringify({ message: 'API key revoked successfully' }),
    });
    const startServer = mountRouter({ fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      const r = await fetch(`${baseUrl}/api/account/keys/abc-123_XYZ`, { method: 'DELETE' });
      expect(r.status).toBe(200);
      const [url, opts] = fetchImpl.mock.calls[0];
      expect(url).toBe(`${BACKEND_URL}/auth/apikey/abc-123_XYZ`);
      expect(opts.method).toBe('DELETE');
    } finally {
      await stop(server);
    }
  });
});

// ────────────────────────────────────────────────────────────────────
// Handoff URL
// ────────────────────────────────────────────────────────────────────
describe('POST /api/account/handoff', () => {
  test('mints token via backend and returns autopilot portal URL with handoff query', async () => {
    const fetchImpl = jest.fn().mockResolvedValue({
      ok: true, status: 200,
      text: async () => JSON.stringify({ handoff: 'handoff-token-xyz' }),
    });
    const startServer = mountRouter({ brand: 'autopilot', fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      const r = await fetch(`${baseUrl}/api/account/handoff`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ destination: 'payment' }),
      });
      expect(r.status).toBe(200);
      const json = await r.json();
      expect(json.url).toBe('https://autopilot.loxia.ai/payment?handoff=handoff-token-xyz');
      const fetchOpts = fetchImpl.mock.calls[0][1];
      expect(fetchOpts.method).toBe('POST');
      expect(JSON.parse(fetchOpts.body).purpose).toBe('portal-payment');
    } finally {
      await stop(server);
    }
  });

  test('switches portal host for OnBuzz brand', async () => {
    const fetchImpl = jest.fn().mockResolvedValue({
      ok: true, status: 200,
      text: async () => JSON.stringify({ handoff: 'tok' }),
    });
    const startServer = mountRouter({ brand: 'onbuzz', fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      const json = await (await fetch(`${baseUrl}/api/account/handoff`, {
        method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
      })).json();
      expect(json.url.startsWith(__test__.PORTAL_URLS_BY_BRAND.onbuzz)).toBe(true);
    } finally {
      await stop(server);
    }
  });

  test('falls back to "payment" when destination is unknown (closed allowlist)', async () => {
    const fetchImpl = jest.fn().mockResolvedValue({
      ok: true, status: 200,
      text: async () => JSON.stringify({ handoff: 'tok' }),
    });
    const startServer = mountRouter({ fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      const json = await (await fetch(`${baseUrl}/api/account/handoff`, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ destination: '../evil' }),
      })).json();
      // Unknown destination falls back to /payment, NOT echoed as /../evil
      expect(json.url).toContain('/payment?handoff=');
      expect(json.url).not.toContain('evil');
    } finally {
      await stop(server);
    }
  });

  test('502 when backend response lacks handoff field', async () => {
    const fetchImpl = jest.fn().mockResolvedValue({
      ok: true, status: 200, text: async () => JSON.stringify({ something_else: 'x' }),
    });
    const startServer = mountRouter({ fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      const r = await fetch(`${baseUrl}/api/account/handoff`, {
        method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
      });
      expect(r.status).toBe(502);
    } finally {
      await stop(server);
    }
  });
});

// ────────────────────────────────────────────────────────────────────
// Backend error translation
// ────────────────────────────────────────────────────────────────────
describe('proxy error envelopes', () => {
  test('non-OK response is converted to { success:false, code:backend_error }', async () => {
    const fetchImpl = jest.fn().mockResolvedValue({
      ok: false, status: 500,
      text: async () => JSON.stringify({ error: 'something broke' }),
    });
    const startServer = mountRouter({ fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      const r = await fetch(`${baseUrl}/api/account/profile`);
      expect(r.status).toBe(500);
      const json = await r.json();
      expect(json).toEqual({
        success: false,
        error: 'something broke',
        code: ERROR_CODES.BACKEND_ERROR,
      });
    } finally {
      await stop(server);
    }
  });

  test('404 maps to code:not_found', async () => {
    const fetchImpl = jest.fn().mockResolvedValue({
      ok: false, status: 404,
      text: async () => JSON.stringify({ error: 'nope' }),
    });
    const startServer = mountRouter({ fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      const r = await fetch(`${baseUrl}/api/account/keys`);
      expect(r.status).toBe(404);
      const json = await r.json();
      expect(json.code).toBe(ERROR_CODES.NOT_FOUND);
    } finally {
      await stop(server);
    }
  });

  test('network failure becomes 503 + code:network_error', async () => {
    const fetchImpl = jest.fn().mockRejectedValue(new Error('ECONNREFUSED'));
    const startServer = mountRouter({ fetchImpl });
    const { server, baseUrl } = await startServer();
    try {
      const r = await fetch(`${baseUrl}/api/account/profile`);
      expect(r.status).toBe(503);
      const json = await r.json();
      expect(json.code).toBe(ERROR_CODES.NETWORK_ERROR);
    } finally {
      await stop(server);
    }
  });
});
