import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ResultSetHeader } from 'mysql2';

// ── DB mock ────────────────────────────────────────────────────────────────
const { mockExecute } = vi.hoisted(() => ({ mockExecute: vi.fn() }));

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

import {
  createAuthCode,
  exchangeCodeForToken,
  validateAccessToken,
  getTokenCustomer,
  isValidRedirectUri,
} from './oauth.js';

// Helper: build a minimal ResultSetHeader-shaped object
function affected(n: number): [ResultSetHeader, unknown[]] {
  return [{ affectedRows: n } as ResultSetHeader, []];
}

// Helper: build a row-result
function rows(data: object[]): [object[], unknown[]] {
  return [data, []];
}

describe('isValidRedirectUri', () => {
  it('exact match is valid', () => {
    expect(isValidRedirectUri('https://app.example.com/callback', ['https://app.example.com/callback'])).toBe(true);
  });

  it('http://localhost:* wildcard matches any localhost port', () => {
    expect(isValidRedirectUri('http://localhost:3000', ['http://localhost:*'])).toBe(true);
    expect(isValidRedirectUri('http://localhost:9876/callback', ['http://localhost:*'])).toBe(true);
  });

  it('http://localhost:* does not match non-localhost', () => {
    expect(isValidRedirectUri('http://evil.com', ['http://localhost:*'])).toBe(false);
    expect(isValidRedirectUri('https://localhost:3000', ['http://localhost:*'])).toBe(false);
  });

  it('unregistered URI is rejected', () => {
    expect(isValidRedirectUri('https://attacker.com', ['https://app.example.com/callback'])).toBe(false);
  });

  it('other wildcards are not supported', () => {
    expect(isValidRedirectUri('https://any.example.com', ['https://*.example.com'])).toBe(false);
  });

  it('empty registered list rejects everything', () => {
    expect(isValidRedirectUri('https://app.example.com/callback', [])).toBe(false);
  });
});

describe('createAuthCode', () => {
  beforeEach(() => vi.clearAllMocks());

  it('inserts with customer_id when provided', async () => {
    mockExecute
      // getClient SELECT (called by nothing here; we call createAuthCode directly)
      .mockResolvedValueOnce([[], []]); // INSERT returns ResultSetHeader but we ignore it

    // The first execute is the INSERT
    mockExecute.mockResolvedValue([{ insertId: 0, affectedRows: 1 }, []]);

    const code = await createAuthCode('client1', 'http://localhost:3000', 'read', undefined, undefined, 'cust-42');

    expect(code.customer_id).toBe('cust-42');
    const [sql, params] = mockExecute.mock.calls[0] as [string, unknown[]];
    expect(sql).toContain('customer_id');
    expect(params).toContain('cust-42');
  });

  it('inserts NULL customer_id when not provided', async () => {
    mockExecute.mockResolvedValue([{ insertId: 0, affectedRows: 1 }, []]);

    const code = await createAuthCode('client1', 'http://localhost:3000');
    expect(code.customer_id).toBeUndefined();

    const [, params] = mockExecute.mock.calls[0] as [string, unknown[]];
    // Last param should be null
    expect(params[params.length - 1]).toBeNull();
  });
});

describe('exchangeCodeForToken — replay protection', () => {
  beforeEach(() => vi.clearAllMocks());

  const fakeClient = {
    client_id: 'c1',
    client_secret: 'secret',
    client_name: 'Test',
    redirect_uris: ['http://localhost:3000'],
    created_at: Date.now(),
  };
  const fakeAuthCode = {
    code: 'abc',
    client_id: 'c1',
    redirect_uri: 'http://localhost:3000',
    scope: 'read',
    code_challenge: null,
    code_challenge_method: null,
    customer_id: 'cust-1',
    expires_at: new Date(Date.now() + 60_000),
    used: true,
  };

  it('returns null when UPDATE affectedRows = 0 (already used)', async () => {
    // getClient SELECT
    mockExecute.mockResolvedValueOnce(rows([fakeClient]));
    // UPDATE oauth_clients last_used
    mockExecute.mockResolvedValueOnce(affected(1));
    // Atomic UPDATE auth code — 0 rows (already used)
    mockExecute.mockResolvedValueOnce(affected(0));

    const result = await exchangeCodeForToken('c1', 'secret', 'abc', 'http://localhost:3000');
    expect(result).toBeNull();
  });

  it('issues token and threads customer_id on success', async () => {
    // getClient SELECT
    mockExecute.mockResolvedValueOnce(rows([fakeClient]));
    // getClient UPDATE last_used
    mockExecute.mockResolvedValueOnce(affected(1));
    // Atomic UPDATE auth code — 1 row consumed
    mockExecute.mockResolvedValueOnce(affected(1));
    // SELECT auth code data
    mockExecute.mockResolvedValueOnce(rows([fakeAuthCode]));
    // INSERT token
    mockExecute.mockResolvedValueOnce(affected(1));

    const token = await exchangeCodeForToken('c1', 'secret', 'abc', 'http://localhost:3000');
    expect(token).not.toBeNull();
    expect(token!.access_token).toBeTruthy();

    // Verify INSERT includes customer_id = 'cust-1'
    const insertCall = mockExecute.mock.calls.find((c) => (c[0] as string).includes('INSERT INTO oauth_tokens'));
    expect(insertCall).toBeDefined();
    const insertParams = insertCall![1] as unknown[];
    expect(insertParams).toContain('cust-1');
  });

  it('returns null when client/redirect mismatch', async () => {
    mockExecute.mockResolvedValueOnce(rows([fakeClient]));
    mockExecute.mockResolvedValueOnce(affected(1));
    mockExecute.mockResolvedValueOnce(affected(1));
    // SELECT returns code with different client_id
    mockExecute.mockResolvedValueOnce(rows([{ ...fakeAuthCode, client_id: 'other' }]));

    const result = await exchangeCodeForToken('c1', 'secret', 'abc', 'http://localhost:3000');
    expect(result).toBeNull();
  });
});

describe('getTokenCustomer', () => {
  beforeEach(() => vi.clearAllMocks());

  it('returns customerId when token has one', async () => {
    mockExecute.mockResolvedValue(rows([{ customer_id: 'cust-99' }]));
    const result = await getTokenCustomer('tok-abc');
    expect(result).toEqual({ customerId: 'cust-99' });
  });

  it('returns null when token not found', async () => {
    mockExecute.mockResolvedValue(rows([]));
    const result = await getTokenCustomer('bad-token');
    expect(result).toBeNull();
  });

  it('returns null when token has no customer_id', async () => {
    mockExecute.mockResolvedValue(rows([{ customer_id: null }]));
    const result = await getTokenCustomer('tok-legacy');
    expect(result).toBeNull();
  });
});

describe('validateAccessToken', () => {
  beforeEach(() => vi.clearAllMocks());

  it('returns true for valid token', async () => {
    mockExecute.mockResolvedValue(rows([{ token: 'tok' }]));
    expect(await validateAccessToken('tok')).toBe(true);
  });

  it('returns false for expired/missing token', async () => {
    mockExecute.mockResolvedValue(rows([]));
    expect(await validateAccessToken('bad')).toBe(false);
  });
});
