import "server-only";
import crypto from "node:crypto";
import {
  LedgerDirection,
  LedgerEntryType,
  PresenceJackpotStatus,
  Prisma,
  Role,
  UserStatus,
  WalletAccountType
} from "@prisma/client";
import { prisma } from "@/server/db";
import { ConflictError, ForbiddenError } from "@/server/errors";
import { decimal, ensureWallet, postLedgerEntry } from "@/server/wallet/ledger";

const TIME_ZONE = "Europe/Zurich";
const DRAW_HOUR = 20;
const CURRENCY = "EUR";
const BASE_AMOUNT = decimal(10);
const DAILY_INCREASE = decimal(5);
const RESET_AT = decimal(255);
const MAX_AMOUNT = decimal(250);
const ACTIVE_WAGER_REQUIREMENT = decimal(10);

type Db = typeof prisma | Prisma.TransactionClient;

export type PresenceJackpotSnapshot = Awaited<ReturnType<typeof getPresenceJackpotSnapshot>>;

export async function getPresenceJackpotSnapshot(userId?: string | null, now = new Date()) {
  const draw = await ensureCurrentPresenceDraw(now);
  const nextDrawKey = getNextDrawDateKey(now);
  const nextDrawAt = zonedDateTimeToUtc(nextDrawKey, DRAW_HOUR);
  const nextAmount = nextJackpotAmount(draw.amount, draw.status, daysBetweenDateKeys(draw.drawDate, nextDrawKey));
  const user = userId ? await getUserParticipation(userId, draw, nextDrawAt, now) : null;

  return {
    rules: {
      baseAmount: BASE_AMOUNT.toString(),
      dailyIncrease: DAILY_INCREASE.toString(),
      maxAmount: MAX_AMOUNT.toString(),
      resetAt: RESET_AT.toString(),
      activeWagerRequirement: ACTIVE_WAGER_REQUIREMENT.toString(),
      drawTime: "20:00",
      claimHours: 24,
      doubleTicketStreak: 7
    },
    currentDraw: {
      id: draw.id,
      drawDate: draw.drawDate,
      drawAt: draw.drawAt.toISOString(),
      claimUntil: draw.claimUntil.toISOString(),
      amount: draw.amount.toString(),
      currency: draw.currency,
      status: draw.status,
      winner: draw.winner
        ? {
            id: draw.winner.id,
            username: draw.winner.username
          }
        : null,
      claimedAt: draw.claimedAt?.toISOString() ?? null
    },
    nextDraw: {
      drawDate: nextDrawKey,
      drawAt: nextDrawAt.toISOString(),
      amountIfUnclaimed: nextAmount.toString()
    },
    user
  };
}

export async function claimPresenceJackpot(userId: string, now = new Date()) {
  const draw = await ensureCurrentPresenceDraw(now);

  return prisma.$transaction(async (tx) => {
    await tx.$queryRaw`SELECT id FROM "PresenceJackpotDraw" WHERE id = CAST(${draw.id} AS uuid) FOR UPDATE`;
    const lockedDraw = await tx.presenceJackpotDraw.findUniqueOrThrow({
      where: { id: draw.id },
      include: { winner: true }
    });

    if (lockedDraw.status === PresenceJackpotStatus.CLAIMED) {
      throw new ConflictError("Ce jackpot a déjà été réclamé.", "PRESENCE_JACKPOT_ALREADY_CLAIMED");
    }

    if (lockedDraw.status === PresenceJackpotStatus.NO_ELIGIBLE) {
      throw new ConflictError("Aucun compte éligible n’a été tiré aujourd’hui.", "PRESENCE_JACKPOT_NO_ELIGIBLE_WINNER");
    }

    if (lockedDraw.status !== PresenceJackpotStatus.CLAIMABLE || lockedDraw.claimUntil <= now) {
      await tx.presenceJackpotDraw.update({
        where: { id: lockedDraw.id },
        data: { status: PresenceJackpotStatus.EXPIRED }
      });
      throw new ConflictError("Le délai de réclamation est terminé.", "PRESENCE_JACKPOT_EXPIRED");
    }

    if (lockedDraw.winnerId !== userId) {
      throw new ForbiddenError("Ce jackpot ne vous est pas attribué aujourd’hui.");
    }

    const wallet = await ensureWallet(userId, lockedDraw.currency, tx);
    const entry = await postLedgerEntry(tx, {
      walletId: wallet.id,
      accountType: WalletAccountType.AVAILABLE,
      direction: LedgerDirection.CREDIT,
      type: LedgerEntryType.PRESENCE_JACKPOT_WIN,
      amount: lockedDraw.amount,
      currency: lockedDraw.currency,
      referenceType: "PresenceJackpotDraw",
      referenceId: lockedDraw.id,
      idempotencyKey: `presence-jackpot:${lockedDraw.id}:claim`,
      reason: "Jackpot Présence réclamé",
      metadata: {
        drawDate: lockedDraw.drawDate,
        winnerId: userId
      }
    });

    const claimedDraw = await tx.presenceJackpotDraw.update({
      where: { id: lockedDraw.id },
      data: {
        status: PresenceJackpotStatus.CLAIMED,
        claimedAt: now,
        claimLedgerEntryId: entry.id
      },
      include: { winner: true }
    });

    return {
      draw: {
        id: claimedDraw.id,
        amount: claimedDraw.amount.toString(),
        currency: claimedDraw.currency,
        claimedAt: claimedDraw.claimedAt?.toISOString() ?? null
      }
    };
  }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}

async function ensureCurrentPresenceDraw(now: Date) {
  const drawDate = getCurrentDrawDateKey(now);

  try {
    return await prisma.$transaction(async (tx) => {
      await expireClaimWindows(tx, now);
      const existing = await tx.presenceJackpotDraw.findUnique({
        where: { drawDate },
        include: { winner: true, entries: true }
      });

      if (existing) return existing;

      const drawAt = zonedDateTimeToUtc(drawDate, DRAW_HOUR);
      const claimUntil = addHours(drawAt, 24);
      const previous = await tx.presenceJackpotDraw.findFirst({
        where: { drawAt: { lt: drawAt } },
        orderBy: { drawAt: "desc" }
      });
      const amount = previous ? nextJackpotAmount(previous.amount, previous.status, daysBetweenDateKeys(previous.drawDate, drawDate)) : BASE_AMOUNT;

      const created = await tx.presenceJackpotDraw.create({
        data: {
          drawDate,
          drawAt,
          claimUntil,
          amount,
          currency: CURRENCY,
          status: PresenceJackpotStatus.CLAIMABLE
        }
      });

      const entries = await buildEntriesForDraw(tx, created.id, drawAt);
      if (!entries.length) {
        return tx.presenceJackpotDraw.update({
          where: { id: created.id },
          data: {
            status: PresenceJackpotStatus.NO_ELIGIBLE,
            metadata: { reason: "no_eligible_player" }
          },
          include: { winner: true, entries: true }
        });
      }

      await tx.presenceJackpotEntry.createMany({ data: entries });
      const winnerId = selectWinner(entries);

      return tx.presenceJackpotDraw.update({
        where: { id: created.id },
        data: {
          winnerId,
          metadata: {
            totalTickets: entries.reduce((total, entry) => total + entry.tickets, 0),
            participantCount: entries.length
          }
        },
        include: { winner: true, entries: true }
      });
    }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
  } catch (error) {
    if (isUniqueConflict(error)) {
      return prisma.presenceJackpotDraw.findUniqueOrThrow({
        where: { drawDate },
        include: { winner: true, entries: true }
      });
    }
    throw error;
  }
}

async function expireClaimWindows(tx: Prisma.TransactionClient, now: Date) {
  await tx.presenceJackpotDraw.updateMany({
    where: {
      status: PresenceJackpotStatus.CLAIMABLE,
      claimUntil: { lte: now },
      claimedAt: null
    },
    data: { status: PresenceJackpotStatus.EXPIRED }
  });
}

async function buildEntriesForDraw(tx: Prisma.TransactionClient, drawId: string, drawAt: Date) {
  const previousDrawAt = addHours(drawAt, -24);
  const wagerWindowStart = addDaysUtc(drawAt, -7);
  const loginEvents = await tx.loginEvent.findMany({
    where: {
      success: true,
      userId: { not: null },
      createdAt: { gt: previousDrawAt, lte: drawAt }
    },
    select: { userId: true, createdAt: true }
  });
  let candidateIds = Array.from(new Set(loginEvents.map((event) => event.userId).filter(Boolean))) as string[];

  if (!candidateIds.length) return [];

  const clientUsers = await tx.user.findMany({
    where: {
      id: { in: candidateIds },
      status: UserStatus.ACTIVE,
      roles: { has: Role.USER }
    },
    select: { id: true }
  });
  candidateIds = clientUsers.map((user) => user.id);

  if (!candidateIds.length) return [];

  const ticketDatesByUser = new Map<string, string[]>();
  for (const event of loginEvents) {
    if (!event.userId) continue;
    const dates = ticketDatesByUser.get(event.userId) ?? [];
    dates.push(localDateKey(event.createdAt));
    ticketDatesByUser.set(event.userId, dates);
  }

  const [casinoStakes, sportStakes, streakEvents] = await Promise.all([
    tx.casinoRound.groupBy({
      by: ["userId"],
      where: {
        userId: { in: candidateIds },
        createdAt: { gte: wagerWindowStart, lte: drawAt }
      },
      _sum: { stake: true }
    }),
    tx.betTicket.groupBy({
      by: ["userId"],
      where: {
        userId: { in: candidateIds },
        placedAt: { gte: wagerWindowStart, lte: drawAt }
      },
      _sum: { stake: true }
    }),
    tx.loginEvent.findMany({
      where: {
        success: true,
        userId: { in: candidateIds },
        createdAt: { gte: addDaysUtc(drawAt, -14), lte: drawAt }
      },
      select: { userId: true, createdAt: true }
    })
  ]);

  const wagers = new Map<string, Prisma.Decimal>();
  for (const stake of casinoStakes) {
    wagers.set(stake.userId, (wagers.get(stake.userId) ?? decimal(0)).plus(stake._sum.stake ?? 0));
  }
  for (const stake of sportStakes) {
    wagers.set(stake.userId, (wagers.get(stake.userId) ?? decimal(0)).plus(stake._sum.stake ?? 0));
  }

  const loginDatesByUser = new Map<string, Set<string>>();
  for (const event of streakEvents) {
    const dates = loginDatesByUser.get(event.userId ?? "") ?? new Set<string>();
    dates.add(localDateKey(event.createdAt));
    if (event.userId) loginDatesByUser.set(event.userId, dates);
  }

  return candidateIds
    .map((userId) => {
      const activeWager = wagers.get(userId) ?? decimal(0);
      const sourceDate = latestDateKey(ticketDatesByUser.get(userId) ?? []) ?? localDateKey(drawAt);
      const streak = countLoginStreak(loginDatesByUser.get(userId) ?? new Set<string>(), sourceDate);
      return {
        drawId,
        userId,
        tickets: streak >= 7 ? 2 : 1,
        loginStreakDays: Math.max(streak, 1),
        activeWagerAmount: activeWager,
        currency: CURRENCY
      };
    })
    .filter((entry) => entry.activeWagerAmount.gte(ACTIVE_WAGER_REQUIREMENT));
}

function selectWinner(entries: { userId: string; tickets: number }[]) {
  const totalTickets = entries.reduce((total, entry) => total + entry.tickets, 0);
  let index = crypto.randomInt(totalTickets);

  for (const entry of entries) {
    if (index < entry.tickets) return entry.userId;
    index -= entry.tickets;
  }

  return entries[0].userId;
}

async function getUserParticipation(userId: string, draw: Awaited<ReturnType<typeof ensureCurrentPresenceDraw>>, nextDrawAt: Date, now: Date) {
  const entry = draw.entries.find((item) => item.userId === userId) ?? null;
  const nextPreview = await getUserNextDrawPreview(userId, nextDrawAt);
  const isWinner = draw.winnerId === userId;
  const canClaim = isWinner && draw.status === PresenceJackpotStatus.CLAIMABLE && draw.claimUntil > now;

  return {
    authenticated: true,
    isWinner,
    canClaim,
    currentEntry: entry
      ? {
          tickets: entry.tickets,
          loginStreakDays: entry.loginStreakDays,
          activeWagerAmount: entry.activeWagerAmount.toString()
        }
      : null,
    nextDraw: nextPreview
  };
}

async function getUserNextDrawPreview(userId: string, nextDrawAt: Date) {
  const previousDrawAt = addHours(nextDrawAt, -24);
  const wagerWindowStart = addDaysUtc(nextDrawAt, -7);
  const [ticketLogins, casino, sport, streakEvents] = await Promise.all([
    prisma.loginEvent.findMany({
      where: {
        userId,
        success: true,
        createdAt: { gt: previousDrawAt, lte: nextDrawAt }
      },
      select: { createdAt: true }
    }),
    prisma.casinoRound.aggregate({
      where: { userId, createdAt: { gte: wagerWindowStart, lte: nextDrawAt } },
      _sum: { stake: true }
    }),
    prisma.betTicket.aggregate({
      where: { userId, placedAt: { gte: wagerWindowStart, lte: nextDrawAt } },
      _sum: { stake: true }
    }),
    prisma.loginEvent.findMany({
      where: { userId, success: true, createdAt: { gte: addDaysUtc(nextDrawAt, -14), lte: nextDrawAt } },
      select: { createdAt: true }
    })
  ]);

  const activeWager = (casino._sum.stake ?? decimal(0)).plus(sport._sum.stake ?? 0);
  const loginDates = new Set(streakEvents.map((event) => localDateKey(event.createdAt)));
  const sourceDate = latestDateKey(ticketLogins.map((event) => localDateKey(event.createdAt))) ?? localDateKey(nextDrawAt);
  const streak = countLoginStreak(loginDates, sourceDate);
  const eligible = ticketLogins.length > 0 && activeWager.gte(ACTIVE_WAGER_REQUIREMENT);

  return {
    hasLoginTicket: ticketLogins.length > 0,
    eligible,
    tickets: eligible ? (streak >= 7 ? 2 : 1) : 0,
    loginStreakDays: streak,
    activeWagerAmount: activeWager.toString(),
    missingWagerAmount: ACTIVE_WAGER_REQUIREMENT.gt(activeWager) ? ACTIVE_WAGER_REQUIREMENT.sub(activeWager).toString() : "0"
  };
}

function nextJackpotAmount(previousAmount: Prisma.Decimal, previousStatus: PresenceJackpotStatus, daySteps: number) {
  let next = previousStatus === PresenceJackpotStatus.CLAIMED ? BASE_AMOUNT : decimal(previousAmount);
  const steps = Math.max(1, daySteps);

  if (previousStatus === PresenceJackpotStatus.CLAIMED) return next;

  for (let step = 0; step < steps; step += 1) {
    next = next.plus(DAILY_INCREASE);
    if (next.gte(RESET_AT)) next = BASE_AMOUNT;
  }

  return next;
}

function getCurrentDrawDateKey(now: Date) {
  const parts = localParts(now);
  const today = `${parts.year}-${parts.month}-${parts.day}`;
  return Number(parts.hour) >= DRAW_HOUR ? today : addDaysToDateKey(today, -1);
}

function getNextDrawDateKey(now: Date) {
  const parts = localParts(now);
  const today = `${parts.year}-${parts.month}-${parts.day}`;
  return Number(parts.hour) >= DRAW_HOUR ? addDaysToDateKey(today, 1) : today;
}

function localParts(date: Date) {
  const parts = new Intl.DateTimeFormat("en-GB", {
    timeZone: TIME_ZONE,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
    second: "2-digit",
    hour12: false
  }).formatToParts(date);
  const value = (type: string) => parts.find((part) => part.type === type)?.value ?? "00";
  return {
    year: value("year"),
    month: value("month"),
    day: value("day"),
    hour: value("hour"),
    minute: value("minute"),
    second: value("second")
  };
}

function localDateKey(date: Date) {
  const parts = localParts(date);
  return `${parts.year}-${parts.month}-${parts.day}`;
}

function zonedDateTimeToUtc(dateKey: string, hour: number) {
  const [year, month, day] = dateKey.split("-").map(Number);
  const targetUtcMs = Date.UTC(year, month - 1, day, hour, 0, 0);
  let candidate = new Date(targetUtcMs);

  for (let index = 0; index < 3; index += 1) {
    const parts = localParts(candidate);
    const candidateLocalAsUtcMs = Date.UTC(
      Number(parts.year),
      Number(parts.month) - 1,
      Number(parts.day),
      Number(parts.hour),
      Number(parts.minute),
      Number(parts.second)
    );
    candidate = new Date(candidate.getTime() - (candidateLocalAsUtcMs - targetUtcMs));
  }

  return candidate;
}

function addHours(date: Date, hours: number) {
  return new Date(date.getTime() + hours * 60 * 60 * 1000);
}

function addDaysUtc(date: Date, days: number) {
  return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
}

function addDaysToDateKey(dateKey: string, days: number) {
  const [year, month, day] = dateKey.split("-").map(Number);
  const date = new Date(Date.UTC(year, month - 1, day + days, 12, 0, 0));
  return date.toISOString().slice(0, 10);
}

function daysBetweenDateKeys(from: string, to: string) {
  const [fromYear, fromMonth, fromDay] = from.split("-").map(Number);
  const [toYear, toMonth, toDay] = to.split("-").map(Number);
  return Math.round((Date.UTC(toYear, toMonth - 1, toDay) - Date.UTC(fromYear, fromMonth - 1, fromDay)) / (24 * 60 * 60 * 1000));
}

function countLoginStreak(dates: Set<string>, endingDateKey: string) {
  let streak = 0;
  let cursor = endingDateKey;

  while (dates.has(cursor)) {
    streak += 1;
    cursor = addDaysToDateKey(cursor, -1);
  }

  return streak;
}

function latestDateKey(dateKeys: string[]) {
  return dateKeys.length ? dateKeys.sort().at(-1) ?? null : null;
}

function isUniqueConflict(error: unknown) {
  return error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002";
}
