import { describe, it, expect, vi, beforeEach } from "vitest";
import type * as DrizzleOrm from "drizzle-orm";

vi.mock("~/database/context", () => ({
  database: vi.fn(),
}));

vi.mock("~/models/scoring-calculator", () => ({
  processQualifyingEvent: vi.fn(),
}));

import { syncTournamentResults } from "../sync-tournament-results";
import { database } from "~/database/context";
import { processQualifyingEvent } from "~/models/scoring-calculator";

// ---------------------------------------------------------------------------
// Mock data types
// ---------------------------------------------------------------------------

interface FakeTournamentResult {
  tournamentId: string;
  participantId: string;
  placement: number | null;
  rawScore: string | null;
}

interface FakeScoringEvent {
  id: string;
  sportsSeasonId: string;
  tournamentId: string | null;
}

interface FakeSeasonParticipant {
  id: string;
  sportsSeasonId: string;
  participantId: string | null;
  name: string;
}

interface FakeEventResult {
  id: string;
  scoringEventId: string;
  seasonParticipantId: string;
  placement: number | null;
  qualifyingPointsAwarded: string | null;
  rawScore: string | null;
}

// ---------------------------------------------------------------------------
// Fake in-memory DB
//
// We build a single shared object with a chainable select/insert/update API
// that the service uses identically whether it's the top-level `db` or the
// `tx` inside a transaction. This lets us both observe the writes that would
// have happened and emulate idempotent upserts.
//
// Internal state holds the three tables the service queries:
//   - tournament_results
//   - scoring_events
//   - season_participants
//   - event_results
// ---------------------------------------------------------------------------

interface FakeDbState {
  tournamentResults: FakeTournamentResult[];
  scoringEvents: FakeScoringEvent[];
  seasonParticipants: FakeSeasonParticipant[];
  eventResults: FakeEventResult[];
}

/**
 * A very thin fake drizzle-ish db. The service only uses:
 *   - db.select().from(table).where(cond)            -- returns rows
 *   - db.insert(table).values(row[|rows])            -- insert
 *   - db.update(table).set(...).where(cond)          -- update
 *   - db.transaction(fn)                             -- runs fn with the same db
 *
 * We identify the target table by reference equality to the schema objects.
 * The service passes schema.tournamentResults etc., so we capture the real
 * schema module and compare by ref.
 */

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function tableName(table: any): string {
  // drizzle-orm PgTable exposes the table name under a symbol. Try a few.
  const sym = Object.getOwnPropertySymbols(table).find(
    (s) => s.toString() === "Symbol(drizzle:Name)"
  );
  if (sym) return table[sym];
  // Fallback for our shim: read `.tableName` property if set.
  return table.tableName;
}

function makeFakeDb(state: FakeDbState) {
  // We mirror the shape in a lazy way: each predicate function is stored on the
  // call so we can match rows. In practice the service only filters by simple
  // eq() / and() combinations — we don't try to parse drizzle's SQL tree.
  // Instead, we stash the filters the service applies by table.
  //
  // The simpler and more faithful approach: every call records enough info for
  // us to return the right subset.

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  function rowsFor(table: any): any[] {
    // Table objects are drizzle PgTable objects; we identify them by name.
    const name = tableName(table);
    switch (name) {
      case "tournament_results":
        return state.tournamentResults;
      case "scoring_events":
        return state.scoringEvents;
      case "season_participants":
        return state.seasonParticipants;
      case "event_results":
        return state.eventResults;
      default:
        throw new Error(`Unknown table in fake db: ${name}`);
    }
  }

  const db = {
    // ---------- select ----------
    select: vi.fn().mockImplementation(() => {
      const chain = {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        _table: null as any,
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        from(table: any) {
          chain._table = table;
          return chain;
        },
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        where(predicate: any) {
          // The predicate is a drizzle SQL expression; we can't introspect it
          // easily. The fake predicate stash sets a callback (see below). If
          // the predicate is a function we apply it; otherwise we return all
          // rows for the table (the caller will then filter).
          const rows = rowsFor(chain._table);
          if (typeof predicate === "function") {
            return Promise.resolve(rows.filter(predicate));
          }
          // Unknown predicate: the test harness attaches `__fakeFilter` to the
          // predicate value we interposed. When we can't evaluate, just return
          // all rows — the fake is used only by the service and tests that
          // control what rows exist.
          return Promise.resolve(rows);
        },
      };
      return chain;
    }),

    // ---------- insert ----------
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    insert: vi.fn().mockImplementation((table: any) => {
      return {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        values(row: any) {
          const rows = Array.isArray(row) ? row : [row];
          const existing = rowsFor(table);
          for (const r of rows) {
            // Assign a fake id for event_results (others aren't re-read by ID).
            existing.push({ id: `er-${existing.length + 1}`, ...r });
          }
          return {
            returning: vi.fn().mockResolvedValue(rows),
          };
        },
      };
    }),

    // ---------- update ----------
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    update: vi.fn().mockImplementation((table: any) => {
      return {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        set(patch: any) {
          return {
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            where(predicate: any) {
              const rows = rowsFor(table);
              for (const r of rows) {
                if (typeof predicate === "function" ? predicate(r) : true) {
                  Object.assign(r, patch);
                }
              }
              return Promise.resolve();
            },
          };
        },
      };
    }),

    // ---------- transaction ----------
    transaction: vi
      .fn()
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      .mockImplementation(async (fn: (tx: any) => any) => fn(db)),
  };

  return db;
}

// ---------------------------------------------------------------------------
// The service builds drizzle SQL expressions via eq() / and() from
// drizzle-orm. We can't intercept those without re-mocking the whole module.
// Instead we mock drizzle-orm's eq/and to return predicate functions directly.
// ---------------------------------------------------------------------------

vi.mock("drizzle-orm", async () => {
  const actual = await vi.importActual<typeof DrizzleOrm>("drizzle-orm");
  return {
    ...actual,
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    eq: (col: any, val: any) => {
      // Each column object (PgColumn) has a `.name` property giving the DB
      // column name. Return a predicate that reads that key off the row.
      const key = colKey(col);
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      return (row: any) => row[key] === val;
    },
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    and: (...preds: any[]) => (row: any) => preds.every((p) => p(row)),
  };
});

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function colKey(col: any): string {
  // drizzle PgColumn: its .name is the snake_case DB name, but our fake state
  // uses camelCase JS field names. Map common columns manually.
  const n = col?.name ?? col?.config?.name ?? "";
  const map: Record<string, string> = {
    tournament_id: "tournamentId",
    sports_season_id: "sportsSeasonId",
    scoring_event_id: "scoringEventId",
    season_participant_id: "seasonParticipantId",
    participant_id: "participantId",
    id: "id",
  };
  return map[n] ?? n;
}

// ---------------------------------------------------------------------------
// Helpers for building fixture data
// ---------------------------------------------------------------------------

beforeEach(() => {
  vi.clearAllMocks();
});

function seedBasicState(overrides: Partial<FakeDbState> = {}): FakeDbState {
  return {
    tournamentResults: [],
    scoringEvents: [],
    seasonParticipants: [],
    eventResults: [],
    ...overrides,
  };
}

// ---------------------------------------------------------------------------
// Test 1: Single window, full roster match
// ---------------------------------------------------------------------------

describe("syncTournamentResults", () => {
  it("syncs a single window with two canonical results and fills 0-QP for unmatched", async () => {
    const state = seedBasicState({
      tournamentResults: [
        {
          tournamentId: "t-1",
          participantId: "cp-A",
          placement: 1,
          rawScore: "280",
        },
        {
          tournamentId: "t-1",
          participantId: "cp-B",
          placement: 2,
          rawScore: "282",
        },
      ],
      scoringEvents: [
        { id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" },
      ],
      seasonParticipants: [
        {
          id: "sp-A",
          sportsSeasonId: "ss-1",
          participantId: "cp-A",
          name: "A",
        },
        {
          id: "sp-B",
          sportsSeasonId: "ss-1",
          participantId: "cp-B",
          name: "B",
        },
        {
          id: "sp-C",
          sportsSeasonId: "ss-1",
          participantId: "cp-C",
          name: "C",
        },
      ],
    });

    const db = makeFakeDb(state);
    vi.mocked(database).mockReturnValue(db as never);
    vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);

    const report = await syncTournamentResults("t-1");

    expect(report.windowsSynced).toBe(1);
    expect(report.windowsFailed).toBe(0);
    expect(report.failures).toEqual([]);

    const eventRows = state.eventResults.filter(
      (r) => r.scoringEventId === "ev-1"
    );
    // sp-A: placement 1, sp-B: placement 2, sp-C: 0-QP filler
    expect(eventRows).toHaveLength(3);

    const a = eventRows.find((r) => r.seasonParticipantId === "sp-A");
    expect(a?.placement).toBe(1);
    expect(a?.rawScore).toBe("280");

    const b = eventRows.find((r) => r.seasonParticipantId === "sp-B");
    expect(b?.placement).toBe(2);
    expect(b?.rawScore).toBe("282");

    const c = eventRows.find((r) => r.seasonParticipantId === "sp-C");
    expect(c?.placement).toBeNull();
    expect(c?.qualifyingPointsAwarded).toBe("0");

    expect(processQualifyingEvent).toHaveBeenCalledTimes(1);
    expect(processQualifyingEvent).toHaveBeenCalledWith("ev-1", db);
  });

  // -------------------------------------------------------------------------
  // Test 2: Two overlapping windows — same tournament, different sports_seasons
  // -------------------------------------------------------------------------

  it("fans out to multiple windows, each getting only its own participants", async () => {
    const state = seedBasicState({
      tournamentResults: [
        { tournamentId: "t-1", participantId: "cp-X", placement: 1, rawScore: null },
        { tournamentId: "t-1", participantId: "cp-Y", placement: 2, rawScore: null },
        { tournamentId: "t-1", participantId: "cp-Z", placement: 3, rawScore: null },
      ],
      scoringEvents: [
        { id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" },
        { id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1" },
      ],
      seasonParticipants: [
        // Window A has X, Y
        { id: "sp-AX", sportsSeasonId: "ss-A", participantId: "cp-X", name: "X" },
        { id: "sp-AY", sportsSeasonId: "ss-A", participantId: "cp-Y", name: "Y" },
        // Window B has Y, Z
        { id: "sp-BY", sportsSeasonId: "ss-B", participantId: "cp-Y", name: "Y" },
        { id: "sp-BZ", sportsSeasonId: "ss-B", participantId: "cp-Z", name: "Z" },
      ],
    });

    const db = makeFakeDb(state);
    vi.mocked(database).mockReturnValue(db as never);
    vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);

    const report = await syncTournamentResults("t-1");

    expect(report.windowsSynced).toBe(2);
    expect(report.windowsFailed).toBe(0);

    const aRows = state.eventResults.filter((r) => r.scoringEventId === "ev-A");
    const bRows = state.eventResults.filter((r) => r.scoringEventId === "ev-B");

    // Window A: sp-AX (placement 1), sp-AY (placement 2). No 0-QP fillers
    // since both roster members got results.
    expect(aRows).toHaveLength(2);
    expect(aRows.find((r) => r.seasonParticipantId === "sp-AX")?.placement).toBe(1);
    expect(aRows.find((r) => r.seasonParticipantId === "sp-AY")?.placement).toBe(2);

    // Window B: sp-BY (placement 2), sp-BZ (placement 3). Again no fillers.
    expect(bRows).toHaveLength(2);
    expect(bRows.find((r) => r.seasonParticipantId === "sp-BY")?.placement).toBe(2);
    expect(bRows.find((r) => r.seasonParticipantId === "sp-BZ")?.placement).toBe(3);

    expect(processQualifyingEvent).toHaveBeenCalledTimes(2);
    expect(processQualifyingEvent).toHaveBeenNthCalledWith(1, "ev-A", db);
    expect(processQualifyingEvent).toHaveBeenNthCalledWith(2, "ev-B", db);
  });

  // -------------------------------------------------------------------------
  // Test 3: Idempotent re-run
  // -------------------------------------------------------------------------

  it("is idempotent: a second run leaves the same end state", async () => {
    const state = seedBasicState({
      tournamentResults: [
        { tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: "10" },
      ],
      scoringEvents: [
        { id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" },
      ],
      seasonParticipants: [
        { id: "sp-A", sportsSeasonId: "ss-1", participantId: "cp-A", name: "A" },
        { id: "sp-B", sportsSeasonId: "ss-1", participantId: "cp-B", name: "B" },
      ],
    });

    const db = makeFakeDb(state);
    vi.mocked(database).mockReturnValue(db as never);
    vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);

    await syncTournamentResults("t-1");
    const afterFirst = JSON.parse(JSON.stringify(state.eventResults));

    await syncTournamentResults("t-1");
    const afterSecond = state.eventResults;

    // Same row count, same per-row placement / QP / participant / event.
    expect(afterSecond).toHaveLength(afterFirst.length);
    for (const first of afterFirst) {
      const match = afterSecond.find(
        (r) =>
          r.scoringEventId === first.scoringEventId &&
          r.seasonParticipantId === first.seasonParticipantId
      );
      expect(match).toBeDefined();
      expect(match?.placement).toBe(first.placement);
      expect(match?.qualifyingPointsAwarded).toBe(first.qualifyingPointsAwarded);
      expect(match?.rawScore).toBe(first.rawScore);
    }
  });

  // -------------------------------------------------------------------------
  // Test 4: Correction propagation
  // -------------------------------------------------------------------------

  it("propagates canonical placement corrections to all linked windows", async () => {
    const state = seedBasicState({
      tournamentResults: [
        { tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
      ],
      scoringEvents: [
        { id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" },
        { id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1" },
      ],
      seasonParticipants: [
        { id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
        { id: "sp-BA", sportsSeasonId: "ss-B", participantId: "cp-A", name: "A" },
      ],
    });

    const db = makeFakeDb(state);
    vi.mocked(database).mockReturnValue(db as never);
    vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);

    await syncTournamentResults("t-1");

    // Sanity — both windows have sp-* at placement 1.
    expect(
      state.eventResults.find((r) => r.seasonParticipantId === "sp-AA")?.placement
    ).toBe(1);
    expect(
      state.eventResults.find((r) => r.seasonParticipantId === "sp-BA")?.placement
    ).toBe(1);

    // Correct the canonical placement from 1 to 5.
    state.tournamentResults[0].placement = 5;
    state.tournamentResults[0].rawScore = "99";

    await syncTournamentResults("t-1");

    expect(
      state.eventResults.find((r) => r.seasonParticipantId === "sp-AA")?.placement
    ).toBe(5);
    expect(
      state.eventResults.find((r) => r.seasonParticipantId === "sp-AA")?.rawScore
    ).toBe("99");
    expect(
      state.eventResults.find((r) => r.seasonParticipantId === "sp-BA")?.placement
    ).toBe(5);
    expect(
      state.eventResults.find((r) => r.seasonParticipantId === "sp-BA")?.rawScore
    ).toBe("99");
  });

  // -------------------------------------------------------------------------
  // Test 5: Partial failure isolation
  // -------------------------------------------------------------------------

  it("isolates failures: one window's error does not stop the others", async () => {
    const state = seedBasicState({
      tournamentResults: [
        { tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
      ],
      scoringEvents: [
        { id: "ev-A", sportsSeasonId: "ss-A", tournamentId: "t-1" },
        { id: "ev-B", sportsSeasonId: "ss-B", tournamentId: "t-1" },
      ],
      seasonParticipants: [
        { id: "sp-AA", sportsSeasonId: "ss-A", participantId: "cp-A", name: "A" },
        { id: "sp-BA", sportsSeasonId: "ss-B", participantId: "cp-A", name: "A" },
      ],
    });

    const db = makeFakeDb(state);
    vi.mocked(database).mockReturnValue(db as never);

    // First window throws in processQualifyingEvent; second succeeds.
    vi.mocked(processQualifyingEvent)
      .mockImplementationOnce(async () => {
        throw new Error("QP calc failed");
      })
      .mockImplementationOnce(async () => undefined);

    const report = await syncTournamentResults("t-1");

    expect(report.windowsSynced).toBe(1);
    expect(report.windowsFailed).toBe(1);
    expect(report.failures).toHaveLength(1);
    expect(report.failures[0].scoringEventId).toBe("ev-A");
    expect(report.failures[0].sportsSeasonId).toBe("ss-A");
    expect(report.failures[0].error).toContain("QP calc failed");

    // Second window (ev-B) still has its row committed in our fake (we don't
    // simulate rollback, but the service called through to the writes path,
    // which is what we care about — the try/catch did not propagate).
    expect(
      state.eventResults.find(
        (r) => r.scoringEventId === "ev-B" && r.seasonParticipantId === "sp-BA"
      )
    ).toBeDefined();

    expect(processQualifyingEvent).toHaveBeenCalledTimes(2);
  });

  // -------------------------------------------------------------------------
  // Test 6: Participant in roster but not in canonical results → 0-QP filler
  // -------------------------------------------------------------------------

  it("writes 0-QP filler rows for roster participants absent from canonical results", async () => {
    const state = seedBasicState({
      tournamentResults: [
        { tournamentId: "t-1", participantId: "cp-A", placement: 1, rawScore: null },
      ],
      scoringEvents: [
        { id: "ev-1", sportsSeasonId: "ss-1", tournamentId: "t-1" },
      ],
      seasonParticipants: [
        { id: "sp-A", sportsSeasonId: "ss-1", participantId: "cp-A", name: "A" },
        { id: "sp-B", sportsSeasonId: "ss-1", participantId: "cp-B", name: "B" },
        { id: "sp-C", sportsSeasonId: "ss-1", participantId: "cp-C", name: "C" },
      ],
    });

    const db = makeFakeDb(state);
    vi.mocked(database).mockReturnValue(db as never);
    vi.mocked(processQualifyingEvent).mockResolvedValue(undefined);

    await syncTournamentResults("t-1");

    const rows = state.eventResults;
    expect(rows).toHaveLength(3);

    const a = rows.find((r) => r.seasonParticipantId === "sp-A");
    expect(a?.placement).toBe(1);
    expect(a?.qualifyingPointsAwarded).toBeUndefined(); // not set by this service

    const b = rows.find((r) => r.seasonParticipantId === "sp-B");
    expect(b?.placement).toBeNull();
    expect(b?.qualifyingPointsAwarded).toBe("0");

    const c = rows.find((r) => r.seasonParticipantId === "sp-C");
    expect(c?.placement).toBeNull();
    expect(c?.qualifyingPointsAwarded).toBe("0");
  });
});
