import crypto from "node:crypto";
import { CasinoGameKey, CasinoRoundStatus, LedgerEntryType, Prisma } from "@prisma/client";
import { z } from "zod";
import { writeAuditLog } from "@/server/audit";
import { createClientSeed, createServerSeed, serializeRound, sha256 } from "@/server/casino/dice";
import { prisma } from "@/server/db";
import { ConflictError } from "@/server/errors";
import { creditPayoutByStakeSourceInTx, recordWagerInTx } from "@/server/vip/rewards";
import { debitPlayableBalanceInTx, decimal } from "@/server/wallet/ledger";

const TWO_POWER_52 = Number(0x10000000000000n);

export const plinkoRowOptions = [8, 10, 12, 14, 16] as const;
export const plinkoRiskOptions = ["low", "medium", "high"] as const;

export type PlinkoRows = (typeof plinkoRowOptions)[number];
export type PlinkoRisk = (typeof plinkoRiskOptions)[number];
export type PlinkoPathStep = "L" | "R";

export const plinkoConfig = {
  currency: "EUR",
  minStake: "0.01",
  maxStake: "10000",
  maxPayout: "100000",
  defaultRows: 12,
  rowOptions: plinkoRowOptions,
  defaultRisk: "medium" as PlinkoRisk,
  risks: plinkoRiskOptions,
  houseEdgeBps: 100
};

export const plinkoMultipliers: Record<PlinkoRows, Record<PlinkoRisk, number[]>> = {
  8: {
    low: [5.6, 2.1, 1.2, 1, 0.5, 1, 1.2, 2.1, 5.6],
    medium: [13, 3.9, 1.6, 0.7, 0.4, 0.7, 1.6, 3.9, 13],
    high: [29, 7, 2, 0.4, 0.2, 0.4, 2, 7, 29]
  },
  10: {
    low: [8.9, 3, 1.4, 1.1, 0.7, 0.5, 0.7, 1.1, 1.4, 3, 8.9],
    medium: [22, 5, 2, 1.1, 0.5, 0.3, 0.5, 1.1, 2, 5, 22],
    high: [76, 14, 4, 1.4, 0.4, 0.2, 0.4, 1.4, 4, 14, 76]
  },
  12: {
    low: [10, 4, 1.9, 1.3, 0.9, 0.6, 0.4, 0.6, 0.9, 1.3, 1.9, 4, 10],
    medium: [33, 11, 4, 1.9, 1, 0.5, 0.3, 0.5, 1, 1.9, 4, 11, 33],
    high: [170, 24, 8, 2, 0.7, 0.2, 0.1, 0.2, 0.7, 2, 8, 24, 170]
  },
  14: {
    low: [14, 5, 2.2, 1.4, 1, 0.7, 0.5, 0.4, 0.5, 0.7, 1, 1.4, 2.2, 5, 14],
    medium: [56, 18, 5, 2.4, 1.3, 0.6, 0.3, 0.2, 0.3, 0.6, 1.3, 2.4, 5, 18, 56],
    high: [420, 56, 18, 5, 1.5, 0.3, 0.2, 0.1, 0.2, 0.3, 1.5, 5, 18, 56, 420]
  },
  16: {
    low: [16, 9, 3, 1.6, 1.3, 1, 0.7, 0.5, 0.4, 0.5, 0.7, 1, 1.3, 1.6, 3, 9, 16],
    medium: [110, 41, 10, 5, 2, 1.1, 0.5, 0.3, 0.2, 0.3, 0.5, 1.1, 2, 5, 10, 41, 110],
    high: [1000, 130, 26, 9, 4, 2, 0.2, 0.2, 0.1, 0.2, 0.2, 2, 4, 9, 26, 130, 1000]
  }
};

const rowsSchema = z.coerce
  .number()
  .int()
  .refine((value) => plinkoRowOptions.includes(value as PlinkoRows), "Nombre de lignes invalide.")
  .transform((value) => value as PlinkoRows);

const riskSchema = z.enum(plinkoRiskOptions);

export const plinkoPlaySchema = z.object({
  currency: z.string().min(3).max(8).default("EUR"),
  stake: z.coerce.number().positive().max(Number(plinkoConfig.maxStake)),
  rows: rowsSchema.default(plinkoConfig.defaultRows as PlinkoRows),
  risk: riskSchema.default(plinkoConfig.defaultRisk),
  clientRequestId: z.string().uuid().optional()
});

export const plinkoRotateSeedSchema = z.object({
  clientSeed: z.string().trim().min(4).max(96).optional()
});

export const plinkoVerifySchema = z.object({
  clientSeed: z.string().min(1).max(160),
  serverSeed: z.string().min(16).max(256),
  nonce: z.coerce.number().int().min(0).max(10_000_000),
  rows: rowsSchema.default(plinkoConfig.defaultRows as PlinkoRows),
  risk: riskSchema.default(plinkoConfig.defaultRisk)
});

type PlinkoRoundShape = Parameters<typeof serializeRound>[0] & {
  metadata: Prisma.JsonValue | null;
};

export type PlinkoPlayInput = z.infer<typeof plinkoPlaySchema>;

export function calculatePlinkoPath(input: { serverSeed: string; clientSeed: string; nonce: number; rows: PlinkoRows }) {
  const path: PlinkoPathStep[] = [];
  let bucket = 0;

  for (let row = 0; row < input.rows; row += 1) {
    const digest = crypto
      .createHmac("sha256", input.serverSeed)
      .update(`${input.clientSeed}:${input.nonce}:plinko:${row}`)
      .digest("hex");
    const sample = Number(BigInt(`0x${digest.slice(0, 13)}`));
    const uniform = (sample + 1) / (TWO_POWER_52 + 1);
    const step: PlinkoPathStep = uniform >= 0.5 ? "R" : "L";
    if (step === "R") bucket += 1;
    path.push(step);
  }

  return { path, bucket };
}

export function calculatePlinkoMultiplier(input: { rows: PlinkoRows; risk: PlinkoRisk; bucket: number }) {
  const multiplier = plinkoMultipliers[input.rows][input.risk][input.bucket];
  if (!Number.isFinite(multiplier)) {
    throw new ConflictError("Case Plinko invalide.", "PLINKO_INVALID_BUCKET");
  }

  return decimal(multiplier).toDecimalPlaces(4, Prisma.Decimal.ROUND_DOWN);
}

export function calculatePlinkoChance(input: { rows: PlinkoRows; bucket: number }) {
  const probability = binomial(input.rows, input.bucket) / 2 ** input.rows;
  return decimal(probability * 100).toDecimalPlaces(8, Prisma.Decimal.ROUND_DOWN);
}

export async function getPlinkoState(userId: string, currency: string) {
  const seedPair = await ensurePlinkoSeedPair(userId);
  const rounds = await prisma.casinoRound.findMany({
    where: { userId, game: CasinoGameKey.PLINKO },
    orderBy: { createdAt: "desc" },
    take: 25
  });

  const stats = rounds.reduce(
    (acc, round) => {
      acc.stake = acc.stake.plus(round.stake);
      acc.profit = acc.profit.plus(round.profit);
      if (round.status === CasinoRoundStatus.WON) acc.wins += 1;
      if (round.status === CasinoRoundStatus.LOST) acc.losses += 1;
      return acc;
    },
    { stake: decimal(0), profit: decimal(0), wins: 0, losses: 0 }
  );

  return {
    config: {
      ...plinkoConfig,
      multipliers: plinkoMultipliers
    },
    currency,
    seedPair: {
      id: seedPair.id,
      clientSeed: seedPair.clientSeed,
      serverSeedHash: seedPair.serverSeedHash,
      nonce: seedPair.nonce
    },
    stats: {
      stake: stats.stake.toDecimalPlaces(6).toString(),
      profit: stats.profit.toDecimalPlaces(6).toString(),
      wins: stats.wins,
      losses: stats.losses
    },
    recentRounds: rounds.map(serializePlinkoRound)
  };
}

export async function playPlinko(userId: string, input: PlinkoPlayInput) {
  if (input.clientRequestId) {
    const existing = await prisma.casinoRound.findUnique({ where: { clientRequestId: input.clientRequestId } });
    if (existing) {
      if (existing.userId !== userId) {
        throw new ConflictError("Cette requête de jeu existe déjà.", "PLINKO_REQUEST_ID_CONFLICT");
      }
      return serializePlinkoRound(existing);
    }
  }

  return prisma.$transaction(async (tx) => {
    const roundId = crypto.randomUUID();
    const stake = decimal(input.stake).toDecimalPlaces(6);
    if (stake.lt(plinkoConfig.minStake)) {
      throw new ConflictError(`La mise minimum est de ${plinkoConfig.minStake} €.`, "PLINKO_MIN_STAKE");
    }

    const seedPair = await ensurePlinkoSeedPairInTx(tx, userId);
    await tx.$queryRaw`SELECT id FROM "CasinoSeedPair" WHERE id = CAST(${seedPair.id} AS uuid) FOR UPDATE`;

    const lockedSeedPair = await tx.casinoSeedPair.findUniqueOrThrow({ where: { id: seedPair.id } });
    const nonce = lockedSeedPair.nonce;
    const drop = calculatePlinkoPath({
      serverSeed: lockedSeedPair.serverSeed,
      clientSeed: lockedSeedPair.clientSeed,
      nonce,
      rows: input.rows
    });
    const multiplier = calculatePlinkoMultiplier({ rows: input.rows, risk: input.risk, bucket: drop.bucket });
    const chance = calculatePlinkoChance({ rows: input.rows, bucket: drop.bucket });
    const payout = stake.mul(multiplier).toDecimalPlaces(6, Prisma.Decimal.ROUND_DOWN);
    const profit = payout.minus(stake).toDecimalPlaces(6, Prisma.Decimal.ROUND_DOWN);
    const won = profit.gte(0);

    if (payout.gt(plinkoConfig.maxPayout)) {
      throw new ConflictError("Le gain potentiel dépasse la limite autorisée.", "PLINKO_MAX_PAYOUT", {
        maxPayout: plinkoConfig.maxPayout,
        potentialPayout: payout.toString()
      });
    }

    const { wallet, entries, availableDebit, bonusDebit } = await debitPlayableBalanceInTx(tx, {
      userId,
      currency: input.currency,
      amount: stake,
      type: LedgerEntryType.CASINO_STAKE,
      referenceType: "casino_round",
      referenceId: roundId,
      idempotencyKey: input.clientRequestId ? `casino:plinko:${input.clientRequestId}:stake` : undefined,
      reason: "Plinko stake",
      metadata: {
        game: "plinko",
        rows: input.rows,
        risk: input.risk
      }
    });

    if (payout.gt(0)) {
      entries.push(
        ...(await creditPayoutByStakeSourceInTx(tx, {
          userId,
          walletId: wallet.id,
          currency: input.currency,
          stake,
          payout,
          availableDebit,
          bonusDebit,
          type: LedgerEntryType.CASINO_WIN,
          referenceType: "casino_round",
          referenceId: roundId,
          idempotencyKey: input.clientRequestId ? `casino:plinko:${input.clientRequestId}:payout` : undefined,
          reason: "Plinko payout",
          metadata: {
            game: "plinko",
            bucket: drop.bucket,
            multiplier: multiplier.toString()
          }
        }))
      );
    }

    const round = await tx.casinoRound.create({
      data: {
        id: roundId,
        userId,
        walletId: wallet.id,
        seedPairId: lockedSeedPair.id,
        game: CasinoGameKey.PLINKO,
        clientRequestId: input.clientRequestId,
        currency: input.currency,
        stake,
        payout,
        profit,
        multiplier,
        chance,
        target: decimal(input.rows),
        direction: input.risk,
        result: decimal(drop.bucket),
        nonce,
        status: won ? CasinoRoundStatus.WON : CasinoRoundStatus.LOST,
        serverSeedHash: lockedSeedPair.serverSeedHash,
        metadata: {
          rows: input.rows,
          risk: input.risk,
          path: drop.path,
          bucket: drop.bucket,
          stakeSplit: {
            available: availableDebit.toString(),
            bonus: bonusDebit.toString()
          },
          ledgerEntryIds: entries.map((entry) => entry.id)
        }
      }
    });

    await tx.casinoSeedPair.update({
      where: { id: lockedSeedPair.id },
      data: { nonce: { increment: 1 } }
    });

    await recordWagerInTx(tx, {
      userId,
      currency: input.currency,
      stake,
      bonusDebit,
      referenceType: "casino_round",
      referenceId: round.id
    });

    await writeAuditLog(
      {
        actorUserId: userId,
        action: "casino.plinko.play",
        entityType: "CasinoRound",
        entityId: round.id,
        after: {
          stake: stake.toString(),
          payout: payout.toString(),
          profit: profit.toString(),
          bucket: drop.bucket,
          multiplier: multiplier.toString(),
          status: round.status,
          stakeSplit: {
            available: availableDebit.toString(),
            bonus: bonusDebit.toString()
          }
        }
      },
      tx
    );

    return serializePlinkoRound(round);
  }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}

export async function rotatePlinkoSeed(userId: string, input: z.infer<typeof plinkoRotateSeedSchema>) {
  return prisma.$transaction(async (tx) => {
    const current = await ensurePlinkoSeedPairInTx(tx, userId);
    await tx.$queryRaw`SELECT id FROM "CasinoSeedPair" WHERE id = CAST(${current.id} AS uuid) FOR UPDATE`;

    const serverSeed = createServerSeed();
    const clientSeed = input.clientSeed?.trim() || createClientSeed();
    const next = await tx.casinoSeedPair.create({
      data: {
        userId,
        game: CasinoGameKey.PLINKO,
        clientSeed,
        serverSeed,
        serverSeedHash: sha256(serverSeed)
      }
    });

    await tx.casinoSeedPair.update({
      where: { id: current.id },
      data: { active: false, rotatedAt: new Date() }
    });

    return {
      previous: {
        id: current.id,
        clientSeed: current.clientSeed,
        serverSeed: current.serverSeed,
        serverSeedHash: current.serverSeedHash,
        nonce: current.nonce
      },
      current: {
        id: next.id,
        clientSeed: next.clientSeed,
        serverSeedHash: next.serverSeedHash,
        nonce: next.nonce
      }
    };
  });
}

export function verifyPlinko(input: z.infer<typeof plinkoVerifySchema>) {
  const drop = calculatePlinkoPath({
    serverSeed: input.serverSeed,
    clientSeed: input.clientSeed,
    nonce: input.nonce,
    rows: input.rows
  });
  const multiplier = calculatePlinkoMultiplier({ rows: input.rows, risk: input.risk, bucket: drop.bucket });
  const chance = calculatePlinkoChance({ rows: input.rows, bucket: drop.bucket });

  return {
    serverSeedHash: sha256(input.serverSeed),
    rows: input.rows,
    risk: input.risk,
    path: drop.path,
    bucket: drop.bucket,
    multiplier: multiplier.toString(),
    chance: chance.toString()
  };
}

export function serializePlinkoRound(round: PlinkoRoundShape) {
  const base = serializeRound(round);
  const metadata = isJsonObject(round.metadata) ? round.metadata : {};
  const rows = Number(metadata.rows ?? round.target.toString());
  const risk = typeof metadata.risk === "string" ? metadata.risk : round.direction;
  const bucket = Number(metadata.bucket ?? round.result.toString());
  const path = Array.isArray(metadata.path) ? metadata.path.filter((step): step is PlinkoPathStep => step === "L" || step === "R") : [];

  return {
    ...base,
    rows,
    risk,
    path,
    bucket
  };
}

async function ensurePlinkoSeedPair(userId: string) {
  return prisma.$transaction((tx) => ensurePlinkoSeedPairInTx(tx, userId));
}

async function ensurePlinkoSeedPairInTx(tx: Prisma.TransactionClient, userId: string) {
  const existing = await tx.casinoSeedPair.findFirst({
    where: { userId, game: CasinoGameKey.PLINKO, active: true },
    orderBy: { createdAt: "desc" }
  });

  if (existing) {
    return existing;
  }

  const serverSeed = createServerSeed();
  return tx.casinoSeedPair.create({
    data: {
      userId,
      game: CasinoGameKey.PLINKO,
      clientSeed: createClientSeed(),
      serverSeed,
      serverSeedHash: sha256(serverSeed)
    }
  });
}

function binomial(n: number, k: number) {
  if (k < 0 || k > n) return 0;
  const effectiveK = Math.min(k, n - k);
  let result = 1;
  for (let index = 1; index <= effectiveK; index += 1) {
    result = (result * (n - effectiveK + index)) / index;
  }
  return result;
}

function isJsonObject(value: Prisma.JsonValue | null): value is Prisma.JsonObject {
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
