/**
 * Auth: upsertUser color round-robin + identity stability
 *
 * Tests for apps/api/src/auth/user.ts (Plan 02)
 */

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

// Mock the db singleton at module level (Vitest hoisting — must be top-level)
vi.mock('../../src/db/client.js', () => ({
  db: {
    select: vi.fn(),
    insert: vi.fn(),
  },
}));

// Import after mock is set up
import { db } from '../../src/db/client.js';
import { upsertUser, COLOR_PALETTE } from '../../src/auth/user.js';

const mockDb = db as {
  select: ReturnType<typeof vi.fn>;
  insert: ReturnType<typeof vi.fn>;
};

// Chainable builder factory used in multiple tests
function makeSelectChain(resolvedValue: unknown[]) {
  const chain = {
    from: vi.fn(),
    where: vi.fn(),
    limit: vi.fn().mockResolvedValue(resolvedValue),
  };
  chain.from.mockReturnValue(chain);
  chain.where.mockReturnValue(chain);
  return chain;
}

function makeInsertChain(returningIdValue: { id: number }[]) {
  const chain = {
    values: vi.fn(),
    $returningId: vi.fn().mockResolvedValue(returningIdValue),
  };
  chain.values.mockReturnValue(chain);
  return chain;
}

describe('COLOR_PALETTE', () => {
  it('exports at least 4 distinct hex colors', () => {
    expect(COLOR_PALETTE).toBeDefined();
    expect(COLOR_PALETTE.length).toBeGreaterThanOrEqual(4);
    for (const c of COLOR_PALETTE) {
      // Each entry must be a 7-char hex string like #4A90D9
      expect(c).toMatch(/^#[0-9A-Fa-f]{6}$/);
    }
    // All colors must be distinct
    const unique = new Set(COLOR_PALETTE);
    expect(unique.size).toBe(COLOR_PALETTE.length);
  });
});

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

  it('assigns palette[0] to the first user inserted', async () => {
    const iss = 'https://auth.example.com';
    const sub = 'user-sub-001';

    // First select: no existing user
    // Second select (used colors): no existing users → no colors in use → palette[0]
    // Third select (re-fetch after insert): return the inserted row
    let selectCallCount = 0;
    mockDb.select.mockImplementation(() => {
      selectCallCount++;
      if (selectCallCount === 1) {
        // Lookup by iss+sub — not found
        return makeSelectChain([]);
      }
      if (selectCallCount === 2) {
        // Used-colors query — no existing users
        return {
          from: vi.fn().mockResolvedValue([]),
        };
      }
      // Re-fetch after insert
      return makeSelectChain([
        {
          id: 1,
          oidcIss: iss,
          oidcSub: sub,
          displayName: null,
          color: COLOR_PALETTE[0],
          createdAt: new Date(),
        },
      ]);
    });

    mockDb.insert.mockReturnValue(makeInsertChain([{ id: 1 }]));

    const user = await upsertUser(iss, sub);

    expect(user).toBeDefined();
    expect(user!.color).toBe(COLOR_PALETTE[0]);
    expect(user!.id).toBe(1);
  });

  it('assigns palette[1] to the second distinct user', async () => {
    const iss = 'https://auth.example.com';
    const sub2 = 'user-sub-002';

    let selectCallCount = 0;
    mockDb.select.mockImplementation(() => {
      selectCallCount++;
      if (selectCallCount === 1) {
        return makeSelectChain([]); // not found
      }
      if (selectCallCount === 2) {
        // Used-colors query — one existing user already holds palette[0],
        // so the next member must get the first unused color: palette[1].
        return {
          from: vi.fn().mockResolvedValue([{ color: COLOR_PALETTE[0] }]),
        };
      }
      return makeSelectChain([
        {
          id: 2,
          oidcIss: iss,
          oidcSub: sub2,
          displayName: null,
          color: COLOR_PALETTE[1],
          createdAt: new Date(),
        },
      ]);
    });

    mockDb.insert.mockReturnValue(makeInsertChain([{ id: 2 }]));

    const user = await upsertUser(iss, sub2);

    expect(user!.color).toBe(COLOR_PALETTE[1]);
  });

  // Regression (Gate 2): a new member must get a color NOT already in use, even
  // after a deletion. The old COUNT(*) % palette logic reused an in-use slot
  // when the user count had shifted (two members both got #E8734A). With colors
  // [0] and [2] taken (slot [1] freed by a delete), the next member fills [1].
  it('assigns the first UNUSED palette color (no collision after deletions)', async () => {
    const iss = 'https://auth.example.com';
    const sub = 'user-sub-005';

    let selectCallCount = 0;
    mockDb.select.mockImplementation(() => {
      selectCallCount++;
      if (selectCallCount === 1) return makeSelectChain([]); // not found
      if (selectCallCount === 2) {
        // palette[0] and palette[2] in use; palette[1] is free
        return {
          from: vi
            .fn()
            .mockResolvedValue([{ color: COLOR_PALETTE[0] }, { color: COLOR_PALETTE[2] }]),
        };
      }
      return makeSelectChain([
        {
          id: 5,
          oidcIss: iss,
          oidcSub: sub,
          displayName: null,
          color: COLOR_PALETTE[1],
          createdAt: new Date(),
        },
      ]);
    });
    mockDb.insert.mockReturnValue(makeInsertChain([{ id: 5 }]));

    await upsertUser(iss, sub);

    // The inserted row's color must be the first unused palette entry (palette[1]).
    const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0];
    expect(insertValues.color).toBe(COLOR_PALETTE[1]);
  });

  it('returns the same user row on re-upsert (idempotent — no duplicate insert)', async () => {
    const iss = 'https://auth.example.com';
    const sub = 'user-sub-001';
    const existingRow = {
      id: 1,
      oidcIss: iss,
      oidcSub: sub,
      displayName: 'Lucas',
      color: COLOR_PALETTE[0],
      createdAt: new Date(),
    };

    // select returns existing row immediately
    mockDb.select.mockImplementation(() => makeSelectChain([existingRow]));

    const user = await upsertUser(iss, sub, 'Lucas');

    // Must NOT call insert (idempotent path)
    expect(mockDb.insert).not.toHaveBeenCalled();
    expect(user!.id).toBe(1);
    expect(user!.color).toBe(COLOR_PALETTE[0]);
  });

  it('uses oidc_iss + oidc_sub as identity key, never email', async () => {
    const iss = 'https://auth.example.com';
    const sub = 'user-sub-003';

    let selectCallCount = 0;
    mockDb.select.mockImplementation(() => {
      selectCallCount++;
      if (selectCallCount === 1) return makeSelectChain([]);
      if (selectCallCount === 2) {
        return { from: vi.fn().mockResolvedValue([{ count: 0 }]) };
      }
      return makeSelectChain([
        {
          id: 3,
          oidcIss: iss,
          oidcSub: sub,
          displayName: null,
          color: COLOR_PALETTE[0],
          createdAt: new Date(),
        },
      ]);
    });
    mockDb.insert.mockReturnValue(makeInsertChain([{ id: 3 }]));

    // Pass a displayName (e.g. email) — identity still keyed on iss+sub
    await upsertUser(iss, sub, 'lucas@example.com');

    // The insert values must include oidcIss and oidcSub, not email as key
    const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0];
    expect(insertValues).toBeDefined();
    expect(insertValues.oidcIss).toBe(iss);
    expect(insertValues.oidcSub).toBe(sub);
    // No 'email' property should be used as an identity field
    expect(insertValues).not.toHaveProperty('email');
  });

  it('returns the full user row including id, color, displayName', async () => {
    const iss = 'https://auth.example.com';
    const sub = 'user-sub-004';
    const existingRow = {
      id: 42,
      oidcIss: iss,
      oidcSub: sub,
      displayName: 'Alice',
      color: '#9B6DC5',
      createdAt: new Date(),
    };

    mockDb.select.mockImplementation(() => makeSelectChain([existingRow]));

    const user = await upsertUser(iss, sub, 'Alice');

    expect(user).toBeDefined();
    expect(user!.id).toBe(42);
    expect(user!.color).toBe('#9B6DC5');
    expect(user!.displayName).toBe('Alice');
  });
});
