/**
 * Tests for src/modules/cache/opportunityCache.ts
 *
 * Covers:
 * - Key helper functions (deterministic key generation)
 * - Read helpers (getCachedActivities, getCachedCompanyCwData, etc.)
 * - Write helpers (fetchAndCacheActivities, fetchAndCacheNotes, etc.)
 */

import { describe, test, expect, mock, beforeEach } from "bun:test";
import { buildMockConstants } from "../setup";

// ---------------------------------------------------------------------------
// Set up mocks before importing the module
// ---------------------------------------------------------------------------

const mockRedisGet = mock(() => Promise.resolve(null));
const mockRedisSet = mock(() => Promise.resolve("OK"));
const mockRedisDel = mock(() => Promise.resolve(1));

const mockFetchByOpportunityDirect = mock(() => Promise.resolve([]));
const mockFetchNotes = mock(() => Promise.resolve([]));
const mockFetchContacts = mock(() => Promise.resolve([]));

mock.module("../../src/constants", () =>
  buildMockConstants({
    redis: {
      get: mockRedisGet,
      set: mockRedisSet,
      del: mockRedisDel,
    },
  }),
);

mock.module("../../src/modules/cw-utils/activities/activities", () => ({
  activityCw: {
    fetchByOpportunityDirect: mockFetchByOpportunityDirect,
  },
}));

mock.module("../../src/modules/cw-utils/opportunities/opportunities", () => ({
  opportunityCw: {
    fetchNotes: mockFetchNotes,
    fetchContacts: mockFetchContacts,
  },
}));

mock.module("../../src/modules/cw-utils/fetchCompany", () => ({
  fetchCwCompanyById: mock(() => Promise.resolve(null)),
}));

mock.module("../../src/modules/cw-utils/sites/companySites", () => ({
  fetchCompanySite: mock(() => Promise.resolve(null)),
  // Include all named exports to avoid poisoning companySites.test.ts
  // which statically imports serializeCwSite and CWCompanySite.
  fetchCompanySites: mock(() => Promise.resolve([])),
  serializeCwSite: (site: any) => ({
    id: site?.id,
    name: site?.name,
    address: {
      line1: site?.addressLine1,
      line2: site?.addressLine2 ?? null,
      city: site?.city,
      state: site?.stateReference?.name ?? null,
      zip: site?.zip,
      country: site?.country?.name ?? "United States",
    },
    phoneNumber: site?.phoneNumber || null,
    faxNumber: site?.faxNumber || null,
    primaryAddressFlag: site?.primaryAddressFlag,
    defaultShippingFlag: site?.defaultShippingFlag,
    defaultBillingFlag: site?.defaultBillingFlag,
    defaultMailingFlag: site?.defaultMailingFlag,
  }),
}));

mock.module("../../src/modules/globalEvents", () => ({
  events: { emit: mock(), on: mock() },
  setupEventDebugger: mock(),
}));

// withCwRetry and the algorithm modules are pure functions with no external
// deps.  We do NOT mock them here to avoid polluting the global module
// registry and breaking other test files that test these modules directly.
// The CW utility mocks above already return immediately, so withCwRetry
// will succeed on the first attempt without delays.

// ---------------------------------------------------------------------------
// Import AFTER mocks
// ---------------------------------------------------------------------------

import {
  activityCacheKey,
  companyCwCacheKey,
  notesCacheKey,
  contactsCacheKey,
  productsCacheKey,
  siteCacheKey,
  oppCwDataCacheKey,
  getCachedActivities,
  getCachedCompanyCwData,
  getCachedNotes,
  getCachedContacts,
  getCachedProducts,
  getCachedSite,
  getCachedOppCwData,
  fetchAndCacheActivities,
  fetchAndCacheNotes,
} from "../../src/modules/cache/opportunityCache";

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

beforeEach(() => {
  mockRedisGet.mockReset();
  mockRedisGet.mockImplementation(() => Promise.resolve(null));
  mockRedisSet.mockReset();
  mockRedisSet.mockImplementation(() => Promise.resolve("OK"));
  mockFetchByOpportunityDirect.mockReset();
  mockFetchByOpportunityDirect.mockImplementation(() => Promise.resolve([]));
  mockFetchNotes.mockReset();
  mockFetchNotes.mockImplementation(() => Promise.resolve([]));
});

// ═══════════════════════════════════════════════════════════════════════════
// KEY HELPERS
// ═══════════════════════════════════════════════════════════════════════════

describe("Cache key helpers", () => {
  test("activityCacheKey", () => {
    expect(activityCacheKey(1001)).toBe("opp:activities:1001");
  });

  test("companyCwCacheKey", () => {
    expect(companyCwCacheKey(123)).toBe("opp:company-cw:123");
  });

  test("notesCacheKey", () => {
    expect(notesCacheKey(1001)).toBe("opp:notes:1001");
  });

  test("contactsCacheKey", () => {
    expect(contactsCacheKey(1001)).toBe("opp:contacts:1001");
  });

  test("productsCacheKey", () => {
    expect(productsCacheKey(1001)).toBe("opp:products:1001");
  });

  test("siteCacheKey", () => {
    expect(siteCacheKey(123, 456)).toBe("opp:site:123:456");
  });

  test("oppCwDataCacheKey", () => {
    expect(oppCwDataCacheKey(1001)).toBe("opp:cw-data:1001");
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// READ HELPERS
// ═══════════════════════════════════════════════════════════════════════════

describe("getCachedActivities", () => {
  test("returns null on cache miss", async () => {
    mockRedisGet.mockResolvedValueOnce(null);
    const result = await getCachedActivities(1001);
    expect(result).toBeNull();
  });

  test("returns parsed array on cache hit", async () => {
    const activities = [{ id: 1 }, { id: 2 }];
    mockRedisGet.mockResolvedValueOnce(JSON.stringify(activities));
    const result = await getCachedActivities(1001);
    expect(result).toEqual(activities);
  });

  test("returns null on invalid JSON", async () => {
    mockRedisGet.mockResolvedValueOnce("not valid json{{{");
    const result = await getCachedActivities(1001);
    expect(result).toBeNull();
  });
});

describe("getCachedCompanyCwData", () => {
  test("returns null on cache miss", async () => {
    mockRedisGet.mockResolvedValueOnce(null);
    const result = await getCachedCompanyCwData(123);
    expect(result).toBeNull();
  });

  test("returns parsed blob on cache hit", async () => {
    const blob = {
      company: { id: 123 },
      defaultContact: { id: 1 },
      allContacts: [{ id: 1 }, { id: 2 }],
    };
    mockRedisGet.mockResolvedValueOnce(JSON.stringify(blob));
    const result = await getCachedCompanyCwData(123);
    expect(result).toEqual(blob);
  });
});

describe("getCachedNotes", () => {
  test("returns null on cache miss", async () => {
    const result = await getCachedNotes(1001);
    expect(result).toBeNull();
  });

  test("returns parsed array on hit", async () => {
    const notes = [{ id: 1, text: "Hello" }];
    mockRedisGet.mockResolvedValueOnce(JSON.stringify(notes));
    const result = await getCachedNotes(1001);
    expect(result).toEqual(notes);
  });
});

describe("getCachedContacts", () => {
  test("returns null on cache miss", async () => {
    const result = await getCachedContacts(1001);
    expect(result).toBeNull();
  });

  test("returns parsed array on hit", async () => {
    const contacts = [{ id: 1 }];
    mockRedisGet.mockResolvedValueOnce(JSON.stringify(contacts));
    const result = await getCachedContacts(1001);
    expect(result).toEqual(contacts);
  });
});

describe("getCachedProducts", () => {
  test("returns null on cache miss", async () => {
    const result = await getCachedProducts(1001);
    expect(result).toBeNull();
  });

  test("returns parsed blob on hit", async () => {
    const products = { forecast: [], procProducts: [] };
    mockRedisGet.mockResolvedValueOnce(JSON.stringify(products));
    const result = await getCachedProducts(1001);
    expect(result).toEqual(products);
  });
});

describe("getCachedSite", () => {
  test("returns null on cache miss", async () => {
    const result = await getCachedSite(123, 456);
    expect(result).toBeNull();
  });

  test("returns parsed data on hit", async () => {
    const site = { id: 456, name: "Main" };
    mockRedisGet.mockResolvedValueOnce(JSON.stringify(site));
    const result = await getCachedSite(123, 456);
    expect(result).toEqual(site);
  });
});

describe("getCachedOppCwData", () => {
  test("returns null on cache miss", async () => {
    const result = await getCachedOppCwData(1001);
    expect(result).toBeNull();
  });

  test("returns parsed data on hit", async () => {
    const data = { id: 1001, name: "Opp" };
    mockRedisGet.mockResolvedValueOnce(JSON.stringify(data));
    const result = await getCachedOppCwData(1001);
    expect(result).toEqual(data);
  });
});

// ═══════════════════════════════════════════════════════════════════════════
// WRITE HELPERS
// ═══════════════════════════════════════════════════════════════════════════

describe("fetchAndCacheActivities", () => {
  test("fetches from CW, caches, and returns the array", async () => {
    const activities = [{ id: 1 }, { id: 2 }];
    mockFetchByOpportunityDirect.mockResolvedValueOnce(activities);

    const result = await fetchAndCacheActivities(1001, 60_000);
    expect(result).toEqual(activities);
    expect(mockRedisSet).toHaveBeenCalledTimes(1);

    const [key, value, px, ttl] = mockRedisSet.mock.calls[0] as any[];
    expect(key).toBe("opp:activities:1001");
    expect(JSON.parse(value)).toEqual(activities);
    expect(px).toBe("PX");
    expect(ttl).toBe(60_000);
  });

  test("returns empty array on 404", async () => {
    const err404: any = new Error("Not found");
    err404.isAxiosError = true;
    err404.response = { status: 404 };
    mockFetchByOpportunityDirect.mockRejectedValueOnce(err404);

    const result = await fetchAndCacheActivities(1001, 60_000);
    expect(result).toEqual([]);
  });

  test("returns empty array on transient error", async () => {
    const errTransient: any = new Error("timeout");
    errTransient.isAxiosError = true;
    errTransient.code = "ECONNABORTED";
    mockFetchByOpportunityDirect.mockRejectedValueOnce(errTransient);

    const result = await fetchAndCacheActivities(1001, 60_000);
    expect(result).toEqual([]);
  });

  test("re-throws non-transient non-404 errors", async () => {
    mockFetchByOpportunityDirect.mockRejectedValueOnce(new Error("Unexpected"));

    await expect(fetchAndCacheActivities(1001, 60_000)).rejects.toThrow(
      "Unexpected",
    );
  });
});

describe("fetchAndCacheNotes", () => {
  test("fetches from CW, caches, and returns the array", async () => {
    const notes = [{ id: 1, text: "Note 1" }];
    mockFetchNotes.mockResolvedValueOnce(notes);

    const result = await fetchAndCacheNotes(1001, 60_000);
    expect(result).toEqual(notes);
    expect(mockRedisSet).toHaveBeenCalledTimes(1);
  });

  test("returns empty array on 404", async () => {
    const err404: any = new Error("Not found");
    err404.isAxiosError = true;
    err404.response = { status: 404 };
    mockFetchNotes.mockRejectedValueOnce(err404);

    const result = await fetchAndCacheNotes(1001, 60_000);
    expect(result).toEqual([]);
  });
});
