import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { DBProvider } from '../../app-providers/db.provider.js';
import { emailMatchesPattern } from '../../financial-entities/helpers/email-pattern.helper.js';
import {
  EmailKind,
  type EmailClassification,
} from '../helpers/email-ingestion-classify.helper.js';
import { EmailIngestionControlProvider } from '../providers/email-ingestion-control.provider.js';

// ---------------------------------------------------------------------------
// Mock DB helpers
// ---------------------------------------------------------------------------

type MockQueryResult = { rows: Record<string, unknown>[]; rowCount: number };

function isControlStatement(text: string): boolean {
  return (
    text === 'BEGIN' || text === 'COMMIT' || text === 'ROLLBACK' || text.includes('set_config')
  );
}

function makeDbProvider(
  queryImpl: (text: string, params?: unknown[]) => MockQueryResult,
): DBProvider {
  // resolveAlias runs on the raw pool; the grant-table ops (issueGrant /
  // validateAndConsumeGrant) run on a pooled client inside a transaction. One
  // shared mock backs both pool.query and the connected client.query: control
  // statements (BEGIN / SET LOCAL / COMMIT / ROLLBACK) are served transparently
  // and data queries delegate to queryImpl, so every query is recorded in one
  // place.
  const query = vi.fn().mockImplementation((text: unknown, params?: unknown[]) => {
    const sqlText = typeof text === 'string' ? text : '';
    if (isControlStatement(sqlText)) {
      return { rows: [], rowCount: 0 };
    }
    return queryImpl(sqlText, params);
  });
  const release = vi.fn();
  return {
    pool: {
      query,
      connect: vi.fn().mockResolvedValue({ query, release }),
    },
  } as unknown as DBProvider;
}

// ---------------------------------------------------------------------------
// resolveAlias
// ---------------------------------------------------------------------------

describe('EmailIngestionControlProvider.resolveAlias', () => {
  it('returns found=true with tenantId for a known active alias', async () => {
    const db = makeDbProvider(() => ({
      rows: [{ owner_id: 'tenant-uuid-1' }],
      rowCount: 1,
    }));
    const provider = new EmailIngestionControlProvider(db);
    const result = await provider.resolveAlias('invoice@tenant.example.com');

    expect(result.found).toBe(true);
    if (result.found) {
      expect(result.tenantId).toBe('tenant-uuid-1');
    }
  });

  it('returns found=false with UNKNOWN_ALIAS reason when alias is not in table', async () => {
    const db = makeDbProvider(() => ({ rows: [], rowCount: 0 }));
    const provider = new EmailIngestionControlProvider(db);
    const result = await provider.resolveAlias('notfound@example.com');

    expect(result.found).toBe(false);
    if (!result.found) {
      expect(result.reason).toBe('UNKNOWN_ALIAS');
    }
  });

  it('returns found=false for an inactive alias (query filters is_active=TRUE)', async () => {
    // The provider queries with is_active = TRUE, so an inactive alias returns 0 rows.
    const db = makeDbProvider(() => ({ rows: [], rowCount: 0 }));
    const provider = new EmailIngestionControlProvider(db);
    const result = await provider.resolveAlias('inactive@example.com');

    expect(result.found).toBe(false);
    if (!result.found) {
      expect(result.reason).toBe('UNKNOWN_ALIAS');
    }
  });

  it('queries with lowercased alias and active flag', async () => {
    const query = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 });
    const db = { pool: { query } } as unknown as DBProvider;
    const provider = new EmailIngestionControlProvider(db);

    await provider.resolveAlias('Mixed@Case.Example.COM');

    const [sql, params] = query.mock.calls[0] as [string, unknown[]];
    expect(sql.toLowerCase()).toMatch(/is_active/);
    expect(params[0]).toBe('mixed@case.example.com');
  });
});

// ---------------------------------------------------------------------------
// issueGrant
// ---------------------------------------------------------------------------

describe('EmailIngestionControlProvider.issueGrant', () => {
  const grantInput = {
    tenantId: 'tenant-uuid-1',
    messageId: 'msg-abc-123',
    rawMessageHash: 'sha256-abc',
    expiresAt: new Date('2026-06-11T00:05:00Z'),
    correlationId: 'corr-xyz',
  };

  let provider: EmailIngestionControlProvider;
  let mockDb: DBProvider;

  beforeEach(() => {
    mockDb = makeDbProvider(() => ({
      rows: [
        {
          id: 'grant-row-uuid',
          jti: 'jti-value',
          owner_id: 'tenant-uuid-1',
          action: 'ingest',
          expires_at: new Date('2026-06-11T00:05:00Z'),
        },
      ],
      rowCount: 1,
    }));
    provider = new EmailIngestionControlProvider(mockDb);
  });

  it('returns a grant with jti and tenantId', async () => {
    const grant = await provider.issueGrant(grantInput);
    expect(typeof grant.jti).toBe('string');
    expect(grant.jti.length).toBeGreaterThan(0);
    expect(grant.tenantId).toBe('tenant-uuid-1');
  });

  it('returns a grant with messageId and rawMessageHash matching input', async () => {
    const grant = await provider.issueGrant(grantInput);
    expect(grant.messageId).toBe('msg-abc-123');
    expect(grant.rawMessageHash).toBe('sha256-abc');
  });

  it('returns a grant with action=ingest and valid expiresAt', async () => {
    const grant = await provider.issueGrant(grantInput);
    expect(grant.action).toBe('ingest');
    expect(grant.expiresAt).toBeInstanceOf(Date);
  });

  it('includes decisionId and auditId as non-empty strings', async () => {
    const grant = await provider.issueGrant(grantInput);
    expect(typeof grant.decisionId).toBe('string');
    expect(grant.decisionId.length).toBeGreaterThan(0);
    expect(typeof grant.auditId).toBe('string');
    expect(grant.auditId.length).toBeGreaterThan(0);
  });

  it('decisionId and auditId are distinct', async () => {
    const grant = await provider.issueGrant(grantInput);
    expect(grant.decisionId).not.toBe(grant.auditId);
  });

  it('inserts into the grants table with correct tenant binding', async () => {
    await provider.issueGrant(grantInput);
    const query = mockDb.pool.query as ReturnType<typeof vi.fn>;
    // The INSERT runs on the pooled client among BEGIN / SET LOCAL / COMMIT, so
    // locate it rather than assuming a fixed call index.
    const insertCall = query.mock.calls.find(
      ([sql]) =>
        typeof sql === 'string' && /insert.*email_ingestion_grants/s.test(sql.toLowerCase()),
    ) as [string, unknown[]] | undefined;
    expect(insertCall).toBeDefined();
    const params = insertCall![1];
    expect(params).toContain('tenant-uuid-1');
    expect(params).toContain('msg-abc-123');
  });

  it('persists the recognized business_id when provided', async () => {
    await provider.issueGrant({ ...grantInput, businessId: 'biz-uuid-9' });
    const query = mockDb.pool.query as ReturnType<typeof vi.fn>;
    const insertCall = query.mock.calls.find(
      ([sql]) =>
        typeof sql === 'string' && /insert.*email_ingestion_grants/s.test(sql.toLowerCase()),
    ) as [string, unknown[]] | undefined;
    expect(insertCall).toBeDefined();
    expect(insertCall![1]).toContain('biz-uuid-9');
  });

  it('persists null business_id when none was recognized', async () => {
    await provider.issueGrant(grantInput);
    const query = mockDb.pool.query as ReturnType<typeof vi.fn>;
    const insertCall = query.mock.calls.find(
      ([sql]) =>
        typeof sql === 'string' && /insert.*email_ingestion_grants/s.test(sql.toLowerCase()),
    ) as [string, unknown[]] | undefined;
    expect(insertCall).toBeDefined();
    expect(insertCall![1]).toContain(null);
  });
});

// ---------------------------------------------------------------------------
// recognizeBusiness
// ---------------------------------------------------------------------------

describe('EmailIngestionControlProvider.recognizeBusiness', () => {
  it('returns the businessId and emailListener config for a matched business', async () => {
    const db = makeDbProvider(sql => {
      if (/from\s+accounter_schema\.businesses/.test(sql.toLowerCase())) {
        return {
          rows: [
            {
              id: 'biz-1',
              suggestion_data: {
                emails: ['vendor@acme.com'],
                emailListener: {
                  emailBody: true,
                  attachments: ['PDF'],
                  internalEmailLinks: ['https://acme.com/inv'],
                },
              },
            },
          ],
          rowCount: 1,
        };
      }
      return { rows: [], rowCount: 0 };
    });
    const provider = new EmailIngestionControlProvider(db);
    const result = await provider.recognizeBusiness('tenant-1', 'vendor@acme.com');

    expect(result.businessId).toBe('biz-1');
    expect(result.config).toEqual({
      emailBody: true,
      attachments: ['PDF'],
      internalEmailLinks: ['https://acme.com/inv'],
    });
  });

  it('returns null businessId and empty config when no business matches', async () => {
    const db = makeDbProvider(() => ({ rows: [], rowCount: 0 }));
    const provider = new EmailIngestionControlProvider(db);
    const result = await provider.recognizeBusiness('tenant-1', 'nobody@nowhere.com');

    expect(result.businessId).toBeNull();
    expect(result.config).toEqual({});
  });

  it('short-circuits without touching the DB when no issuer email is given', async () => {
    const query = vi.fn();
    const connect = vi.fn();
    const db = { pool: { query, connect } } as unknown as DBProvider;
    const provider = new EmailIngestionControlProvider(db);
    const result = await provider.recognizeBusiness('tenant-1', null);

    expect(result).toEqual({ businessId: null, config: {} });
    expect(query).not.toHaveBeenCalled();
    expect(connect).not.toHaveBeenCalled();
  });

  it('returns the businessId but empty config when suggestion_data is invalid', async () => {
    const db = makeDbProvider(sql => {
      if (/from\s+accounter_schema\.businesses/.test(sql.toLowerCase())) {
        return { rows: [{ id: 'biz-2', suggestion_data: { unexpected: 'shape' } }], rowCount: 1 };
      }
      return { rows: [], rowCount: 0 };
    });
    const provider = new EmailIngestionControlProvider(db);
    const result = await provider.recognizeBusiness('tenant-1', 'vendor@acme.com');

    expect(result.businessId).toBe('biz-2');
    expect(result.config).toEqual({});
  });

  it('pins the lookup to the tenant RLS context (set_config with the tenant id)', async () => {
    const db = makeDbProvider(() => ({
      rows: [{ id: 'biz-3', suggestion_data: null }],
      rowCount: 1,
    }));
    const provider = new EmailIngestionControlProvider(db);
    await provider.recognizeBusiness('tenant-xyz', 'vendor@acme.com');

    const query = db.pool.query as ReturnType<typeof vi.fn>;
    const setConfigCall = query.mock.calls.find(
      ([sql]) => typeof sql === 'string' && sql.includes('set_config'),
    ) as [string, unknown[]] | undefined;
    expect(setConfigCall).toBeDefined();
    expect(setConfigCall![1]).toContain('tenant-xyz');
  });
});

// ---------------------------------------------------------------------------
// recognizeBusinessFromClassification
// ---------------------------------------------------------------------------

describe('EmailIngestionControlProvider.recognizeBusinessFromClassification', () => {
  /** Build a classification carrying just the candidate list under test. */
  function candidates(issuerCandidates: string[]): EmailClassification {
    return { kind: EmailKind.DIRECT, issuerCandidates, forwarder: null, issuerNameHint: null };
  }

  // Mock the businesses lookup to match a single email (case-insensitively),
  // mirroring the real lower()-based SQL.
  function dbMatchingEmail(target: string, row: Record<string, unknown>) {
    return makeDbProvider((sql, params) => {
      if (/from\s+accounter_schema\.businesses/.test(sql.toLowerCase())) {
        const email = (params?.[0] as string | undefined)?.toLowerCase();
        if (email === target.toLowerCase()) {
          return { rows: [row], rowCount: 1 };
        }
      }
      return { rows: [], rowCount: 0 };
    });
  }

  it('recognizes the issuer from a forwarded body candidate when the live From is the forwarder', async () => {
    const db = dbMatchingEmail('noreply@notify.cloudflare.com', {
      id: 'cloudflare-biz',
      suggestion_data: {
        emails: ['noreply@notify.cloudflare.com'],
        emailListener: { emailBody: false, attachments: ['PDF'] },
      },
    });
    const provider = new EmailIngestionControlProvider(db);

    // The classifier has already dropped the forwarder and the tenant's own group,
    // so only the real issuer reaches the lookup.
    const result = await provider.recognizeBusinessFromClassification(
      'tenant-1',
      candidates(['noreply@notify.cloudflare.com']),
    );

    expect(result.businessId).toBe('cloudflare-biz');
    expect(result.config).toEqual({ emailBody: false, attachments: ['PDF'] });
  });

  it('recognizes a business via a wildcard suggestion_data email (unique per-invoice sender)', async () => {
    // The real SQL translates a stored `*@cloudflare.com` pattern into a LIKE
    // match; the mock mirrors that using the shared in-process matcher so the
    // wildcard is what actually drives recognition here.
    const business = {
      id: 'cloudflare-biz',
      suggestion_data: {
        emails: ['*@cloudflare.com'],
        emailListener: { emailBody: false, attachments: ['PDF'] },
      },
    };
    const db = makeDbProvider((sql, params) => {
      if (/from\s+accounter_schema\.businesses/.test(sql.toLowerCase())) {
        const email = params?.[0] as string | undefined;
        if (email && business.suggestion_data.emails.some(p => emailMatchesPattern(p, email))) {
          return { rows: [business], rowCount: 1 };
        }
      }
      return { rows: [], rowCount: 0 };
    });
    const provider = new EmailIngestionControlProvider(db);

    const result = await provider.recognizeBusinessFromClassification(
      'tenant-1',
      candidates(['qr45uf@cloudflare.com']),
    );

    expect(result.businessId).toBe('cloudflare-biz');
    expect(result.config).toEqual({ emailBody: false, attachments: ['PDF'] });
  });

  it('matches case-insensitively', async () => {
    const db = dbMatchingEmail('vendor@acme.com', {
      id: 'biz-1',
      suggestion_data: { emails: ['vendor@acme.com'] },
    });
    const provider = new EmailIngestionControlProvider(db);

    const result = await provider.recognizeBusinessFromClassification(
      'tenant-1',
      candidates(['vendor@acme.com']),
    );

    expect(result.businessId).toBe('biz-1');
  });

  it('returns null when no candidate matches a business', async () => {
    const db = makeDbProvider(() => ({ rows: [], rowCount: 0 }));
    const provider = new EmailIngestionControlProvider(db);

    const result = await provider.recognizeBusinessFromClassification(
      'tenant-1',
      candidates(['nobody@nowhere.com']),
    );

    expect(result).toEqual({ businessId: null, config: {} });
  });

  it('short-circuits without touching the DB when there is no usable evidence', async () => {
    const query = vi.fn();
    const connect = vi.fn();
    const db = { pool: { query, connect } } as unknown as DBProvider;
    const provider = new EmailIngestionControlProvider(db);

    const result = await provider.recognizeBusinessFromClassification('tenant-1', candidates([]));

    expect(result).toEqual({ businessId: null, config: {} });
    expect(query).not.toHaveBeenCalled();
    expect(connect).not.toHaveBeenCalled();
  });
});

// ---------------------------------------------------------------------------
// loadTenantMailContext
// ---------------------------------------------------------------------------

describe('EmailIngestionControlProvider.loadTenantMailContext', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
  afterEach(() => {
    vi.useRealTimers();
  });

  /** Serves the two reads the loader issues: active aliases, then the own business row. */
  function makeContextDb(ownBusiness: Record<string, unknown> | null = null) {
    return makeDbProvider(sql => {
      if (sql.includes('email_ingestion_alias_routing')) {
        return { rows: [{ alias: 'tenant-alias@accounter.tax' }], rowCount: 1 };
      }
      if (sql.includes('accounter_schema.businesses')) {
        return ownBusiness ? { rows: [ownBusiness], rowCount: 1 } : { rows: [], rowCount: 0 };
      }
      return { rows: [], rowCount: 0 };
    });
  }

  it('derives own addresses, names, domains and platform senders', async () => {
    const db = makeContextDb({
      name: 'Acme Ltd',
      hebrew_name: 'אקמה',
      suggestion_data: {
        emails: ['billing@tenant.example'],
        emailIngestion: {
          ownDomains: ['Tenant.Example'],
          extraPlatformSenders: ['relay@platform.example'],
        },
      },
    });
    const provider = new EmailIngestionControlProvider(db);

    const ctx = await provider.loadTenantMailContext('tenant-1');

    expect([...ctx.ownAddresses].sort()).toEqual([
      'billing@tenant.example',
      'tenant-alias@accounter.tax',
    ]);
    expect([...ctx.ownDomains]).toEqual(['tenant.example']);
    expect(ctx.ownNames).toEqual(['Acme Ltd', 'אקמה']);
    expect(ctx.invoicePlatformSenders.has('relay@platform.example')).toBe(true);
    expect(ctx.invoicePlatformSenders.has('notify@morning.co')).toBe(true);
  });

  it('caches within the TTL and refreshes after it', async () => {
    const db = makeContextDb();
    const provider = new EmailIngestionControlProvider(db);
    const query = (db.pool as unknown as { query: ReturnType<typeof vi.fn> }).query;

    await provider.loadTenantMailContext('tenant-1');
    const afterFirst = query.mock.calls.length;

    await provider.loadTenantMailContext('tenant-1');
    expect(query.mock.calls.length).toBe(afterFirst); // served from cache

    vi.advanceTimersByTime(61_000);
    await provider.loadTenantMailContext('tenant-1');
    expect(query.mock.calls.length).toBeGreaterThan(afterFirst);
  });

  // The provider is a process-lifetime singleton, so without a sweep the cache would
  // retain an entry for every tenant that ever received mail.
  it('evicts expired entries so the cache cannot grow without bound', async () => {
    const db = makeContextDb();
    const provider = new EmailIngestionControlProvider(db);
    const cache = (
      provider as unknown as { mailContextCache: Map<string, unknown> }
    ).mailContextCache;

    for (const tenant of ['tenant-1', 'tenant-2', 'tenant-3']) {
      await provider.loadTenantMailContext(tenant);
    }
    expect(cache.size).toBe(3);

    // Every existing entry is now stale; loading a fourth tenant sweeps them out.
    vi.advanceTimersByTime(61_000);
    await provider.loadTenantMailContext('tenant-4');

    expect([...cache.keys()]).toEqual(['tenant-4']);
  });
});
