/**
 * pax8-company-matcher.ts unit tests.
 *
 * postgresClient.query is mocked — similarity() runs in real Postgres and is
 * never exercised here; these tests only assert on the SQL string + bound
 * params issued for each decision branch (auto-link / review / empty
 * candidates / idempotency guard / dryRun), matching the mocking discipline
 * of lib/services/pax8-client.test.ts adapted from fetch to postgresClient.query.
 */

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

// Mock postgresClient BEFORE importing the module under test — the matcher
// imports the default export.
const queryMock = vi.fn();
vi.mock('@/lib/services/postgres-client', () => ({
  default: {
    query: (...args: unknown[]) => queryMock(...args),
  },
}));

// Import AFTER the mock is declared so vi.mock hoisting takes effect.
import { matchPax8Companies } from './pax8-company-matcher';

interface MockCall {
  sql: string;
  params: unknown[];
}

function calls(): MockCall[] {
  return queryMock.mock.calls.map(([sql, params]) => ({
    sql: String(sql),
    params: (params as unknown[]) ?? [],
  }));
}

/** Eligibility SELECT response — a single pax8_companies row under test. */
function eligibleRow(id: string, name: string) {
  return { rows: [{ id, name }], rowCount: 1 };
}

/** findCandidates() response — score rows shaped like the real query output. */
function candidateRows(rows: Array<{ id: string; score: number }>) {
  return {
    rows: rows.map((r) => ({ id: r.id, score: r.score.toFixed(3) })),
    rowCount: rows.length,
  };
}

describe('matchPax8Companies', () => {
  beforeEach(() => {
    queryMock.mockReset();
  });

  it('auto-link: single candidate scoring 0.95 issues an UPDATE against pax8_companies and no review INSERT', async () => {
    queryMock
      .mockResolvedValueOnce(eligibleRow('p1', 'Acme Inc'))
      .mockResolvedValueOnce(candidateRows([{ id: '10', score: 0.95 }]));

    const result = await matchPax8Companies({ limit: 10 });

    expect(result.autoLinked).toBe(1);
    expect(result.flaggedAmbiguous).toBe(0);
    expect(result.flaggedNoCandidate).toBe(0);

    const applyLinkCall = calls().find((c) => /UPDATE pax8_companies/.test(c.sql) && /autotask_company_id\s*=\s*\$2/.test(c.sql));
    expect(applyLinkCall).toBeDefined();
    expect(applyLinkCall!.params).toEqual(['p1', 10, '0.950']);

    const reviewInsert = calls().find((c) => /INSERT INTO pax8_company_match_review/.test(c.sql));
    expect(reviewInsert).toBeUndefined();
  });

  it('review (below threshold): best candidate 0.80 issues an INSERT/UPSERT into pax8_company_match_review and no auto-link UPDATE writing autotask_company_id', async () => {
    queryMock
      .mockResolvedValueOnce(eligibleRow('p2', 'Widgets LLC'))
      .mockResolvedValueOnce(candidateRows([{ id: '20', score: 0.8 }]));

    const result = await matchPax8Companies({ limit: 10 });

    expect(result.flaggedAmbiguous).toBe(1);
    expect(result.autoLinked).toBe(0);

    const reviewInsert = calls().find((c) => /INSERT INTO pax8_company_match_review/.test(c.sql));
    expect(reviewInsert).toBeDefined();
    expect(reviewInsert!.params[0]).toBe('p2');
    expect(reviewInsert!.params[1]).toEqual([20]);

    const autoLinkUpdate = calls().find(
      (c) => /UPDATE pax8_companies/.test(c.sql) && /autotask_company_id\s*=\s*\$2/.test(c.sql)
    );
    expect(autoLinkUpdate).toBeUndefined();
  });

  it('review (near-tie): candidates 0.95 and 0.92 force review even though the top score clears 0.90 (D-02)', async () => {
    queryMock
      .mockResolvedValueOnce(eligibleRow('p3', 'Contoso Corp'))
      .mockResolvedValueOnce(
        candidateRows([
          { id: '30', score: 0.95 },
          { id: '31', score: 0.92 },
        ])
      );

    const result = await matchPax8Companies({ limit: 10 });

    expect(result.flaggedAmbiguous).toBe(1);
    expect(result.autoLinked).toBe(0);

    const reviewInsert = calls().find((c) => /INSERT INTO pax8_company_match_review/.test(c.sql));
    expect(reviewInsert).toBeDefined();
    expect(reviewInsert!.params[1]).toEqual([30, 31]);

    const autoLinkUpdate = calls().find(
      (c) => /UPDATE pax8_companies/.test(c.sql) && /autotask_company_id\s*=\s*\$2/.test(c.sql)
    );
    expect(autoLinkUpdate).toBeUndefined();
  });

  it('empty candidates: zero rows above the floor produce a review row with an empty candidate_company_ids array (D-03)', async () => {
    queryMock
      .mockResolvedValueOnce(eligibleRow('p4', 'No Match Co'))
      .mockResolvedValueOnce(candidateRows([]));

    const result = await matchPax8Companies({ limit: 10 });

    expect(result.flaggedNoCandidate).toBe(1);

    const reviewInsert = calls().find((c) => /INSERT INTO pax8_company_match_review/.test(c.sql));
    expect(reviewInsert).toBeDefined();
    expect(reviewInsert!.params[1]).toEqual([]);
    expect(reviewInsert!.params[2]).toEqual([]);
  });

  it('idempotent (D-05/SC#4): the applyLink UPDATE includes the resolved_at guard and the manual-method guard; the eligibility SELECT excludes manually-resolved rows', async () => {
    queryMock
      .mockResolvedValueOnce(eligibleRow('p5', 'Idempotent Co'))
      .mockResolvedValueOnce(candidateRows([{ id: '50', score: 0.99 }]));

    await matchPax8Companies({ limit: 10 });

    const eligibilitySelect = calls().find((c) => /FROM pax8_companies/.test(c.sql) && /SELECT/.test(c.sql));
    expect(eligibilitySelect).toBeDefined();
    expect(eligibilitySelect!.sql).toContain("match_method IS DISTINCT FROM 'manual'");
    expect(eligibilitySelect!.sql).toContain('resolved_at IS NOT NULL');

    const applyLinkCall = calls().find((c) => /UPDATE pax8_companies/.test(c.sql) && /autotask_company_id\s*=\s*\$2/.test(c.sql));
    expect(applyLinkCall).toBeDefined();
    expect(applyLinkCall!.sql).toContain('resolved_at IS NOT NULL');
    expect(applyLinkCall!.sql).toContain("IS DISTINCT FROM 'manual'");
  });

  it('dryRun: no write queries (UPDATE/INSERT/DELETE) are issued when dryRun is true', async () => {
    queryMock
      .mockResolvedValueOnce(eligibleRow('p6', 'DryRun Co'))
      .mockResolvedValueOnce(candidateRows([{ id: '60', score: 0.99 }]));

    await matchPax8Companies({ limit: 10, dryRun: true });

    const writeCalls = calls().filter((c) => /^\s*(UPDATE|INSERT|DELETE)/i.test(c.sql));
    expect(writeCalls).toHaveLength(0);
  });
});
