import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, inArray } from "drizzle-orm";
import { getScoringRules, calculateFantasyPoints, calculateBracketPoints } from "./scoring-rules";
import { getSeasonResults } from "./participant-season-result";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
import { getUserDisplayName } from "~/models/user";
import { createDailySnapshot } from "~/models/standings";
import { logger } from "~/lib/logger";

/**
 * Core scoring calculation engine
 * This file contains the logic for calculating fantasy points based on participant results
 */

export type ScoringPattern =
  | "playoff_bracket"
  | "season_standings"
  | "qualifying_points";

// ── Round scoring configuration ──────────────────────────────────────────────

interface RoundScoringConfig {
  /** Final position assigned to the loser of this round. */
  loserPosition: number;
  /** true = loser is still competing (AFL double-chance); false = loser is eliminated. */
  loserIsPartial: boolean;
  /**
   * Guaranteed minimum position for the winner.
   * null signals a Finals round: winner is finalized as 1st place (handled inline).
   */
  winnerFloor: number | null;
}

/**
 * Standard round name → scoring config mapping.
 * Covers all common bracket formats (NCAA, NBA, NFL, Darts, Snooker, AFL).
 */
const ROUND_CONFIG: Record<string, RoundScoringConfig> = {
  // ── Finals equivalents ─────────────────────────────────────────────────────
  Finals:                  { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
  Championship:            { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
  "Super Bowl":            { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
  "NBA Finals":            { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
  "Grand Final":           { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
  // ── Semi-final equivalents ─────────────────────────────────────────────────
  Semifinals:              { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
  "Final Four":            { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
  "Conference Finals":     { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
  "Conference Championship": { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
  "Preliminary Finals":    { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
  // ── Quarter-final equivalents ──────────────────────────────────────────────
  Quarterfinals:           { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
  "Elite Eight":           { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
  Divisional:              { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
  "Conference Semifinals": { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
  // ── AFL-specific rounds ────────────────────────────────────────────────────
  // Qualifying Finals losers get a second chance via Semi-Finals (double-chance).
  "Qualifying Finals":     { loserPosition: 5, loserIsPartial: true,  winnerFloor: 3 },
  // Elimination Finals losers are permanently out (7th–8th).
  "Elimination Finals":    { loserPosition: 7, loserIsPartial: false, winnerFloor: 5 },
};

/**
 * Per-template round config overrides.
 * Some round names mean different things in different bracket formats.
 *
 * IMPORTANT: Template overrides only apply when bracketTemplateId matches exactly.
 * For example, "Semi-Finals" in afl_10 eliminates the loser (5th–6th), while a
 * generic "Semi-Finals" round (without afl_10 template) is not in ROUND_CONFIG at
 * all — use "Semifinals" (no hyphen) for standard brackets.
 */
const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>> = {
  afl_10: {
    "Semi-Finals": { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
  },
};

/**
 * Returns true if a non-scoring round's winners are entering the first scoring round
 * (i.e., they've guaranteed a top-8 fantasy placement and should receive a T5–T8 floor).
 *
 * For multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 →
 * Sweet Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring
 * bracket — Round of 64 and Round of 32 winners should not receive any floor yet.
 *
 * Falls back to true when template/round info is unavailable to preserve legacy behavior.
 */
function doesNonScoringRoundFeedIntoScoringRound(
  round: string,
  bracketTemplateId: string | null | undefined
): boolean {
  if (!bracketTemplateId) return true; // Legacy: preserve old behavior
  const template = BRACKET_TEMPLATES[bracketTemplateId];
  if (!template) return true; // Unknown template: preserve old behavior
  const currentRound = template.rounds.find((r) => r.name === round);
  if (!currentRound) return true; // Unknown round: preserve old behavior
  const nextRoundName = currentRound.feedsInto;
  if (!nextRoundName) return false; // No next round (shouldn't happen for non-scoring)
  const nextRound = template.rounds.find((r) => r.name === nextRoundName);
  return nextRound?.isScoring === true;
}

/**
 * Look up the scoring config for a given round name, applying any template-specific
 * overrides before falling back to the standard ROUND_CONFIG.
 * Returns null for unrecognized rounds.
 */
function getRoundConfig(
  round: string,
  bracketTemplateId?: string | null
): RoundScoringConfig | null {
  if (bracketTemplateId && TEMPLATE_ROUND_CONFIG[bracketTemplateId]?.[round]) {
    return TEMPLATE_ROUND_CONFIG[bracketTemplateId][round];
  }
  return ROUND_CONFIG[round] ?? null;
}

/**
 * Process a playoff event completion and assign final placements.
 *
 * Loser placement rules per round type:
 *   Non-scoring rounds: losers get 0 pts (pre-bracket elimination)
 *   Quarterfinals / equivalent: losers share T5–T8
 *   Semifinals / equivalent: losers share T3–T4
 *   Finals / equivalent: loser gets 2nd, winner gets 1st
 *
 * Progressive floor scoring: winners immediately earn a provisional placement
 * reflecting their worst-case finish from this point forward.
 */
export async function processPlayoffEvent(
  eventId: string,
  providedDb?: ReturnType<typeof database>,
  options?: { skipRecalculate?: boolean }
): Promise<void> {
  const db = providedDb || database();

  // Get the event
  const event = await db.query.scoringEvents.findFirst({
    where: eq(schema.scoringEvents.id, eventId),
    with: {
      sportsSeason: {
        with: {
          seasonSports: {
            with: {
              season: true,
            },
          },
        },
      },
    },
  });

  if (!event) {
    throw new Error(`Event ${eventId} not found`);
  }

  if (!event.playoffRound) {
    throw new Error(`Event ${eventId} is not a playoff event`);
  }

  // Get all matches for this event in the specified round
  const matches = await db.query.playoffMatches.findMany({
    where: and(
      eq(schema.playoffMatches.scoringEventId, eventId),
      eq(schema.playoffMatches.round, event.playoffRound)
    ),
    orderBy: (m, { asc }) => [asc(m.matchNumber)],
  });

  // Process based on round
  const round = event.playoffRound;

  // Check if this is a scoring round
  // First try to get isScoring from the match (template-based)
  const isScoring = matches[0]?.isScoring ?? true; // Default to true for legacy brackets

  // PHASE 5.3: Handle teams that didn't make the playoffs
  // Get all participants in the sports season
  const allParticipants = await db.query.participants.findMany({
    where: eq(schema.participants.sportsSeasonId, event.sportsSeasonId),
  });

  // Get all participant IDs that are in ANY playoff match (winners or losers)
  const allMatches = await db.query.playoffMatches.findMany({
    where: eq(schema.playoffMatches.scoringEventId, eventId),
  });

  const participantsInBracket = new Set<string>();
  for (const match of allMatches) {
    if (match.winnerId) participantsInBracket.add(match.winnerId);
    if (match.loserId) participantsInBracket.add(match.loserId);
    if (match.participant1Id) participantsInBracket.add(match.participant1Id);
    if (match.participant2Id) participantsInBracket.add(match.participant2Id);
  }

  // Mark participants NOT in the bracket as eliminated (didn't make playoffs)
  for (const participant of allParticipants) {
    if (!participantsInBracket.has(participant.id)) {
      // Check if they already have a result (don't overwrite)
      const existingResult = await db.query.participantResults.findFirst({
        where: and(
          eq(schema.participantResults.participantId, participant.id),
          eq(schema.participantResults.sportsSeasonId, event.sportsSeasonId)
        ),
      });

      if (!existingResult) {
        await upsertParticipantResult(
          participant.id,
          event.sportsSeasonId,
          0, // 0 = didn't make playoffs, eliminated
          db
        );
      }
    }
  }

  // Get season scoring rules for the first affected season
  // (all seasons should have their own rules, but we use the event's sports season to find one)
  const seasonSport = event.sportsSeason.seasonSports[0];
  if (!seasonSport) {
    throw new Error(`No fantasy seasons found for sports season ${event.sportsSeasonId}`);
  }

  const scoringRules = await getScoringRules(seasonSport.seasonId, db);
  if (!scoringRules) {
    throw new Error(`Scoring rules not found for season ${seasonSport.seasonId}`);
  }

  if (!isScoring) {
    // Non-scoring (pre-bracket) round: losers are permanently eliminated (0 pts).
    // Winners only bank a provisional T5–T8 floor if they're entering the first
    // scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences
    // like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners should
    // receive floor points — R64 and R32 winners are not yet guaranteed top-8.
    const awardFloor = doesNonScoringRoundFeedIntoScoringRound(round, event.bracketTemplateId);
    for (const match of matches) {
      if (match.loserId) {
        await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
      }
      if (match.winnerId && awardFloor) {
        await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true);
      }
    }
  } else {
    const config = getRoundConfig(round, event.bracketTemplateId);

    if (config === null) {
      // Unrecognized scoring round: losers earn 0 pts (outside top-8).
      logger.warn(
        `[ScoringCalculator] Unrecognized scoring round "${round}" for event ${eventId}. Losers receive 0 pts.`
      );
      for (const match of matches) {
        if (match.loserId) {
          await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
        }
      }
    } else if (config.winnerFloor === null) {
      // Finals: finalize both participants — winner gets 1st, loser gets 2nd.
      const finalMatch = matches[0];
      if (!finalMatch || !finalMatch.winnerId || !finalMatch.loserId) {
        throw new Error("Finals match is not complete");
      }
      await upsertParticipantResult(finalMatch.winnerId, event.sportsSeasonId, 1, db);
      await upsertParticipantResult(finalMatch.loserId, event.sportsSeasonId, config.loserPosition, db);
    } else {
      // All other scoring rounds: assign loser's final (or provisional) position.
      for (const match of matches) {
        if (match.loserId) {
          await upsertParticipantResult(
            match.loserId,
            event.sportsSeasonId,
            config.loserPosition,
            db,
            config.loserIsPartial
          );
        }
      }
    }
  }

  // Progressive floor scoring: assign guaranteed minimum points to winners.
  // For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the
  // winner is already finalized as 1st above. For non-scoring rounds it also
  // returns null (winners were given floor 5 inline above).
  const guaranteedMinimum = getGuaranteedMinimumPosition(
    round,
    event.bracketTemplateId,
    isScoring
  );
  if (guaranteedMinimum !== null) {
    for (const match of matches) {
      if (match.winnerId) {
        await upsertParticipantResult(
          match.winnerId,
          event.sportsSeasonId,
          guaranteedMinimum,
          db,
          true // isPartialScore: still competing, floor will rise as they advance
        );
      }
    }
  }

  // Recalculate standings for all affected leagues (skipped when caller handles it)
  if (!options?.skipRecalculate) {
    await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventName: event.name, eventId });
  }

  // Auto-trigger probability recalculation after result
  try {
    await updateProbabilitiesAfterResult(event.sportsSeasonId, true);
    logger.log(
      `[ScoringCalculator] Auto-updated probabilities for sports season ${event.sportsSeasonId}`
    );
  } catch (error) {
    logger.error(
      `[ScoringCalculator] Failed to auto-update probabilities for sports season ${event.sportsSeasonId}:`,
      error
    );
  }
}

/**
 * Process a single completed match and immediately award points.
 *
 * Called when a match winner is set, before the full round is complete.
 * Loser placement is final (isPartialScore=false); winner placement is
 * provisional (isPartialScore=true) reflecting their guaranteed minimum finish.
 *
 * @param skipSideEffects - When true, skips standings recalc and probability update.
 *   Use this when processing multiple matches in a batch (call side effects once after
 *   the loop instead of once per match).
 */
export async function processMatchResult(
  params: {
    round: string;
    winnerId: string;
    loserId: string;
    /** isScoring ?? true: default to true for legacy rows that pre-date the isScoring column. */
    isScoring: boolean;
    sportsSeasonId: string;
    bracketTemplateId?: string | null;
    eventId?: string;
    eventName?: string;
    /** When set, Discord notification only shows this match (not all completed matches for the event). */
    matchId?: string;
    skipSideEffects?: boolean;
  },
  providedDb?: ReturnType<typeof database>
): Promise<void> {
  const db = providedDb || database();
  const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects } = params;

  if (!isScoring) {
    // Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts).
    // Winner only banks provisional T5–T8 floor if they're entering the first
    // scoring round (guaranteed top-8). For multi-round pre-bracket sequences
    // like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners
    // should receive floor points.
    await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
    if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) {
      await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
    }
  } else {
    const config = getRoundConfig(round, bracketTemplateId);

    if (config === null) {
      // Unrecognized scoring round: loser earns 0 pts, winner gets fallback T5–T8 floor.
      logger.warn(
        `[ScoringCalculator] processMatchResult: unrecognized round "${round}". Loser receives 0 pts.`
      );
      await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
      await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
    } else if (config.winnerFloor === null) {
      // Finals: finalize both participants.
      await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db);
      await upsertParticipantResult(winnerId, sportsSeasonId, 1, db);
    } else {
      // All other scoring rounds: loser gets final (or provisional) position, winner gets floor.
      await upsertParticipantResult(loserId, sportsSeasonId, config.loserPosition, db, config.loserIsPartial);
      await upsertParticipantResult(winnerId, sportsSeasonId, config.winnerFloor, db, true);
    }
  }

  if (!skipSideEffects) {
    const sideEffectOptions = (eventName || eventId)
      ? { eventName, eventId, matchIds: matchId ? [matchId] : undefined }
      : undefined;
    await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
    try {
      await updateProbabilitiesAfterResult(sportsSeasonId, true);
    } catch (error) {
      logger.error(
        `[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
        error
      );
    }
  }
}

/**
 * Helper to upsert participant result
 *
 * isPartialScore=true means the participant is still alive and this placement
 * is their guaranteed minimum floor — it will be replaced as they advance or
 * when they are finally eliminated.
 */
async function upsertParticipantResult(
  participantId: string,
  sportsSeasonId: string,
  finalPosition: number,
  db: ReturnType<typeof database>,
  isPartialScore = false
): Promise<void> {
  const existing = await db.query.participantResults.findFirst({
    where: and(
      eq(schema.participantResults.participantId, participantId),
      eq(schema.participantResults.sportsSeasonId, sportsSeasonId)
    ),
  });

  if (existing) {
    // Never un-finalize: if the row is already finalized (isPartialScore=false),
    // a call with isPartialScore=true must not overwrite it (e.g. a re-run of
    // processPlayoffEvent after a manual finalization).
    if (!existing.isPartialScore && isPartialScore) return;

    await db
      .update(schema.participantResults)
      .set({
        finalPosition,
        isPartialScore,
        updatedAt: new Date(),
      })
      .where(eq(schema.participantResults.id, existing.id));
  } else {
    await db.insert(schema.participantResults).values({
      participantId,
      sportsSeasonId,
      finalPosition,
      isPartialScore,
    });
  }
}

/**
 * Returns the guaranteed minimum final position for winners of a given round.
 *
 * This is the worst-case placement a participant can achieve if they lose
 * every remaining match from this point forward. Returns null when:
 * - The round is non-scoring (winners haven't reached a points-paying zone)
 * - The round is the Finals (winner already finalized as 1st place inline)
 */
export function getGuaranteedMinimumPosition(
  round: string,
  bracketTemplateId: string | null | undefined,
  isScoring: boolean
): number | null {
  if (!isScoring) return null;
  const config = getRoundConfig(round, bracketTemplateId);
  if (config === null) return 5; // Unknown scoring round: fallback to T5–T8 floor
  return config.winnerFloor;    // null for Finals; actual floor for all other rounds
}

/**
 * Process a qualifying event completion and update QP totals.
 * Ties in QP are handled by sharing placements (averaged points).
 */
export async function processQualifyingEvent(
  eventId: string,
  providedDb?: ReturnType<typeof database>
): Promise<void> {
  const db = providedDb || database();

  // Import qualifying points functions
  const { getEventResults } = await import("./event-result");
  const { getQPForPlacement, recalculateParticipantQP } = await import("./qualifying-points");

  // Get the event
  const event = await db.query.scoringEvents.findFirst({
    where: eq(schema.scoringEvents.id, eventId),
    with: {
      sportsSeason: true,
    },
  });

  if (!event) {
    throw new Error(`Event ${eventId} not found`);
  }

  if (!event.isQualifyingEvent) {
    throw new Error(`Event ${eventId} is not a qualifying event`);
  }

  // Get all event results for this qualifying event
  const results = await getEventResults(eventId, db);

  // Check if this was already processed (for majorsCompleted counter)
  const wasAlreadyProcessed = results.some(r => r.qualifyingPointsAwarded && parseFloat(r.qualifyingPointsAwarded) > 0);

  // Group results by placement to handle ties
  const placementGroups = new Map<number, typeof results>();
  for (const result of results) {
    if (result.placement) {
      const group = placementGroups.get(result.placement) || [];
      group.push(result);
      placementGroups.set(result.placement, group);
    }
  }

  // Process each placement group and update event_results with QP awarded
  for (const [placement, group] of placementGroups) {
    const tieCount = group.length;

    // Calculate total QP for the positions this group occupies
    // A tie for Nth place occupies positions N through N + tieCount - 1
    let totalQP = 0;
    for (let pos = placement; pos < placement + tieCount; pos++) {
      const qpForPos = await getQPForPlacement(event.sportsSeasonId, pos, db);
      totalQP += qpForPos;
    }

    // Divide equally among tied participants
    const qpPerParticipant = totalQP / tieCount;

    // Update the event result with the QP awarded
    for (const result of group) {
      await db
        .update(schema.eventResults)
        .set({
          qualifyingPointsAwarded: qpPerParticipant.toString(),
          updatedAt: new Date(),
        })
        .where(eq(schema.eventResults.id, result.id));
    }
  }

  // Recalculate totals for all participants from scratch
  // This is the source of truth - sums all their event_results
  const participantIds = new Set(results.map(r => r.participantId));
  for (const participantId of participantIds) {
    await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
  }

  // Increment majorsCompleted counter (only if this is the first time processing)
  if (!wasAlreadyProcessed) {
    const sportsSeason = event.sportsSeason;
    await db
      .update(schema.sportsSeasons)
      .set({
        majorsCompleted: (sportsSeason.majorsCompleted || 0) + 1,
        updatedAt: new Date(),
      })
      .where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
  }

  logger.log(
    `[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
  );
}

/**
 * Finalize qualifying points and convert to fantasy placements.
 * Called manually by admin when all events are complete.
 * Tied QP totals result in shared placements (averaged points).
 */
export async function finalizeQualifyingPoints(
  sportsSeasonId: string,
  providedDb?: ReturnType<typeof database>
): Promise<void> {
  const db = providedDb || database();

  // Import qualifying points functions
  const { getQPStandings, updateFinalRankings } = await import("./qualifying-points");

  // Get all participants ranked by total QP (highest first)
  const standings = await getQPStandings(sportsSeasonId, db);

  if (standings.length === 0) {
    throw new Error(`No qualifying points standings found for sports season ${sportsSeasonId}`);
  }

  // Update final rankings in participant_qualifying_totals
  await updateFinalRankings(sportsSeasonId, db);

  // Group participants by QP total to handle ties (Q5, Q19)
  const groupedByQP = new Map<string, typeof standings>();
  for (const standing of standings) {
    const qp = standing.totalQualifyingPoints;
    if (!groupedByQP.has(qp)) {
      groupedByQP.set(qp, []);
    }
    groupedByQP.get(qp)?.push(standing);
  }

  // Sort groups by QP (descending)
  const sortedGroups = Array.from(groupedByQP.entries()).toSorted((a, b) => {
    return parseFloat(b[0]) - parseFloat(a[0]);
  });

  // Assign fantasy placements (1-8) based on QP ranking
  let currentPlacement = 1;

  for (const [_, group] of sortedGroups) {
    // Stop if we've gone beyond the top 8 fantasy placements
    if (currentPlacement > 8) break;

    const participantsInGroup = group.length;

    // Calculate how many placements this group shares
    const maxPlacementForGroup = Math.min(8, currentPlacement + participantsInGroup - 1);
    const actualParticipantsScoring = maxPlacementForGroup - currentPlacement + 1;

    // Assign placements to participants in this group
    for (let i = 0; i < Math.min(participantsInGroup, actualParticipantsScoring); i++) {
      const standing = group[i];
      await upsertParticipantResult(
        standing.participantId,
        sportsSeasonId,
        currentPlacement,
        db
      );
    }

    // If there are more participants than scoring positions, assign 0 to the rest
    for (let i = actualParticipantsScoring; i < participantsInGroup; i++) {
      const standing = group[i];
      await upsertParticipantResult(
        standing.participantId,
        sportsSeasonId,
        0, // Beyond top 8
        db
      );
    }

    // Move to next placement group
    currentPlacement += participantsInGroup;
  }

  // Assign 0 points to any remaining participants beyond top 8
  if (currentPlacement <= standings.length) {
    for (let i = currentPlacement - 1; i < standings.length; i++) {
      const standing = standings[i];
      const hasResult = await db.query.participantResults.findFirst({
        where: and(
          eq(schema.participantResults.participantId, standing.participantId),
          eq(schema.participantResults.sportsSeasonId, sportsSeasonId)
        ),
      });

      if (!hasResult) {
        await upsertParticipantResult(
          standing.participantId,
          sportsSeasonId,
          0,
          db
        );
      }
    }
  }

  // Mark sports season as finalized
  await db
    .update(schema.sportsSeasons)
    .set({
      qualifyingPointsFinalized: true,
      updatedAt: new Date(),
    })
    .where(eq(schema.sportsSeasons.id, sportsSeasonId));

  // Trigger recalculation for all affected leagues
  await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Final Standings" });

  // Auto-trigger probability recalculation after result
  try {
    await updateProbabilitiesAfterResult(sportsSeasonId, true);
    logger.log(
      `[ScoringCalculator] Auto-updated probabilities for sports season ${sportsSeasonId}`
    );
  } catch (error) {
    logger.error(
      `[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
      error
    );
  }

  logger.log(
    `[ScoringCalculator] Finalized qualifying points for sports season ${sportsSeasonId}: ${standings.length} participants processed`
  );
}

/**
 * Process season standings (auto racing: F1, IndyCar, etc.) and assign final placements.
 * Reads from participant_season_results (championship points) and converts
 * final standings positions to fantasy placements. Ties share placements.
 */
export async function processSeasonStandings(
  sportsSeasonId: string,
  providedDb?: ReturnType<typeof database>
): Promise<void> {
  const db = providedDb || database();

  // Get all season results for this sports season, sorted by standings
  const seasonResults = await getSeasonResults(sportsSeasonId, db);

  // Group participants by position to handle ties
  const positionGroups: Record<number, string[]> = {};

  for (const result of seasonResults) {
    const position = result.currentPosition;
    if (position === null) continue; // Skip participants without a position

    if (!positionGroups[position]) {
      positionGroups[position] = [];
    }
    positionGroups[position].push(result.participantId);
  }

  // Get sorted positions
  const positions = Object.keys(positionGroups)
    .map(Number)
    .toSorted((a, b) => a - b);

  // Assign fantasy placements (1-8) based on standings
  let currentPlacement = 1;

  for (const position of positions) {
    const participantIds = positionGroups[position];

    // Stop if we've gone beyond the top 8 fantasy placements
    if (currentPlacement > 8) break;

    // Calculate how many placements this group shares
    const participantsInGroup = participantIds.length;

    // If this group would exceed position 8, only some participants get points
    const maxPlacementForGroup = Math.min(8, currentPlacement + participantsInGroup - 1);
    const actualParticipantsScoring = maxPlacementForGroup - currentPlacement + 1;

    // Determine if we should assign placements to this group
    if (currentPlacement <= 8) {
      // All participants tied at this position get the first placement in the range
      // (e.g., if 4 people tie for 5th place, they all get placement 5)
      // The scoring system will handle averaging the points for positions 5, 6, 7, 8
      for (let i = 0; i < Math.min(participantIds.length, actualParticipantsScoring); i++) {
        const participantId = participantIds[i];
        await upsertParticipantResult(
          participantId,
          sportsSeasonId,
          currentPlacement,
          db
        );
      }

      // If there are more participants than scoring positions, assign 0 to the rest
      for (let i = actualParticipantsScoring; i < participantIds.length; i++) {
        const participantId = participantIds[i];
        await upsertParticipantResult(
          participantId,
          sportsSeasonId,
          0, // Beyond top 8
          db
        );
      }
    } else {
      // Position is beyond top 8, assign 0 points
      for (const participantId of participantIds) {
        await upsertParticipantResult(
          participantId,
          sportsSeasonId,
          0,
          db
        );
      }
    }

    // Move to next placement group
    currentPlacement += participantsInGroup;
  }

  // Trigger recalculation for all affected leagues
  await recalculateAffectedLeagues(sportsSeasonId, db, { eventName: "Season Complete" });

  // Auto-trigger probability recalculation after result
  try {
    await updateProbabilitiesAfterResult(sportsSeasonId, true);
    logger.log(
      `[ScoringCalculator] Auto-updated probabilities for sports season ${sportsSeasonId}`
    );
  } catch (error) {
    logger.error(
      `[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
      error
    );
  }

  logger.log(
    `[ScoringCalculator] Processed season standings for sports season ${sportsSeasonId}`
  );
}

/**
 * Calculate total fantasy points for a team in a specific season
 * Sums points from all drafted participants based on their final placements
 */
export async function calculateTeamScore(
  teamId: string,
  seasonId: string,
  providedDb?: ReturnType<typeof database>
): Promise<{
  totalPoints: number;
  placementCounts: Record<number, number>;
  participantsCompleted: number;
  participantsTotal: number;
}> {
  const db = providedDb || database();

  // Get season scoring rules
  const scoringRules = await getScoringRules(seasonId, db);
  if (!scoringRules) {
    throw new Error(`Season ${seasonId} not found`);
  }

  // Get all draft picks for this team
  const picks = await db.query.draftPicks.findMany({
    where: and(
      eq(schema.draftPicks.teamId, teamId),
      eq(schema.draftPicks.seasonId, seasonId)
    ),
    with: {
      participant: {
        with: {
          results: true,
          sportsSeason: true,
        },
      },
    },
  });

  let totalPoints = 0;
  const placementCounts: Record<number, number> = {
    1: 0,
    2: 0,
    3: 0,
    4: 0,
    5: 0,
    6: 0,
    7: 0,
    8: 0,
  };
  let participantsCompleted = 0;

  // Cache bracket template IDs per sports season (one extra query per unique bracket season).
  // Needed to determine the correct scoring tier structure (e.g. AFL splits 5–8 into two pairs).
  const bracketTemplateCache = new Map<string, string | null>();
  async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
    if (bracketTemplateCache.has(sportsSeasonId)) {
      return bracketTemplateCache.get(sportsSeasonId) ?? null;
    }
    const event = await db.query.scoringEvents.findFirst({
      where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
      columns: { bracketTemplateId: true },
    });
    const templateId = event?.bracketTemplateId ?? null;
    bracketTemplateCache.set(sportsSeasonId, templateId);
    return templateId;
  }

  for (const pick of picks) {
    // One result per participant per sports season (enforced by upsertParticipantResult).
    // If duplicates exist due to data corruption, the first row wins — acceptable trade-off.
    const result = pick.participant.results[0];

    if (result && result.finalPosition !== null && result.finalPosition > 0) {
      const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";
      let points: number;
      if (isBracket) {
        const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
        points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
      } else {
        points = calculateFantasyPoints(result.finalPosition, scoringRules);
      }
      totalPoints += points;

      if (!result.isPartialScore) {
        // Only fully finalized participants count toward completed and placement tiebreakers
        participantsCompleted++;
        if (result.finalPosition >= 1 && result.finalPosition <= 8) {
          placementCounts[result.finalPosition]++;
        }
      }
    } else if (result && !result.isPartialScore) {
      // Finalized with no scoring position (e.g. eliminated in a non-scoring round) — still counts as done
      participantsCompleted++;
    }
  }

  return {
    totalPoints,
    placementCounts,
    participantsCompleted,
    participantsTotal: picks.length,
  };
}

/**
 * Calculate projected total points for a team.
 * Combines actual points from finished participants with EV projections for the rest.
 */
export async function calculateTeamProjectedScore(
  teamId: string,
  seasonId: string,
  providedDb?: ReturnType<typeof database>
): Promise<{
  actualPoints: number;
  projectedPoints: number;
  participantsFinished: number;
  participantsRemaining: number;
}> {
  const db = providedDb || database();

  // Get season scoring rules
  const scoringRules = await getScoringRules(seasonId, db);
  if (!scoringRules) {
    throw new Error(`Season ${seasonId} not found`);
  }

  // Get all draft picks for this team with their results and EVs
  const picks = await db.query.draftPicks.findMany({
    where: and(
      eq(schema.draftPicks.teamId, teamId),
      eq(schema.draftPicks.seasonId, seasonId)
    ),
    with: {
      participant: {
        with: {
          results: true,
          sportsSeason: true,
        },
      },
    },
  });

  let actualPoints = 0;
  let participantsFinished = 0;
  const unfinishedParticipants: Array<{ participantId: string; sportsSeasonId: string; floorPoints: number }> = [];

  // Cache bracket template IDs per sports season
  const bracketTemplateCache = new Map<string, string | null>();
  async function getBracketTemplate(sportsSeasonId: string): Promise<string | null> {
    if (bracketTemplateCache.has(sportsSeasonId)) {
      return bracketTemplateCache.get(sportsSeasonId) ?? null;
    }
    const event = await db.query.scoringEvents.findFirst({
      where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
      columns: { bracketTemplateId: true },
    });
    const templateId = event?.bracketTemplateId ?? null;
    bracketTemplateCache.set(sportsSeasonId, templateId);
    return templateId;
  }

  // Separate finished vs unfinished participants
  for (const pick of picks) {
    const result = pick.participant.results[0];
    const isBracket = pick.participant.sportsSeason?.scoringPattern === "playoff_bracket";

    if (result && result.finalPosition !== null && !result.isPartialScore) {
      // Participant is fully finalized — use bracket-averaged points
      let points: number;
      if (isBracket) {
        const templateId = await getBracketTemplate(pick.participant.sportsSeasonId);
        points = calculateBracketPoints(result.finalPosition, scoringRules, templateId);
      } else {
        points = calculateFantasyPoints(result.finalPosition, scoringRules);
      }
      actualPoints += points;
      participantsFinished++;
    } else if (result && result.finalPosition !== null && result.isPartialScore) {
      // Still alive with a provisional floor — count floor as actual, EV for projection.
      // Note: NOT incremented in participantsFinished; these participants are still competing.
      const templateId = isBracket ? await getBracketTemplate(pick.participant.sportsSeasonId) : null;
      const floorPoints = isBracket
        ? calculateBracketPoints(result.finalPosition, scoringRules, templateId)
        : calculateFantasyPoints(result.finalPosition, scoringRules);
      actualPoints += floorPoints;
      // EV already accounts for their full projected value, so subtract floor to avoid
      // double-counting when we do actualPoints + evSum below
      unfinishedParticipants.push({
        participantId: pick.participant.id,
        sportsSeasonId: pick.participant.sportsSeasonId,
        floorPoints,
      });
    } else {
      // Participant is unfinished - will need EV
      unfinishedParticipants.push({
        participantId: pick.participant.id,
        sportsSeasonId: pick.participant.sportsSeasonId,
        floorPoints: 0,
      });
    }
  }

  // Get EVs for unfinished participants
  let evSum = 0;
  if (unfinishedParticipants.length > 0) {
    // Import the participant EV model
    const { getParticipantEV } = await import("./participant-expected-value");
    const { calculateEV } = await import("~/services/ev-calculator");

    for (const { participantId, sportsSeasonId, floorPoints } of unfinishedParticipants) {
      const ev = await getParticipantEV(participantId, sportsSeasonId);

      if (ev) {
        // EV is already calculated with default scoring (100/70/50/40/25/25/15/15)
        // We need to recalculate with THIS league's scoring rules
        const probabilities = {
          probFirst: parseFloat(ev.probFirst),
          probSecond: parseFloat(ev.probSecond),
          probThird: parseFloat(ev.probThird),
          probFourth: parseFloat(ev.probFourth),
          probFifth: parseFloat(ev.probFifth),
          probSixth: parseFloat(ev.probSixth),
          probSeventh: parseFloat(ev.probSeventh),
          probEighth: parseFloat(ev.probEighth),
        };

        const leagueSpecificEV = calculateEV(probabilities, scoringRules);
        // For partial-score participants, floor is already in actualPoints — add only the
        // incremental EV above the floor to avoid double-counting in projectedPoints.
        // For pending participants (floorPoints=0) this is just the full EV.
        // Math.max guards against the rare case where EV drops below the floor.
        evSum += Math.max(0, leagueSpecificEV - floorPoints);
      }
      // If no EV data exists, assume 0 additional projected points
    }
  }

  const projectedPoints = actualPoints + evSum;

  return {
    actualPoints: Math.round(actualPoints * 100) / 100,
    projectedPoints: Math.round(projectedPoints * 100) / 100,
    participantsFinished,
    participantsRemaining: unfinishedParticipants.length,
  };
}

/**
 * Compare two teams for ranking purposes using tiebreaker logic
 * Returns: negative if teamA ranks higher, positive if teamB ranks higher, 0 if tied
 *
 * Tiebreaker order:
 * 1. Total points (higher is better)
 * 2. Number of 1st place finishes (higher is better)
 * 3. Number of 2nd place finishes (higher is better)
 * 4. Continue through all placements (3rd, 4th, ..., 8th)
 */
function compareTeamsForRanking(
  teamA: {
    totalPoints: number;
    placementCounts: Record<number, number>;
  },
  teamB: {
    totalPoints: number;
    placementCounts: Record<number, number>;
  }
): number {
  // First compare by total points (descending)
  if (teamA.totalPoints !== teamB.totalPoints) {
    return teamB.totalPoints - teamA.totalPoints;
  }

  // If points are tied, apply tiebreaker cascade
  for (let placement = 1; placement <= 8; placement++) {
    const countA = teamA.placementCounts[placement] || 0;
    const countB = teamB.placementCounts[placement] || 0;

    if (countA !== countB) {
      return countB - countA; // More of this placement is better
    }
  }

  // Completely tied
  return 0;
}

/**
 * Assign ranks to teams based on their scores and tiebreakers
 * Teams with identical scores and placements will share the same rank
 */
function assignRanks(
  teams: Array<{
    teamId: string;
    totalPoints: number;
    placementCounts: Record<number, number>;
  }>
): Map<string, number> {
  // Sort teams by ranking criteria
  const sorted = [...teams].toSorted(compareTeamsForRanking);

  const ranks = new Map<string, number>();
  let currentRank = 1;

  for (let i = 0; i < sorted.length; i++) {
    const team = sorted[i];

    // Check if this team is tied with the previous team
    if (i > 0) {
      const prevTeam = sorted[i - 1];
      const comparison = compareTeamsForRanking(team, prevTeam);

      if (comparison !== 0) {
        // Not tied, advance rank
        currentRank = i + 1;
      }
      // If comparison === 0, keep the same rank (tie)
    }

    ranks.set(team.teamId, currentRank);
  }

  return ranks;
}

/**
 * Recalculate standings for all teams in a season.
 * Called after any scoring event completes or participant results change.
 * Implements tiebreaker logic: total points, then placement counts (1st, 2nd, …).
 */
export async function recalculateStandings(
  seasonId: string,
  providedDb?: ReturnType<typeof database>
): Promise<void> {
  const db = providedDb || database();

  const teams = await db.query.teams.findMany({
    where: eq(schema.teams.seasonId, seasonId),
  });

  // Calculate scores for all teams
  const teamScores = await Promise.all(
    teams.map(async (team) => {
      const score = await calculateTeamScore(team.id, seasonId, db);
      const projected = await calculateTeamProjectedScore(team.id, seasonId, db);
      return {
        teamId: team.id,
        totalPoints: score.totalPoints,
        placementCounts: score.placementCounts,
        participantsCompleted: score.participantsCompleted,
        participantsTotal: score.participantsTotal,
        actualPoints: projected.actualPoints,
        projectedPoints: projected.projectedPoints,
        participantsFinished: projected.participantsFinished,
      };
    })
  );

  // Assign ranks based on tiebreaker logic
  const ranks = assignRanks(teamScores);

  // Update team standings with scores and ranks
  for (const teamScore of teamScores) {
    const newRank = ranks.get(teamScore.teamId) || 1;

    // Get existing standing to preserve previousRank
    const existing = await db.query.teamStandings.findFirst({
      where: and(
        eq(schema.teamStandings.teamId, teamScore.teamId),
        eq(schema.teamStandings.seasonId, seasonId)
      ),
    });

    const standingData = {
      totalPoints: teamScore.totalPoints.toString(),
      currentRank: newRank,
      previousRank: existing ? existing.currentRank : null,
      firstPlaceCount: teamScore.placementCounts[1],
      secondPlaceCount: teamScore.placementCounts[2],
      thirdPlaceCount: teamScore.placementCounts[3],
      fourthPlaceCount: teamScore.placementCounts[4],
      fifthPlaceCount: teamScore.placementCounts[5],
      sixthPlaceCount: teamScore.placementCounts[6],
      seventhPlaceCount: teamScore.placementCounts[7],
      eighthPlaceCount: teamScore.placementCounts[8],
      participantsRemaining:
        teamScore.participantsTotal - teamScore.participantsCompleted,
      actualPoints: teamScore.actualPoints?.toString() ?? null,
      projectedPoints: teamScore.projectedPoints?.toString() ?? null,
      participantsFinished: teamScore.participantsFinished ?? null,
      calculatedAt: new Date(),
    };

    if (existing) {
      await db
        .update(schema.teamStandings)
        .set(standingData)
        .where(eq(schema.teamStandings.id, existing.id));
    } else {
      await db.insert(schema.teamStandings).values({
        teamId: teamScore.teamId,
        seasonId,
        ...standingData,
      });
    }
  }

  logger.log(`[ScoringCalculator] Recalculated standings for season ${seasonId}: ${teams.length} teams ranked`);
}

/**
 * Recalculate standings for all leagues that include a specific sports season
 * Called after a sports season event completes
 */
export async function recalculateAffectedLeagues(
  sportsSeasonId: string,
  providedDb?: ReturnType<typeof database>,
  options?: { eventName?: string; eventId?: string; matchIds?: string[] }
): Promise<void> {
  const db = providedDb || database();

  // Get all fantasy seasons that include this sports season
  const seasonSports = await db.query.seasonSports.findMany({
    where: eq(schema.seasonSports.sportsSeasonId, sportsSeasonId),
  });

  const seasonIds = seasonSports.map((ss) => ss.seasonId);

  // Look up sport name for the notification header
  const sportsSeason = await db.query.sportsSeasons.findFirst({
    where: eq(schema.sportsSeasons.id, sportsSeasonId),
    with: { sport: true },
  });
  const sportName = sportsSeason?.sport?.name;

  // Look up matches for the Discord notification.
  // When matchIds is provided, only those specific matches are shown (e.g. current batch).
  // Falling back to all completed matches for the event would include prior rounds.
  let allCompletedMatches: Array<{
    winnerId: string | null;
    loserId: string | null;
    winnerName: string | null;
    loserName: string | null;
  }> = [];

  if (options?.eventId) {
    const whereClause = options.matchIds?.length
      ? inArray(schema.playoffMatches.id, options.matchIds)
      : and(
          eq(schema.playoffMatches.scoringEventId, options.eventId),
          eq(schema.playoffMatches.isComplete, true)
        );
    const matches = await db.query.playoffMatches.findMany({
      where: whereClause,
      with: {
        winner: true,
        loser: true,
      },
    });
    allCompletedMatches = matches
      .filter((m) => m.winnerId && m.loserId)
      .map((m) => ({
        winnerId: m.winnerId,
        loserId: m.loserId,
        winnerName: m.winner?.name ?? null,
        loserName: m.loser?.name ?? null,
      }));
  }

  // Recalculate each affected season
  for (const seasonId of seasonIds) {
    // Capture standings before recalculation for delta calculation
    const beforeStandings = await db.query.teamStandings.findMany({
      where: eq(schema.teamStandings.seasonId, seasonId),
      with: { team: true },
    });
    const previousPointsById = new Map(
      beforeStandings.map((s) => [s.teamId, parseFloat(s.totalPoints)])
    );
    const previousRanksById = new Map(
      beforeStandings.map((s) => [s.teamId, s.currentRank])
    );

    await recalculateStandings(seasonId, db);

    try {
      await createDailySnapshot(seasonId, db);
    } catch (err) {
      logger.error(`[ScoringCalculator] Snapshot failed for season ${seasonId}:`, err);
    }

    // Check if any team's score changed — if so, send Discord notification
    const afterStandings = await db.query.teamStandings.findMany({
      where: eq(schema.teamStandings.seasonId, seasonId),
      with: { team: true },
      orderBy: (ts, { asc }) => [asc(ts.currentRank)],
    });

    const changedTeamIds = new Set(
      afterStandings
        .filter((s) => {
          const prev = previousPointsById.get(s.teamId);
          return prev === undefined || prev !== parseFloat(s.totalPoints);
        })
        .map((s) => s.teamId)
    );
    const hasChanges = changedTeamIds.size > 0;

    // Look up the league's Discord webhook URL via the season
    const season = await db.query.seasons.findFirst({
      where: eq(schema.seasons.id, seasonId),
      with: { league: true },
    });

    const webhookUrl = season?.league?.discordWebhookUrl;
    if (!webhookUrl) continue;

    // Build clerkId → username map for all team owners in this season
    const ownerClerkIds = afterStandings
      .map((s) => s.team.ownerId)
      .filter((id): id is string => id !== null && id !== undefined);
    const teamOwnerUsers = ownerClerkIds.length
      ? await db.query.users.findMany({
          where: inArray(schema.users.clerkId, ownerClerkIds),
        })
      : [];
    const usernameByClerkId = new Map(
      teamOwnerUsers
        .map((u) => [u.clerkId, getUserDisplayName(u)])
        .filter((entry): entry is [string, string] => entry[1] !== null)
    );

    // Build teamId → ownerId map from standings data
    const ownerIdByTeamId = new Map(
      afterStandings.map((s) => [s.teamId, s.team.ownerId])
    );

    // Build scored matches for the notification — computed before the gate
    // because an elimination (0 points, no score change) still warrants a
    // notification when a drafted participant is knocked out.
    let scoredMatches: ScoredMatch[] | undefined;
    if (allCompletedMatches.length > 0) {
      const draftPicks = await db.query.draftPicks.findMany({
        where: eq(schema.draftPicks.seasonId, seasonId),
      });
      const draftedIds = new Set(draftPicks.map((p) => p.participantId));

      const relevant = allCompletedMatches.filter(
        (m) =>
          (m.winnerId && draftedIds.has(m.winnerId)) ||
          (m.loserId && draftedIds.has(m.loserId))
      );

      if (relevant.length > 0) {
        const teamIdByParticipantId = new Map(
          draftPicks.map((p) => [p.participantId, p.teamId])
        );

        // Look up the fantasy owner's display name for a participant.
        // Pass requireScoreChange=true for the winner to avoid surfacing wins
        // that didn't award points this round.
        const usernameForParticipant = (
          participantId: string | null | undefined,
          requireScoreChange = false
        ) => {
          if (!participantId) return undefined;
          const teamId = teamIdByParticipantId.get(participantId);
          if (!teamId) return undefined;
          if (requireScoreChange && !changedTeamIds.has(teamId)) return undefined;
          const ownerId = ownerIdByTeamId.get(teamId);
          if (!ownerId) return undefined;
          return usernameByClerkId.get(ownerId);
        };

        scoredMatches = relevant
          .filter((m) => m.winnerName && m.loserName)
          .map((m) => ({
            winnerName: m.winnerName ?? "",
            loserName: m.loserName ?? "",
            winnerUsername: usernameForParticipant(m.winnerId, true),
            loserUsername: usernameForParticipant(m.loserId),
          }));
      }
    }

    // Skip notification if scores didn't change AND no match has a displayable username.
    const hasScoredMatchesToShow = scoredMatches?.some(
      (m) => m.winnerUsername !== undefined || m.loserUsername !== undefined
    );
    if (!hasChanges && !hasScoredMatchesToShow) continue;

    const standings = afterStandings.map((s) => ({
      teamId: s.teamId,
      teamName: s.team.name,
      username: usernameByClerkId.get(s.team.ownerId ?? ""),
      totalPoints: parseFloat(s.totalPoints),
      rank: s.currentRank,
    }));

    try {
      await sendStandingsUpdateNotification({
        webhookUrl,
        seasonName: `${season.league.name} ${season.year}`,
        standings,
        previousStandings: previousPointsById,
        previousRanks: previousRanksById,
        sportName,
        eventName: options?.eventName,
        scoredMatches,
      });
    } catch (err) {
      // Log but don't fail the scoring pipeline if Discord is unreachable
      logger.error(`[ScoringCalculator] Discord notification failed for season ${seasonId}:`, err);
    }
  }

  logger.log(
    `[ScoringCalculator] Recalculated ${seasonIds.length} affected season(s) for sports season ${sportsSeasonId}`
  );
}
