/**
 * Tests for the OAuth login flow routes and the /oauth/authorize redirect behavior.
 *
 * Critical invariants guarded here:
 *  1. GET /login serves an HTML form and validates the return_to URL
 *  2. POST /login sets a first-party session cookie with correct security properties
 *  3. POST /login with bad credentials redirects to /login?error=invalid (no open redirect)
 *  4. GET /oauth/authorize unauthenticated → redirects to /login (NOT app.squaremcp.com)
 *  5. GET /oauth/authorize with valid session → shows consent page
 *
 * Why these matter: claude.ai and ChatGPT use browser-based OAuth popups. If the
 * authorize endpoint redirects to app.squaremcp.com instead of hermes /login, the
 * session cookie is set in the wrong top-level context (browser CHIPS partitioning)
 * and the OAuth flow fails silently.
 */

import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest';

// ── Hoist mocks before any imports ────────────────────────────────────────────

const {
  mockFindCustomerByEmail,
  mockVerifyPassword,
  mockSignJWT,
  mockVerifyJWT,
  mockGetClient,
  mockIsValidRedirectUri,
  mockGetAuthorizeHtml,
  mockInitDatabase,
  mockGetPool,
  mockRedis,
  mockEnsureOAuthAppRegistered,
} = vi.hoisted(() => ({
  mockFindCustomerByEmail: vi.fn(),
  mockVerifyPassword: vi.fn(),
  mockSignJWT: vi.fn(),
  mockVerifyJWT: vi.fn(),
  mockGetClient: vi.fn(),
  mockIsValidRedirectUri: vi.fn(),
  mockGetAuthorizeHtml: vi.fn(),
  mockInitDatabase: vi.fn(),
  mockGetPool: vi.fn(() => ({ execute: vi.fn(), query: vi.fn() })),
  mockRedis: { get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
  mockEnsureOAuthAppRegistered: vi.fn(),
}));

vi.mock('./auth.js', () => ({
  findCustomerByEmail: mockFindCustomerByEmail,
  findCustomerById: vi.fn(),
  verifyPassword: mockVerifyPassword,
  signJWT: mockSignJWT,
  verifyJWT: mockVerifyJWT,
  hashPassword: vi.fn(),
  createCustomer: vi.fn(),
  setResetToken: vi.fn(),
  findCustomerByResetToken: vi.fn(),
  clearResetToken: vi.fn(),
  updatePassword: vi.fn(),
}));

vi.mock('./oauth.js', () => ({
  getClient: mockGetClient,
  isValidRedirectUri: mockIsValidRedirectUri,
  getAuthorizeHtml: mockGetAuthorizeHtml,
  registerClient: vi.fn(),
  createAuthCode: vi.fn(),
  exchangeCodeForToken: vi.fn(),
  validateAccessToken: vi.fn(),
  getTokenCustomer: vi.fn(),
  ensureOAuthAppRegistered: mockEnsureOAuthAppRegistered,
  revokeToken: vi.fn(),
}));

vi.mock('./db.js', () => ({
  initDatabase: mockInitDatabase,
  getPool: mockGetPool,
  isPoolReady: vi.fn(() => true),
}));

vi.mock('./redis.js', () => ({ default: mockRedis }));

vi.mock('./billing/middleware.js', () => ({
  meterMiddleware: vi.fn((_req: unknown, _res: unknown, next: () => void) => next()),
  resolveCustomerByApiKey: vi.fn(),
  resolveCustomerById: vi.fn(),
}));

vi.mock('./billing/usage.js', () => ({
  recordUsage: vi.fn(),
  getMonthlyUsage: vi.fn(() => 0),
  getUsageBreakdown: vi.fn(() => []),
  checkLimit: vi.fn(() => ({ allowed: true, limit: 1000 })),
}));

vi.mock('./billing/invoices.js', () => ({
  getCustomerInvoices: vi.fn(() => []),
  getInvoiceByNumber: vi.fn(),
  markInvoiceSent: vi.fn(),
  markInvoicePaid: vi.fn(),
  generateMonthlyInvoice: vi.fn(),
}));

vi.mock('./billing/cron.js', () => ({ startInvoiceCron: vi.fn() }));

vi.mock('./multitenancy/webhook-router.js', () => ({
  routeWhatsAppWebhook: vi.fn(),
  registerWhatsAppNumber: vi.fn(),
}));

vi.mock('./multitenancy/credential-store.js', () => ({
  storeCredential: vi.fn(),
  getCredential: vi.fn(),
}));

vi.mock('./multitenancy/platform-health.js', () => ({
  getAllPlatformHealth: vi.fn(() => []),
}));

vi.mock('./multitenancy/audit-log.js', () => ({ logAudit: vi.fn() }));

vi.mock('./webhooks/delivery.js', () => ({
  deliverWebhook: vi.fn(),
  isValidWebhookUrl: vi.fn(() => true),
}));

vi.mock('./tools.js', () => ({
  tools: [],
  handleToolCall: vi.fn(),
  stripAccountParam: vi.fn((args: unknown) => args),
}));

vi.mock('./manifest.js', () => ({
  getManifest: vi.fn(() => ({})),
  getOpenApiSpec: vi.fn(() => ({})),
  getOpenApiSpecMail: vi.fn(() => ({})),
  getOpenApiSpecSocial: vi.fn(() => ({})),
}));

// ── Import app after mocks ─────────────────────────────────────────────────────

process.env.NODE_ENV = 'test';
process.env.SERVER_URL = 'https://hermes.squaremcp.com';
process.env.MCP_API_KEY = 'test-global-key';

const { app } = await import('./index.js');

// ── Shared fixtures ────────────────────────────────────────────────────────────

const HERMES_RETURN_TO = 'https://hermes.squaremcp.com/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&state=xyz';

const fakeCustomer = {
  id: 'cust-1',
  email: 'user@example.com',
  plan: 'starter',
  role: 'user',
  active: true,
  api_key: 'api-key-1',
  password_hash: '$2b$10$hashedpassword',
};

const fakeClient = {
  client_id: 'abc',
  client_secret: 'secret',
  client_name: 'Test Client',
  redirect_uris: ['https://claude.ai/api/mcp/auth_callback'],
  created_at: Date.now(),
};

// ── GET /login ─────────────────────────────────────────────────────────────────

describe('GET /login', () => {
  it('returns 200 HTML with a login form', async () => {
    const res = await request(app).get('/login');
    expect(res.status).toBe(200);
    expect(res.headers['content-type']).toMatch(/text\/html/);
    expect(res.text).toContain('<form method="POST" action="/login">');
    expect(res.text).toContain('SquareMCP');
  });

  it('embeds a valid hermes return_to in the form hidden field', async () => {
    const res = await request(app).get(`/login?return_to=${encodeURIComponent(HERMES_RETURN_TO)}`);
    expect(res.status).toBe(200);
    expect(res.text).toContain('https://hermes.squaremcp.com/oauth/authorize');
  });

  it('ignores return_to pointing to other domains (uses /)', async () => {
    const res = await request(app).get('/login?return_to=https%3A%2F%2Fevil.com%2Fsteal');
    expect(res.status).toBe(200);
    // Should fall back to / — the malicious URL must NOT appear in the form
    expect(res.text).not.toContain('evil.com');
    expect(res.text).toContain('value="/"');
  });

  it('ignores return_to pointing to app.squaremcp.com (different subdomain)', async () => {
    const res = await request(app).get('/login?return_to=https%3A%2F%2Fapp.squaremcp.com%2Fdashboard');
    expect(res.status).toBe(200);
    expect(res.text).not.toContain('app.squaremcp.com');
    expect(res.text).toContain('value="/"');
  });

  it('shows error message for error=invalid', async () => {
    const res = await request(app).get('/login?error=invalid');
    expect(res.text).toContain('Incorrect email or password');
  });

  it('shows error message for error=missing', async () => {
    const res = await request(app).get('/login?error=missing');
    expect(res.text).toContain('Email and password are required');
  });

  it('HTML-escapes double quotes in return_to to prevent attribute injection', async () => {
    const xssAttempt = 'https://hermes.squaremcp.com/oauth/authorize?x="onmouseover=alert(1)"';
    const res = await request(app).get(`/login?return_to=${encodeURIComponent(xssAttempt)}`);
    // Quotes should be replaced with &quot; in the hidden field value
    expect(res.text).not.toContain('"onmouseover=alert(1)"');
    expect(res.text).toContain('&quot;');
  });
});

// ── POST /login ────────────────────────────────────────────────────────────────

describe('POST /login', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    mockSignJWT.mockReturnValue('jwt-token-abc');
  });

  it('valid credentials → sets session cookie with correct security properties', async () => {
    mockFindCustomerByEmail.mockResolvedValue(fakeCustomer);
    mockVerifyPassword.mockResolvedValue(true);

    const res = await request(app)
      .post('/login')
      .type('form')
      .send({ email: 'user@example.com', password: 'correct', return_to: HERMES_RETURN_TO });

    expect(res.status).toBe(302);

    const setCookie = res.headers['set-cookie'] as string[] | string;
    const cookieStr = Array.isArray(setCookie) ? setCookie.join('; ') : setCookie;

    expect(cookieStr).toContain('session=jwt-token-abc');
    expect(cookieStr.toLowerCase()).toContain('httponly');
    expect(cookieStr.toLowerCase()).toContain('secure');
    expect(cookieStr.toLowerCase()).toContain('samesite=lax');
    expect(cookieStr.toLowerCase()).toContain('domain=.squaremcp.com');
  });

  it('valid credentials → redirects to return_to on hermes domain', async () => {
    mockFindCustomerByEmail.mockResolvedValue(fakeCustomer);
    mockVerifyPassword.mockResolvedValue(true);

    const res = await request(app)
      .post('/login')
      .type('form')
      .send({ email: 'user@example.com', password: 'correct', return_to: HERMES_RETURN_TO });

    expect(res.status).toBe(302);
    expect(res.headers.location).toBe(HERMES_RETURN_TO);
  });

  it('valid credentials + non-hermes return_to → redirects to / (open redirect blocked)', async () => {
    mockFindCustomerByEmail.mockResolvedValue(fakeCustomer);
    mockVerifyPassword.mockResolvedValue(true);

    const res = await request(app)
      .post('/login')
      .type('form')
      .send({ email: 'user@example.com', password: 'correct', return_to: 'https://evil.com/steal' });

    expect(res.status).toBe(302);
    expect(res.headers.location).toBe('/');
    expect(res.headers.location).not.toContain('evil.com');
  });

  it('valid credentials + app.squaremcp.com return_to → redirects to / (wrong domain blocked)', async () => {
    mockFindCustomerByEmail.mockResolvedValue(fakeCustomer);
    mockVerifyPassword.mockResolvedValue(true);

    const res = await request(app)
      .post('/login')
      .type('form')
      .send({ email: 'user@example.com', password: 'correct', return_to: 'https://app.squaremcp.com/dashboard' });

    expect(res.status).toBe(302);
    expect(res.headers.location).toBe('/');
  });

  it('wrong password → redirect to /login?error=invalid (no cookie set)', async () => {
    mockFindCustomerByEmail.mockResolvedValue(fakeCustomer);
    mockVerifyPassword.mockResolvedValue(false);

    const res = await request(app)
      .post('/login')
      .type('form')
      .send({ email: 'user@example.com', password: 'wrong', return_to: HERMES_RETURN_TO });

    expect(res.status).toBe(302);
    expect(res.headers.location).toMatch(/\/login.*error=invalid/);
    expect(res.headers['set-cookie']).toBeUndefined();
  });

  it('unknown email → redirect to /login?error=invalid', async () => {
    mockFindCustomerByEmail.mockResolvedValue(null);

    const res = await request(app)
      .post('/login')
      .type('form')
      .send({ email: 'nobody@example.com', password: 'anything', return_to: HERMES_RETURN_TO });

    expect(res.status).toBe(302);
    expect(res.headers.location).toMatch(/\/login.*error=invalid/);
  });

  it('missing fields → redirect to /login?error=missing', async () => {
    const res = await request(app)
      .post('/login')
      .type('form')
      .send({ return_to: HERMES_RETURN_TO });

    expect(res.status).toBe(302);
    expect(res.headers.location).toMatch(/\/login.*error=missing/);
  });

  it('preserves hermes return_to through failed login redirect', async () => {
    mockFindCustomerByEmail.mockResolvedValue(null);

    const res = await request(app)
      .post('/login')
      .type('form')
      .send({ email: 'x@x.com', password: 'bad', return_to: HERMES_RETURN_TO });

    expect(res.status).toBe(302);
    expect(res.headers.location).toContain(encodeURIComponent(HERMES_RETURN_TO));
  });
});

// ── GET /oauth/authorize — redirect behavior ───────────────────────────────────

describe('GET /oauth/authorize — unauthenticated redirect', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    mockGetClient.mockResolvedValue(fakeClient);
    mockIsValidRedirectUri.mockReturnValue(true);
  });

  it('redirects to /login — NOT to app.squaremcp.com', async () => {
    const res = await request(app).get(
      '/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&response_type=code&state=xyz'
    );

    expect(res.status).toBe(302);
    // Must redirect to /login (first-party on hermes)
    expect(res.headers.location).toMatch(/^\/login\?/);
    // Must NOT redirect to app.squaremcp.com (wrong cookie context — breaks browser OAuth popup)
    expect(res.headers.location).not.toContain('app.squaremcp.com');
  });

  it('return_to in redirect encodes the full hermes.squaremcp.com authorize URL', async () => {
    const res = await request(app).get(
      '/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&state=xyz'
    );

    expect(res.status).toBe(302);
    const location = res.headers.location as string;
    expect(location).toContain('return_to=');

    // The encoded return_to must start with the hermes domain
    const returnToEncoded = new URL('http://dummy' + location).searchParams.get('return_to');
    expect(returnToEncoded).toMatch(/^https:\/\/hermes\.squaremcp\.com\//);
    expect(returnToEncoded).toContain('client_id=abc');
  });

  it('with invalid cookie → also redirects to /login (not app.squaremcp.com)', async () => {
    mockVerifyJWT.mockImplementation(() => { throw new Error('invalid token'); });

    const res = await request(app)
      .get('/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback')
      .set('Cookie', 'session=bad-token');

    expect(res.status).toBe(302);
    expect(res.headers.location).toMatch(/^\/login\?/);
    expect(res.headers.location).not.toContain('app.squaremcp.com');
  });

  it('with valid session cookie → returns 200 consent HTML (no redirect)', async () => {
    mockVerifyJWT.mockReturnValue({ sub: 'cust-1', email: 'user@example.com', plan: 'starter' });
    mockGetAuthorizeHtml.mockReturnValue('<html>consent</html>');

    const res = await request(app)
      .get('/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback')
      .set('Cookie', 'session=valid-jwt');

    expect(res.status).toBe(200);
    expect(res.text).toContain('consent');
    expect(res.headers.location).toBeUndefined();
  });

  it('missing client_id → 400 (not a redirect)', async () => {
    const res = await request(app).get('/oauth/authorize?redirect_uri=https%3A%2F%2Fclaude.ai%2Fcallback');
    expect(res.status).toBe(400);
  });

  it('unregistered client_id → 400', async () => {
    mockGetClient.mockResolvedValue(null);
    const res = await request(app).get(
      '/oauth/authorize?client_id=unknown&redirect_uri=https%3A%2F%2Fclaude.ai%2Fcallback'
    );
    expect(res.status).toBe(400);
  });

  it('redirect_uri not registered for client → 400', async () => {
    mockIsValidRedirectUri.mockReturnValue(false);
    const res = await request(app).get(
      '/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fevil.com%2Fcallback'
    );
    expect(res.status).toBe(400);
  });
});
