/**
 * pax8-sync-service.ts unit tests — first tests for this service.
 *
 * postgresClient.query and pax8-company-matcher's matchPax8Companies are both
 * mocked (following pax8-company-matcher.test.ts's mocking discipline); no
 * real Postgres or PAX8 API calls are made. A mock Pax8Client is injected via
 * the constructor's optional `client` parameter.
 */

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

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

// Mock the Plan 03 matcher so syncCompanyMatches() delegation can be asserted
// without exercising real pg_trgm/Postgres logic.
const matchPax8CompaniesMock = vi.fn();
vi.mock('./pax8-company-matcher', () => ({
  matchPax8Companies: (...args: unknown[]) => matchPax8CompaniesMock(...args),
}));

// Import AFTER the mocks are declared so vi.mock hoisting takes effect.
import { Pax8SyncService } from './pax8-sync-service';
import type { Pax8Client } from './pax8-client';

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

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

/**
 * Two invoice headers, each with one item. item-1 (type 'subscription')
 * exercises the default/CONFIRM cost mapping with real live-verified sample
 * values from 12-02-SUMMARY.md; item-2 (type 'one-time') exercises a second
 * observed type — both are CONFIRM per 12-02-SUMMARY.md, so both are expected
 * to use the same default mapping (no DIVERGENCE type was recorded).
 */
function makeMockClient(): Pax8Client {
  const invoiceOneItems = [
    {
      id: 'item-1',
      type: 'subscription',
      companyId: 'company-1',
      subscriptionId: 'sub-1',
      quantity: 9,
      sku: 'MST-NCE-103-C100',
      description: 'Microsoft 365 Business Premium',
      startPeriod: '2026-06-03',
      endPeriod: '2026-07-02',
      price: 26.4,
      amountDue: 199.58,
      cost: 22.176,
      costTotal: 199.58,
      productId: 'prod-1',
      currencyCode: 'USD',
    },
  ];
  const invoiceTwoItems = [
    {
      id: 'item-2',
      type: 'one-time',
      companyId: 'company-2',
      subscriptionId: null,
      quantity: 1,
      sku: 'ONE-TIME-SKU',
      description: 'Setup fee',
      startPeriod: null,
      endPeriod: null,
      price: 0.02,
      amountDue: 0.81,
      cost: 0.0182,
      costTotal: 0.81,
      productId: 'prod-2',
      currencyCode: 'USD',
    },
  ];

  const client = {
    listAllCompanies: vi.fn().mockResolvedValue([]),
    listAllSubscriptions: vi.fn().mockResolvedValue([]),
    listAllProducts: vi.fn().mockResolvedValue([]),
    listAllInvoices: vi.fn().mockResolvedValue([
      { id: 'inv-1', invoiceDate: '2026-06-01', total: 237.6, status: 'PAID', currencyCode: 'USD' },
      { id: 'inv-2', invoiceDate: '2026-07-01', total: 0.81, status: 'PAID', currencyCode: 'USD' },
    ]),
    listAllInvoiceItems: vi.fn((invoiceId: string) => {
      if (invoiceId === 'inv-1') return Promise.resolve(invoiceOneItems);
      if (invoiceId === 'inv-2') return Promise.resolve(invoiceTwoItems);
      return Promise.resolve([]);
    }),
  };

  return client as unknown as Pax8Client;
}

describe('Pax8SyncService — syncOrders + syncCompanyMatches', () => {
  beforeEach(() => {
    queryMock.mockReset();
    queryMock.mockResolvedValue({ rows: [], rowCount: 0 });
    matchPax8CompaniesMock.mockReset();
    matchPax8CompaniesMock.mockResolvedValue({
      scanned: 2,
      autoLinked: 1,
      flaggedAmbiguous: 1,
      flaggedNoCandidate: 0,
      durationMs: 1,
    });
  });

  it('upserts invoice headers into pax8_orders and items into pax8_order_items, binding companyId into pax8_company_id and amountDue into line_total', async () => {
    const service = new Pax8SyncService(makeMockClient());
    await service.fullSync('test');

    const orderInserts = calls().filter((c) => /INSERT INTO pax8_orders/.test(c.sql));
    expect(orderInserts).toHaveLength(2);
    expect(orderInserts.map((c) => c.params[0])).toEqual(['inv-1', 'inv-2']);
    // Header pax8_company_id stays NULL (Pitfall 1) — bound as the literal
    // NULL in the VALUES clause, not a parameter, so no company id param leaks
    // into the header insert's params array.
    expect(orderInserts[0].sql).toMatch(/VALUES \(\$1, NULL/);

    const itemInserts = calls().filter((c) => /INSERT INTO pax8_order_items/.test(c.sql));
    expect(itemInserts).toHaveLength(2);

    const item1Insert = itemInserts.find((c) => c.params[0] === 'item-1');
    expect(item1Insert).toBeDefined();
    expect(item1Insert!.params).toContain('company-1'); // pax8_company_id
    expect(item1Insert!.params).toContain(199.58); // line_total <- amountDue (default mapping)
  });

  it('tombstones unseen order_items before orders via id <> ALL($1::uuid[])', async () => {
    const service = new Pax8SyncService(makeMockClient());
    await service.fullSync('test');

    const itemTombstone = calls().find(
      (c) => /UPDATE pax8_order_items/.test(c.sql) && /id\s*<>\s*ALL/.test(c.sql)
    );
    expect(itemTombstone).toBeDefined();
    expect(itemTombstone!.params[0]).toEqual(['item-1', 'item-2']);

    const orderTombstone = calls().find(
      (c) => /UPDATE pax8_orders/.test(c.sql) && /id\s*<>\s*ALL/.test(c.sql)
    );
    expect(orderTombstone).toBeDefined();
    expect(orderTombstone!.params[0]).toEqual(['inv-1', 'inv-2']);

    // Child-then-parent ordering (FK safety).
    const allCalls = calls();
    const itemTombstoneIndex = allCalls.findIndex(
      (c) => /UPDATE pax8_order_items/.test(c.sql) && /id\s*<>\s*ALL/.test(c.sql)
    );
    const orderTombstoneIndex = allCalls.findIndex(
      (c) => /UPDATE pax8_orders/.test(c.sql) && /id\s*<>\s*ALL/.test(c.sql)
    );
    expect(itemTombstoneIndex).toBeLessThan(orderTombstoneIndex);
  });

  it('resolveCostColumns applies the CONFIRM default mapping for both observed item types (subscription, one-time)', async () => {
    const service = new Pax8SyncService(makeMockClient());
    await service.fullSync('test');

    const itemInserts = calls().filter((c) => /INSERT INTO pax8_order_items/.test(c.sql));

    const item1Insert = itemInserts.find((c) => c.params[0] === 'item-1');
    // subscription: unit_price<-price(26.4), line_total<-amountDue(199.58),
    // partner_cost<-cost(22.176), partner_cost_total<-costTotal(199.58).
    expect(item1Insert!.params).toEqual(expect.arrayContaining([26.4, 199.58, 22.176]));

    const item2Insert = itemInserts.find((c) => c.params[0] === 'item-2');
    // one-time: unit_price<-price(0.02), line_total<-amountDue(0.81),
    // partner_cost<-cost(0.0182), partner_cost_total<-costTotal(0.81) — both
    // CONFIRM per 12-02-SUMMARY.md, no divergence recorded for either type.
    expect(item2Insert!.params).toEqual(expect.arrayContaining([0.02, 0.81, 0.0182]));
  });

  it('syncCompanyMatches delegates to matchPax8Companies and reports entity company_matches', async () => {
    const service = new Pax8SyncService(makeMockClient());
    const result = await service.fullSync('test');

    expect(matchPax8CompaniesMock).toHaveBeenCalledTimes(1);
    const matchEntity = result.entities.find((e) => e.entity === 'company_matches');
    expect(matchEntity).toBeDefined();
    expect(matchEntity!.success).toBe(true);
    expect(matchEntity!.upserted).toBe(1); // autoLinked
    // No soft-deletes are performed by matching (CR-02) — tombstoned must
    // always be 0 so sync_history.records_deleted never fabricates a
    // "deleted" count for review-flagged companies.
    expect(matchEntity!.tombstoned).toBe(0);
    expect(matchEntity!.flaggedForReview).toBe(1); // flaggedAmbiguous + flaggedNoCandidate
  });

  it("fullSync's entities array includes an 'orders' result and a 'company_matches' result", async () => {
    const service = new Pax8SyncService(makeMockClient());
    const result = await service.fullSync('test');

    const entityNames = result.entities.map((e) => e.entity);
    expect(entityNames).toContain('orders');
    expect(entityNames).toContain('company_matches');

    const ordersEntity = result.entities.find((e) => e.entity === 'orders');
    expect(ordersEntity!.success).toBe(true);
    expect(ordersEntity!.upserted).toBe(4); // 2 orders + 2 items
  });
});
