/**
 * Unit tests for the instruct-measure service.
 *
 * These tests exercise the orchestration logic — DB transaction +
 * post-commit HubSpot push — by injecting lightweight fakes for the DB hooks.
 * The tests never touch the real DB or HubSpot client.
 */
import { describe, expect, it, vi } from "vitest";
import {
  INSTRUCTED_MEASURES_PROP,
  PROPOSED_MEASURES_PROP,
  APPROVED_MEASURES_PROP,
  instructMeasure,
  instructMeasures,
} from "./instructMeasure";
import type {
  InstructTxOutcome,
  InstructMeasuresTxOutcome,
  RunInstructTx,
  RunInstructMeasuresTx,
  ReadInstructedMeasureNames,
  StampPushedAt,
  SyncMeasuresField,
} from "./instructMeasure";

function makeDeps(overrides?: {
  txOutcome?: Partial<InstructTxOutcome>;
  txError?: Error;
  instructedAfter?: string[];
  syncResults?: Array<
    { ok: true } | { ok: false; error: string }
  >;
  stampError?: Error;
}) {
  const txOutcome: InstructTxOutcome = {
    instructedRowId: 42n,
    existingProposedMeasures: [],
    allApprovedMeasureNames: [],
    ...overrides?.txOutcome,
  };
  const runInstructTx: RunInstructTx = vi.fn(async () => {
    if (overrides?.txError) throw overrides.txError;
    return txOutcome;
  });
  const readInstructedMeasureNames: ReadInstructedMeasureNames = vi.fn(
    async () => overrides?.instructedAfter ?? ["ASHP"],
  );
  const syncQueue: Array<{ ok: true } | { ok: false; error: string }> =
    overrides?.syncResults ?? [{ ok: true }, { ok: true }, { ok: true }];
  const syncMeasuresField: SyncMeasuresField = vi.fn(async () => {
    return syncQueue.shift() ?? ({ ok: true } as const);
  });
  const stampPushedAt: StampPushedAt = vi.fn(async () => {
    if (overrides?.stampError) throw overrides.stampError;
  });
  return {
    runInstructTx,
    readInstructedMeasureNames,
    syncMeasuresField,
    stampPushedAt,
  };
}

describe("instructMeasure — input validation", () => {
  it("rejects an unknown measure name without touching the DB or HubSpot", async () => {
    const deps = makeDeps();
    const result = await instructMeasure({
      dealId: "deal-1",
      measureName: "Not a real measure",
      userId: 1n,
      deps,
    });
    expect(result).toEqual({
      ok: false,
      error: "Unknown measure: Not a real measure",
    });
    expect(deps.runInstructTx).not.toHaveBeenCalled();
    expect(deps.syncMeasuresField).not.toHaveBeenCalled();
    expect(deps.stampPushedAt).not.toHaveBeenCalled();
  });

  it("rejects an empty measure name", async () => {
    const deps = makeDeps();
    const result = await instructMeasure({
      dealId: "deal-1",
      measureName: "   ",
      userId: 1n,
      deps,
    });
    expect(result.ok).toBe(false);
    expect(deps.runInstructTx).not.toHaveBeenCalled();
  });
});

describe("instructMeasure — happy path", () => {
  it("commits tx, pushes instructed + proposed + approved, stamps pushed_at", async () => {
    const deps = makeDeps({
      instructedAfter: ["ASHP", "Solar PV"],
      txOutcome: {
        instructedRowId: 99n,
        existingProposedMeasures: ["ASHP"],
        allApprovedMeasureNames: ["ASHP", "Solar PV"],
      },
    });
    const result = await instructMeasure({
      dealId: "deal-42",
      measureName: "Solar PV",
      userId: 7n,
      deps,
    });
    expect(result).toMatchObject({
      ok: true,
      instructedRowId: 99n,
      hubspotSync: "ok",
    });
    expect(deps.runInstructTx).toHaveBeenCalledWith({
      dealId: "deal-42",
      measureName: "Solar PV",
      userId: 7n,
      notes: null,
    });
    expect(deps.syncMeasuresField).toHaveBeenCalledTimes(3);
    expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(1, {
      hubspotDealId: "deal-42",
      propName: INSTRUCTED_MEASURES_PROP,
      measureNames: ["ASHP", "Solar PV"],
    });
    expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
      hubspotDealId: "deal-42",
      propName: PROPOSED_MEASURES_PROP,
      measureNames: ["ASHP", "Solar PV"],
    });
    expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(3, {
      hubspotDealId: "deal-42",
      propName: APPROVED_MEASURES_PROP,
      measureNames: ["ASHP", "Solar PV"],
    });
    expect(deps.stampPushedAt).toHaveBeenCalledWith(99n);
  });

  it("merges new measure into existing proposed (deduped)", async () => {
    const deps = makeDeps({
      instructedAfter: ["ASHP", "EWI"],
      txOutcome: {
        instructedRowId: 1n,
        existingProposedMeasures: ["ASHP", "Solar PV"],
        allApprovedMeasureNames: ["ASHP", "EWI"],
      },
    });
    await instructMeasure({
      dealId: "deal-merge",
      measureName: "EWI",
      userId: 3n,
      deps,
    });
    expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
      hubspotDealId: "deal-merge",
      propName: PROPOSED_MEASURES_PROP,
      measureNames: ["ASHP", "Solar PV", "EWI"],
    });
  });

  it("adds to proposed even when deal already had proposed measures", async () => {
    const deps = makeDeps({
      instructedAfter: ["EWI"],
      txOutcome: {
        instructedRowId: 1n,
        existingProposedMeasures: ["ASHP", "Solar PV"],
        allApprovedMeasureNames: ["EWI"],
      },
    });
    const result = await instructMeasure({
      dealId: "deal-with-proposed",
      measureName: "EWI",
      userId: 3n,
      deps,
    });
    expect(result.ok).toBe(true);
    expect(deps.syncMeasuresField).toHaveBeenCalledTimes(3);
    expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2,
      expect.objectContaining({
        propName: PROPOSED_MEASURES_PROP,
        measureNames: ["ASHP", "Solar PV", "EWI"],
      }),
    );
  });

  it("adds to proposed when deal has no existing proposed measures", async () => {
    const deps = makeDeps({
      instructedAfter: ["EWI"],
      txOutcome: {
        instructedRowId: 1n,
        existingProposedMeasures: [],
        allApprovedMeasureNames: ["EWI"],
      },
    });
    await instructMeasure({
      dealId: "deal-blank",
      measureName: "EWI",
      userId: 3n,
      deps,
    });
    expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
      hubspotDealId: "deal-blank",
      propName: PROPOSED_MEASURES_PROP,
      measureNames: ["EWI"],
    });
  });
});

describe("instructMeasure — DB transaction failure", () => {
  it("returns an error and skips HubSpot when the tx throws", async () => {
    const deps = makeDeps({ txError: new Error("insert failed") });
    const result = await instructMeasure({
      dealId: "deal-x",
      measureName: "ASHP",
      userId: 1n,
      deps,
    });
    expect(result).toEqual({ ok: false, error: "insert failed" });
    expect(deps.syncMeasuresField).not.toHaveBeenCalled();
    expect(deps.readInstructedMeasureNames).not.toHaveBeenCalled();
    expect(deps.stampPushedAt).not.toHaveBeenCalled();
  });
});

describe("instructMeasure — HubSpot push failure leaves DB committed", () => {
  it("returns ok=true with hubspotSync=failed when instructed push fails, does NOT stamp", async () => {
    const deps = makeDeps({
      instructedAfter: ["ASHP"],
      txOutcome: {
        instructedRowId: 11n,
        existingProposedMeasures: [],
        allApprovedMeasureNames: ["ASHP"],
      },
      syncResults: [
        { ok: false, error: "hubspot 500" },
        { ok: true },
        { ok: true },
      ],
    });
    const result = await instructMeasure({
      dealId: "deal-h",
      measureName: "ASHP",
      userId: 1n,
      deps,
    });
    expect(result).toMatchObject({
      ok: true,
      instructedRowId: 11n,
      hubspotSync: "failed",
      hubspotError: "hubspot 500",
    });
    expect(deps.stampPushedAt).not.toHaveBeenCalled();
  });

  it("returns hubspotSync=failed when proposed push fails", async () => {
    const deps = makeDeps({
      instructedAfter: ["EWI"],
      txOutcome: {
        instructedRowId: 12n,
        existingProposedMeasures: [],
        allApprovedMeasureNames: ["EWI"],
      },
      syncResults: [
        { ok: true },
        { ok: false, error: "proposed push failed" },
        { ok: true },
      ],
    });
    const result = await instructMeasure({
      dealId: "deal-blank",
      measureName: "EWI",
      userId: 3n,
      deps,
    });
    expect(result).toMatchObject({
      ok: true,
      hubspotSync: "failed",
      hubspotError: "proposed push failed",
    });
    expect(deps.stampPushedAt).not.toHaveBeenCalled();
  });

  it("returns hubspotSync=failed when approved push fails", async () => {
    const deps = makeDeps({
      instructedAfter: ["EWI"],
      txOutcome: {
        instructedRowId: 13n,
        existingProposedMeasures: [],
        allApprovedMeasureNames: ["EWI"],
      },
      syncResults: [
        { ok: true },
        { ok: true },
        { ok: false, error: "approved push failed" },
      ],
    });
    const result = await instructMeasure({
      dealId: "deal-blank",
      measureName: "EWI",
      userId: 3n,
      deps,
    });
    expect(result).toMatchObject({
      ok: true,
      hubspotSync: "failed",
      hubspotError: "approved push failed",
    });
    expect(deps.stampPushedAt).not.toHaveBeenCalled();
  });
});

// ---------------------------------------------------------------------------
// instructMeasures (plural) — batch variant
// ---------------------------------------------------------------------------

function makeBatchDeps(overrides?: {
  txOutcome?: Partial<InstructMeasuresTxOutcome>;
  txError?: Error;
  instructedAfter?: string[];
  syncResults?: Array<{ ok: true } | { ok: false; error: string }>;
  stampError?: Error;
}) {
  const txOutcome: InstructMeasuresTxOutcome = {
    instructedRowIds: [1n, 2n],
    existingProposedMeasures: [],
    allApprovedMeasureNames: [],
    ...overrides?.txOutcome,
  };
  const runInstructMeasuresTx: RunInstructMeasuresTx = vi.fn(async () => {
    if (overrides?.txError) throw overrides.txError;
    return txOutcome;
  });
  const readInstructedMeasureNames: ReadInstructedMeasureNames = vi.fn(
    async () => overrides?.instructedAfter ?? ["ASHP", "Solar PV"],
  );
  const syncQueue: Array<{ ok: true } | { ok: false; error: string }> =
    overrides?.syncResults ?? [{ ok: true }, { ok: true }, { ok: true }];
  const syncMeasuresField: SyncMeasuresField = vi.fn(async () => {
    return syncQueue.shift() ?? ({ ok: true } as const);
  });
  const stampPushedAt: StampPushedAt = vi.fn(async () => {
    if (overrides?.stampError) throw overrides.stampError;
  });
  return {
    runInstructMeasuresTx,
    readInstructedMeasureNames,
    syncMeasuresField,
    stampPushedAt,
  };
}

describe("instructMeasures — input validation", () => {
  it("rejects when measureNames is empty", async () => {
    const deps = makeBatchDeps();
    const result = await instructMeasures({
      dealId: "deal-1",
      measureNames: [],
      userId: 1n,
      deps,
    });
    expect(result).toEqual({ ok: false, error: "measureNames must not be empty" });
    expect(deps.runInstructMeasuresTx).not.toHaveBeenCalled();
    expect(deps.syncMeasuresField).not.toHaveBeenCalled();
  });

  it("rejects when any measureName is unknown", async () => {
    const deps = makeBatchDeps();
    const result = await instructMeasures({
      dealId: "deal-1",
      measureNames: ["ASHP", "Not a real measure"],
      userId: 1n,
      deps,
    });
    expect(result).toEqual({ ok: false, error: "Unknown measure: Not a real measure" });
    expect(deps.runInstructMeasuresTx).not.toHaveBeenCalled();
    expect(deps.syncMeasuresField).not.toHaveBeenCalled();
  });
});

describe("instructMeasures — happy path", () => {
  it("commits single tx, pushes instructed + proposed + approved, stamps all rowIds", async () => {
    const deps = makeBatchDeps({
      instructedAfter: ["ASHP", "Solar PV"],
      txOutcome: {
        instructedRowIds: [10n, 11n],
        existingProposedMeasures: [],
        allApprovedMeasureNames: ["ASHP", "Solar PV"],
      },
    });
    const result = await instructMeasures({
      dealId: "deal-42",
      measureNames: ["ASHP", "Solar PV"],
      userId: 7n,
      deps,
    });
    expect(result).toMatchObject({ ok: true, instructedRowIds: [10n, 11n], hubspotSync: "ok" });
    expect(deps.runInstructMeasuresTx).toHaveBeenCalledOnce();
    expect(deps.runInstructMeasuresTx).toHaveBeenCalledWith({
      dealId: "deal-42",
      measureNames: ["ASHP", "Solar PV"],
      userId: 7n,
      notes: null,
    });
    expect(deps.syncMeasuresField).toHaveBeenCalledTimes(3);
    expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(1, {
      hubspotDealId: "deal-42",
      propName: INSTRUCTED_MEASURES_PROP,
      measureNames: ["ASHP", "Solar PV"],
    });
    expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
      hubspotDealId: "deal-42",
      propName: PROPOSED_MEASURES_PROP,
      measureNames: ["ASHP", "Solar PV"],
    });
    expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(3, {
      hubspotDealId: "deal-42",
      propName: APPROVED_MEASURES_PROP,
      measureNames: ["ASHP", "Solar PV"],
    });
    expect(deps.stampPushedAt).toHaveBeenCalledTimes(2);
    expect(deps.stampPushedAt).toHaveBeenCalledWith(10n);
    expect(deps.stampPushedAt).toHaveBeenCalledWith(11n);
  });

  it("merges all new measures into existing proposed (deduped)", async () => {
    const deps = makeBatchDeps({
      instructedAfter: ["ASHP", "EWI", "Solar PV"],
      txOutcome: {
        instructedRowIds: [20n, 21n],
        existingProposedMeasures: ["ASHP", "Loft insulation"],
        allApprovedMeasureNames: ["ASHP", "EWI", "Solar PV"],
      },
    });
    await instructMeasures({
      dealId: "deal-merge",
      measureNames: ["EWI", "Solar PV"],
      userId: 3n,
      deps,
    });
    expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, {
      hubspotDealId: "deal-merge",
      propName: PROPOSED_MEASURES_PROP,
      measureNames: ["ASHP", "Loft insulation", "EWI", "Solar PV"],
    });
  });
});

describe("instructMeasures — DB transaction failure", () => {
  it("returns error and skips HubSpot when tx throws", async () => {
    const deps = makeBatchDeps({ txError: new Error("batch insert failed") });
    const result = await instructMeasures({
      dealId: "deal-x",
      measureNames: ["ASHP", "EWI"],
      userId: 1n,
      deps,
    });
    expect(result).toEqual({ ok: false, error: "batch insert failed" });
    expect(deps.syncMeasuresField).not.toHaveBeenCalled();
    expect(deps.stampPushedAt).not.toHaveBeenCalled();
  });
});

describe("instructMeasures — HubSpot push failure leaves DB committed", () => {
  it("returns ok=true with hubspotSync=failed when sync fails, does NOT stamp", async () => {
    const deps = makeBatchDeps({
      instructedAfter: ["ASHP", "EWI"],
      txOutcome: {
        instructedRowIds: [30n, 31n],
        existingProposedMeasures: [],
        allApprovedMeasureNames: ["ASHP", "EWI"],
      },
      syncResults: [{ ok: false, error: "hubspot 500" }, { ok: true }, { ok: true }],
    });
    const result = await instructMeasures({
      dealId: "deal-h",
      measureNames: ["ASHP", "EWI"],
      userId: 1n,
      deps,
    });
    expect(result).toMatchObject({
      ok: true,
      hubspotSync: "failed",
      hubspotError: "hubspot 500",
    });
    expect(deps.stampPushedAt).not.toHaveBeenCalled();
  });
});
