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

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

import { getPool } from './db.js';
import {
  isValidTokenFormat,
  isBot,
  validateDestinationUrl,
  createTrackingLink,
  getTrackingLink,
  recordClick,
} from './tracking-links.js';

// ── isValidTokenFormat ────────────────────────────────────────────────────────

describe('isValidTokenFormat', () => {
  it('accepts a 32-char base64url string', () => {
    expect(isValidTokenFormat('abcdefghijklmnopqrstuvwxyz123456')).toBe(true);
    expect(isValidTokenFormat('ABCDEFGHIJKLMNOPQRSTUVWXYZ-_1234')).toBe(true);
  });

  it('rejects wrong length', () => {
    expect(isValidTokenFormat('short')).toBe(false);
    expect(isValidTokenFormat('a'.repeat(33))).toBe(false);
  });

  it('rejects non-base64url characters', () => {
    expect(isValidTokenFormat('a'.repeat(31) + '!')).toBe(false);
    expect(isValidTokenFormat('a'.repeat(31) + '+')).toBe(false);
  });
});

// ── isBot ─────────────────────────────────────────────────────────────────────

describe('isBot', () => {
  it('flags known bot user agents', () => {
    expect(isBot('Googlebot/2.1')).toBe(true);
    expect(isBot('facebookexternalhit/1.1')).toBe(true);
    expect(isBot('AhrefsBot/7.0')).toBe(true);
  });

  it('passes real browser user agents', () => {
    expect(isBot('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)')).toBe(false);
    expect(isBot('Mozilla/5.0 (Windows NT 10.0) Chrome/120')).toBe(false);
  });
});

// ── validateDestinationUrl ────────────────────────────────────────────────────

describe('validateDestinationUrl', () => {
  it('accepts http and https URLs', () => {
    expect(() => validateDestinationUrl('https://calendly.com/alex/30min')).not.toThrow();
    expect(() => validateDestinationUrl('http://example.com')).not.toThrow();
  });

  it('rejects non-http protocols', () => {
    expect(() => validateDestinationUrl('javascript:alert(1)')).toThrow();
    expect(() => validateDestinationUrl('ftp://example.com')).toThrow();
  });

  it('rejects URLs with embedded credentials', () => {
    expect(() => validateDestinationUrl('https://user:pass@example.com')).toThrow('credentials');
  });

  it('rejects URLs over 2048 chars', () => {
    expect(() => validateDestinationUrl('https://example.com/' + 'a'.repeat(2048))).toThrow('too long');
  });

  it('rejects malformed URLs', () => {
    expect(() => validateDestinationUrl('not a url')).toThrow();
  });
});

// ── createTrackingLink ────────────────────────────────────────────────────────

describe('createTrackingLink', () => {
  const mockQuery = vi.fn();
  beforeEach(() => {
    mockQuery.mockClear();
    vi.mocked(getPool).mockReturnValue({ query: mockQuery } as any);
    mockQuery.mockResolvedValue([{ insertId: 1, affectedRows: 1 }]);
  });

  it('returns a 32-char token on success', async () => {
    const token = await createTrackingLink({
      customerId: 'cust-1',
      draftId: 42,
      destinationUrl: 'https://calendly.com/alex',
      expiresAt: null,
    });
    expect(token).toHaveLength(32);
    expect(/^[A-Za-z0-9_-]{32}$/.test(token)).toBe(true);
  });

  it('retries on duplicate key error and succeeds', async () => {
    mockQuery
      .mockRejectedValueOnce({ code: 'ER_DUP_ENTRY' })
      .mockResolvedValueOnce([{ insertId: 1 }]);
    const token = await createTrackingLink({
      customerId: 'cust-1',
      draftId: null,
      destinationUrl: 'https://example.com',
      expiresAt: null,
    });
    expect(token).toHaveLength(32);
    expect(mockQuery).toHaveBeenCalledTimes(2);
  });

  it('throws when destination URL is invalid', async () => {
    await expect(
      createTrackingLink({
        customerId: 'cust-1',
        draftId: null,
        destinationUrl: 'javascript:alert(1)',
        expiresAt: null,
      })
    ).rejects.toThrow();
    expect(mockQuery).not.toHaveBeenCalled();
  });
});

// ── getTrackingLink ───────────────────────────────────────────────────────────

describe('getTrackingLink', () => {
  const mockQuery = vi.fn();
  beforeEach(() => {
    vi.mocked(getPool).mockReturnValue({ query: mockQuery } as any);
  });

  const baseRow = {
    token: 'a'.repeat(32),
    customer_id: 'cust-1',
    draft_id: 7,
    destination_url: 'https://calendly.com/alex',
    click_count: 5,
    expires_at: null,
    created_at: new Date('2026-06-09'),
  };

  it('returns a tracking link for a known token', async () => {
    mockQuery.mockResolvedValue([[baseRow]]);
    const link = await getTrackingLink('a'.repeat(32));
    expect(link).not.toBeNull();
    expect(link!.destinationUrl).toBe('https://calendly.com/alex');
    expect(link!.clickCount).toBe(5);
  });

  it('returns null for an unknown token', async () => {
    mockQuery.mockResolvedValue([[]]);
    expect(await getTrackingLink('b'.repeat(32))).toBeNull();
  });

  it('returns null for an expired token', async () => {
    mockQuery.mockResolvedValue([[{ ...baseRow, expires_at: new Date('2020-01-01') }]]);
    expect(await getTrackingLink('a'.repeat(32))).toBeNull();
  });

  it('returns a link when expires_at is null (no expiry)', async () => {
    mockQuery.mockResolvedValue([[{ ...baseRow, expires_at: null }]]);
    const link = await getTrackingLink('a'.repeat(32));
    expect(link).not.toBeNull();
  });

  it('returns a link when expires_at is in the future', async () => {
    const future = new Date(Date.now() + 86400_000);
    mockQuery.mockResolvedValue([[{ ...baseRow, expires_at: future }]]);
    const link = await getTrackingLink('a'.repeat(32));
    expect(link).not.toBeNull();
  });
});

// ── recordClick ───────────────────────────────────────────────────────────────

describe('recordClick', () => {
  const mockQuery = vi.fn();
  beforeEach(() => {
    vi.mocked(getPool).mockReturnValue({ query: mockQuery } as any);
  });

  it('fires an UPDATE without throwing', () => {
    mockQuery.mockResolvedValue([{ affectedRows: 1 }]);
    expect(() => recordClick('a'.repeat(32))).not.toThrow();
  });

  it('swallows DB errors — does not propagate', async () => {
    const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
    mockQuery.mockRejectedValue(new Error('DB down'));
    recordClick('a'.repeat(32));
    // Give the microtask queue a tick to process the rejected promise
    await new Promise(r => setTimeout(r, 0));
    expect(consoleSpy).toHaveBeenCalledWith(
      expect.stringContaining('[tracking]'),
      expect.any(Error)
    );
    consoleSpy.mockRestore();
  });
});
