import { describe, it, expect, beforeAll, vi } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';

// Environment must be pinned BEFORE the db/betterAuth singletons are imported
// (dotenv never overrides pre-set values).
const dir = mkdtempSync(join(tmpdir(), 'ba-test-'));
const dbPath = join(dir, 'test.db');
process.env.DB_TYPE = 'sqlite';
process.env.DATABASE_URL = dbPath;
process.env.FRONTEND_URL = 'http://localhost:3002';
process.env.BETTER_AUTH_SECRET = 'integration-test-secret-0123456789abcdef';
delete process.env.REDIS_URL; // memory lockout/rate-limit backends
delete process.env.GOOGLE_CLIENT_ID;

// Capture outgoing auth emails (magic links, password resets)
const sentEmails: Array<{ to: string; subject: string; html: string }> = [];
vi.mock('./email.js', () => ({
  sendEmail: vi.fn(async (opts: any) => {
    sentEmails.push(opts);
  }),
  emailService: {},
  default: {},
}));

let auth: (typeof import('./betterAuth.js'))['auth'];
let db: any;
let sqlite: any;

function lastEmailTo(email: string) {
  const found = [...sentEmails].reverse().find((e) => e.to === email);
  expect(found, `expected an email sent to ${email}`).toBeTruthy();
  return found!;
}

function extractToken(html: string, param = 'token'): string {
  const match = html.match(new RegExp(`[?&]${param}=([^"&\\s]+)`));
  expect(match, `expected a ${param} in the email link`).toBeTruthy();
  return decodeURIComponent(match![1]);
}

function cookieHeaders(setCookie: string | null): Headers {
  const sessionPart = (setCookie || '')
    .split(/,(?=[^ ;]+=)/)
    .map((c) => c.split(';')[0].trim())
    .filter((c) => c.includes('session_token'))
    .join('; ');
  return new Headers({ cookie: sessionPart });
}

beforeAll(() => {
  execFileSync('npx', ['tsx', 'src/db/migrate.ts'], {
    env: { ...process.env },
    stdio: 'pipe',
  });
  return (async () => {
    ({ auth } = await import('./betterAuth.js'));
    ({ db } = await import('../db/index.js'));
    const Database = (await import('better-sqlite3')).default;
    sqlite = new Database(dbPath);
  })();
}, 120_000);

describe('Better Auth integration', () => {
  it('makes the first registered user an admin, later users regular', async () => {
    const first = await auth.api.signUpEmail({
      body: { email: 'admin@test.py', password: 'FirstAdmin1!x', name: 'Admin' },
    });
    expect((first.user as any).id).toBeTruthy();

    const row = sqlite.prepare('SELECT role, is_claimed, account_status FROM users WHERE email = ?').get('admin@test.py');
    expect(row.role).toBe('admin');
    expect(row.account_status).toBe('active');

    await auth.api.signUpEmail({
      body: { email: 'user@test.py', password: 'SecondUser1!x', name: 'User' },
    });
    const row2 = sqlite.prepare('SELECT role FROM users WHERE email = ?').get('user@test.py');
    expect(row2.role).toBe('user');
  });

  it('stores credential passwords as argon2id in auth_accounts, not users', async () => {
    const acct = sqlite
      .prepare("SELECT a.password FROM auth_accounts a JOIN users u ON u.id = a.user_id WHERE u.email = ? AND a.provider_id = 'credential'")
      .get('admin@test.py');
    expect(acct.password.startsWith('$argon2id$')).toBe(true);
    const user = sqlite.prepare('SELECT password FROM users WHERE email = ?').get('admin@test.py');
    expect(user.password).toBeNull();
  });

  it('rejects passwords that violate the policy', async () => {
    // Too short
    await expect(
      auth.api.signUpEmail({ body: { email: 'weak1@test.py', password: 'Short1!', name: 'W' } })
    ).rejects.toThrow(/at least 10 characters/);

    // Long enough but no character mix (app policy hook)
    await expect(
      auth.api.signUpEmail({ body: { email: 'weak2@test.py', password: 'alllowercasepw', name: 'W' } })
    ).rejects.toThrow(/uppercase and lowercase/);

    // Common password normalized (policy blocklist)
    await expect(
      auth.api.signUpEmail({ body: { email: 'weak3@test.py', password: 'Spanglish!', name: 'W' } })
    ).rejects.toThrow(/too common/);

    expect(sqlite.prepare("SELECT COUNT(*) AS n FROM users WHERE email LIKE 'weak%'").get().n).toBe(0);
  });

  it('locks an email after 5 failed sign-ins', async () => {
    await auth.api.signUpEmail({
      body: { email: 'lockout@test.py', password: 'LockoutPass1!', name: 'L' },
    });
    for (let i = 0; i < 5; i++) {
      await expect(
        auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'WrongPass1!x' } })
      ).rejects.toThrow();
    }
    // Correct password now also refused: locked
    await expect(
      auth.api.signInEmail({ body: { email: 'lockout@test.py', password: 'LockoutPass1!' } })
    ).rejects.toThrow(/Too many login attempts/);
  });

  it('refuses sign-in for banned (suspended) users and kills nothing else', async () => {
    await auth.api.signUpEmail({
      body: { email: 'banned@test.py', password: 'BannedPass1!x', name: 'B' },
    });
    sqlite.prepare("UPDATE users SET banned = 1, account_status = 'suspended' WHERE email = ?").run('banned@test.py');
    await expect(
      auth.api.signInEmail({ body: { email: 'banned@test.py', password: 'BannedPass1!x' } })
    ).rejects.toThrow(/suspended|banned/i);
  });

  it('verifies legacy bcrypt hashes and upgrades them to argon2 on sign-in', async () => {
    const bcrypt = (await import('bcryptjs')).default;
    const legacyHash = bcrypt.hashSync('LegacyBcrypt1!', 10);
    const su = await auth.api.signUpEmail({
      body: { email: 'legacy@test.py', password: 'TempPass123!x', name: 'Legacy' },
    });
    sqlite
      .prepare("UPDATE auth_accounts SET password = ? WHERE user_id = ? AND provider_id = 'credential'")
      .run(legacyHash, (su.user as any).id);

    const si = await auth.api.signInEmail({
      body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' },
    });
    expect(si.user.email).toBe('legacy@test.py');

    // Upgrade happens in the after-hook; poll briefly for it
    let upgraded = '';
    for (let i = 0; i < 20 && !upgraded.startsWith('$argon2'); i++) {
      await new Promise((r) => setTimeout(r, 100));
      upgraded = sqlite
        .prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'")
        .get((su.user as any).id).password;
    }
    expect(upgraded.startsWith('$argon2id$')).toBe(true);

    // And the upgraded hash still verifies
    const again = await auth.api.signInEmail({
      body: { email: 'legacy@test.py', password: 'LegacyBcrypt1!' },
    });
    expect(again.user.email).toBe('legacy@test.py');
  });

  it('magic link signs in existing users but never creates accounts', async () => {
    await auth.api.signUpEmail({
      body: { email: 'magic@test.py', password: 'MagicPass12!x', name: 'M' },
    });
    await auth.api.signInMagicLink({
      body: { email: 'magic@test.py', callbackURL: '/dashboard' },
      headers: new Headers(),
    });
    const email = lastEmailTo('magic@test.py');
    // Emails link to the frontend page, not the raw API endpoint
    expect(email.html).toContain('http://localhost:3002/auth/magic-link?token=');
    const token = extractToken(email.html);

    const verified = await auth.api.magicLinkVerify({
      query: { token },
      headers: new Headers(),
    });
    expect((verified as any).user?.email ?? (verified as any).session?.userId).toBeTruthy();

    // Unknown email: enumeration-safe success (an email may still go out),
    // but verification can never create an account (disableSignUp)
    const ghost = await auth.api.signInMagicLink({
      body: { email: 'ghost@test.py' },
      headers: new Headers(),
    });
    expect((ghost as any).status).toBe(true);
    const ghostEmail = sentEmails.filter((e) => e.to === 'ghost@test.py').pop();
    if (ghostEmail) {
      const ghostToken = extractToken(ghostEmail.html);
      await expect(
        auth.api.magicLinkVerify({ query: { token: ghostToken }, headers: new Headers() })
      ).rejects.toThrow();
    }
    expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('ghost@test.py').n).toBe(0);
  });

  it('completes the guest claim path: magic link session + setPassword', async () => {
    // Simulate tickets.ts guest creation: user row, no credential account
    const guestId = 'guest-claim-user-000001';
    const now = new Date().toISOString();
    sqlite
      .prepare(
        `INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, banned, token_version, created_at, updated_at)
         VALUES (?, ?, NULL, 'Guest', 'user', 0, 'unclaimed', 0, 0, 0, ?, ?)`
      )
      .run(guestId, 'guest@test.py', now, now);

    await auth.api.signInMagicLink({
      body: { email: 'guest@test.py', callbackURL: '/auth/claim-account' },
      headers: new Headers(),
    });
    const token = extractToken(lastEmailTo('guest@test.py').html);
    const verified = await auth.api.magicLinkVerify({
      query: { token },
      returnHeaders: true,
      headers: new Headers(),
    });
    const headers = cookieHeaders(verified.headers.get('set-cookie'));

    // The session works even while unclaimed (the claim endpoint depends on this)
    const session = await auth.api.getSession({ headers });
    expect((session?.user as any)?.accountStatus).toBe('unclaimed');

    // Set the password (what /api/auth-ext/claim-account does)
    await auth.api.setPassword({ body: { newPassword: 'ClaimedPass1!x' }, headers });
    const acct = sqlite
      .prepare("SELECT password FROM auth_accounts WHERE user_id = ? AND provider_id = 'credential'")
      .get(guestId);
    expect(acct.password.startsWith('$argon2id$')).toBe(true);

    // The claim email uses the claim template with the frontend link
    const claimEmail = lastEmailTo('guest@test.py');
    expect(claimEmail.subject).toContain('Claim');
    expect(claimEmail.html).toContain('callbackURL=%2Fauth%2Fclaim-account');
  });

  it('password reset revokes existing sessions and applies the new password', async () => {
    const su = await auth.api.signUpEmail({
      body: { email: 'reset@test.py', password: 'BeforeReset1!x', name: 'R' },
    });
    const userId = (su.user as any).id;
    // A live session from sign-in
    await auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } });
    expect(
      sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n
    ).toBeGreaterThan(0);

    await auth.api.requestPasswordReset({
      body: { email: 'reset@test.py', redirectTo: '/auth/reset-password' },
    });
    const email = lastEmailTo('reset@test.py');
    // Reset URLs are either .../reset-password/{token}?... or ...?token={token}
    const html = email.html;
    const pathMatch = html.match(/reset-password\/([^?"&\s]+)/);
    const token = pathMatch ? decodeURIComponent(pathMatch[1]) : extractToken(html);

    await auth.api.resetPassword({ body: { newPassword: 'AfterReset1!x', token } });

    // revokeSessionsOnPasswordReset: true
    expect(
      sqlite.prepare('SELECT COUNT(*) AS n FROM auth_sessions WHERE user_id = ?').get(userId).n
    ).toBe(0);

    await expect(
      auth.api.signInEmail({ body: { email: 'reset@test.py', password: 'BeforeReset1!x' } })
    ).rejects.toThrow();
    const after = await auth.api.signInEmail({
      body: { email: 'reset@test.py', password: 'AfterReset1!x' },
    });
    expect(after.user.email).toBe('reset@test.py');
  });

  it('sessions are stored in auth_sessions with 7-day expiry', async () => {
    const si = await auth.api.signInEmail({
      body: { email: 'admin@test.py', password: 'FirstAdmin1!x' },
    });
    const row = sqlite
      .prepare('SELECT expires_at FROM auth_sessions WHERE token = ?')
      .get((si as any).token);
    expect(row).toBeTruthy();
    const days = (row.expires_at - Date.now()) / (1000 * 60 * 60 * 24);
    expect(days).toBeGreaterThan(6.5);
    expect(days).toBeLessThan(7.5);
  });
});
