import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import type { RowDataPacket } from "mysql2/promise";
import type { Transporter } from "nodemailer";
import { createHash, randomUUID } from "node:crypto";
import type { RouteContext } from "./types.js";
import { ensureWallet } from "../server/db-schema.js";
import { fetchWallet } from "../server/data-fetchers.js";
import { getUserById } from "../server/user-service.js";
import { buildContactEmail } from "../server/email-service.js";
import { recordAudit, recordWalletChange } from "../server/admin-service.js";

const paramsUserIdSchema = {
  type: "object" as const,
  required: ["userId"],
  properties: { userId: { type: "string", pattern: "^[0-9]+$" } },
};

const bodyUserIdSchema = {
  type: "object" as const,
  properties: { userId: { type: "number" as const } },
};

const DEFAULT_CONSENT_VERSION = "2026-02";

type PrivacyConsentState = {
  consentVersion: string;
  consentStatus: "pending" | "accepted" | "rejected" | "custom";
  hasChoice: boolean;
  adsConsent: boolean;
  personalizedAdsConsent: boolean;
  analyticsConsent: boolean;
  consentSource: string;
  grantedAt: string | null;
  updatedAt: string | null;
};

const normalizeDate = (value: unknown): string | null => {
  if (typeof value === "string" && value.trim()) {
    return value;
  }
  if (value instanceof Date && Number.isFinite(value.getTime())) {
    return value.toISOString();
  }
  return null;
};

const fetchPrivacyConsentState = async (
  db: RouteContext["db"],
  userId: number,
): Promise<PrivacyConsentState> => {
  const [rows] = await db.execute<RowDataPacket[]>(
    `SELECT
      consent_version,
      consent_status,
      ads_consent,
      personalized_ads_consent,
      analytics_consent,
      consent_source,
      granted_at,
      updated_at
     FROM user_privacy_consents
     WHERE user_id = ?
     LIMIT 1`,
    [userId],
  );
  const row = rows[0];
  if (!row) {
    return {
      consentVersion: DEFAULT_CONSENT_VERSION,
      consentStatus: "pending",
      hasChoice: false,
      adsConsent: false,
      personalizedAdsConsent: false,
      analyticsConsent: false,
      consentSource: "app",
      grantedAt: null,
      updatedAt: null,
    };
  }

  const rowStatus =
    typeof row.consent_status === "string" ? row.consent_status : "pending";
  const consentStatus: PrivacyConsentState["consentStatus"] =
    rowStatus === "accepted" ||
    rowStatus === "rejected" ||
    rowStatus === "custom"
      ? rowStatus
      : "pending";

  return {
    consentVersion:
      typeof row.consent_version === "string" && row.consent_version.trim()
        ? row.consent_version
        : DEFAULT_CONSENT_VERSION,
    consentStatus,
    hasChoice: true,
    adsConsent: Boolean(row.ads_consent),
    personalizedAdsConsent: Boolean(row.personalized_ads_consent),
    analyticsConsent: Boolean(row.analytics_consent),
    consentSource:
      typeof row.consent_source === "string" && row.consent_source.trim()
        ? row.consent_source
        : "app",
    grantedAt: normalizeDate(row.granted_at),
    updatedAt: normalizeDate(row.updated_at),
  };
};

type DbExecutor = {
  execute: RouteContext["db"]["execute"];
};

const fetchAdsConsent = async (
  db: DbExecutor,
  userId: number,
): Promise<boolean> => {
  const [rows] = await db.execute<RowDataPacket[]>(
    "SELECT ads_consent FROM user_privacy_consents WHERE user_id = ? LIMIT 1",
    [userId],
  );
  return Boolean(rows[0]?.ads_consent);
};

const fetchAdsUiEnabled = async (db: DbExecutor): Promise<boolean> => {
  const [rows] = await db.execute<RowDataPacket[]>(
    "SELECT config_value FROM app_config WHERE config_key = 'ads_ui_enabled' LIMIT 1",
  );
  const raw = rows[0]?.config_value;
  if (typeof raw !== "string") {
    return true;
  }
  const value = raw.trim().toLowerCase();
  if (!value) {
    return true;
  }
  return value === "1" || value === "true";
};

const requireAdsUiEnabled = async (
  db: DbExecutor,
  reply: FastifyReply,
): Promise<boolean> => {
  const enabled = await fetchAdsUiEnabled(db);
  if (enabled) {
    return true;
  }
  reply.code(503);
  reply.send({
    error: "Publicites temporairement masquees.",
  });
  return false;
};

const requireAdsConsent = async (
  db: DbExecutor,
  userId: number,
  reply: FastifyReply,
): Promise<boolean> => {
  const allowed = await fetchAdsConsent(db, userId);
  if (allowed) {
    return true;
  }
  reply.code(403);
  reply.send({
    error:
      "Consentement publicitaire requis. Ouvre Parametres > Centre de confidentialite.",
  });
  return false;
};

// Constantes AdMob
const MAX_DAILY_ADS = 10;
const HINTS_PER_AD = 1;
const BONUS_SOLUTION_AT = 10;

type AdEventType = "daily_reward" | "double_points" | "free_solution";
type AdEventSource = "ad" | "vip";
type AdRewardStatus = "started" | "claimed" | "expired" | "rejected";

type AdRewardEventInput = {
  request: FastifyRequest;
  userId: number;
  rewardDate: string;
  adType: AdEventType;
  source?: AdEventSource;
  rewardStatus?: AdRewardStatus;
  sessionNonce?: string | null;
  idempotencyKey?: string | null;
  rewardAmount?: number;
  watchedCountAfter?: number | null;
  expiresAt?: Date | string | null;
  claimedAt?: Date | string | null;
  meta?: Record<string, unknown>;
};

const recordAdRewardEvent = async (
  db: RouteContext["db"],
  input: AdRewardEventInput,
): Promise<void> => {
  const {
    request,
    userId,
    rewardDate,
    adType,
    source = "ad",
    rewardStatus = "claimed",
    sessionNonce = null,
    idempotencyKey = null,
    rewardAmount = 0,
    watchedCountAfter = null,
    expiresAt = null,
    claimedAt = null,
    meta,
  } = input;

  try {
    await db.execute(
      `INSERT INTO ad_reward_events (
        user_id,
        reward_date,
        ad_type,
        source,
        reward_status,
        session_nonce,
        idempotency_key,
        reward_amount,
        watched_count_after,
        expires_at,
        claimed_at,
        meta,
        ip,
        user_agent,
        installation_id
      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      [
        userId,
        rewardDate,
        adType,
        source,
        rewardStatus,
        sessionNonce,
        idempotencyKey,
        rewardAmount,
        watchedCountAfter,
        expiresAt,
        claimedAt,
        meta ? JSON.stringify(meta) : null,
        request.ip,
        request.headers["user-agent"]?.toString() ?? null,
        request.headers["x-installation-id"]?.toString() ?? null,
      ],
    );
  } catch (error) {
    request.log.warn(
      { err: error, userId, adType },
      "ad_reward_event_insert_failed",
    );
  }
};

const createAdSessionNonce = (): string => randomUUID().replace(/-/g, "");

const toComparableDate = (value: unknown): Date | null => {
  if (value instanceof Date && Number.isFinite(value.getTime())) {
    return value;
  }
  if (typeof value === "string" && value.trim()) {
    const normalized = value.includes("T")
      ? value
      : value.replace(" ", "T");
    const parsed = new Date(normalized);
    if (Number.isFinite(parsed.getTime())) {
      return parsed;
    }
  }
  return null;
};

const getTodayDateKey = (value: Date = new Date()): string =>
  `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;

const defaultAdIdempotencyKey = (userId: number, sessionNonce: string): string =>
  createHash("sha256").update(`${userId}:${sessionNonce}`).digest("hex");

export const registerContactRoutes = (
  app: FastifyInstance,
  ctx: RouteContext,
) => {
  const { db, config, mailer, requireAuth } = ctx;

  // POST /contact
  app.post(
    "/contact",
    {
      preHandler: requireAuth,
      schema: {
        body: {
          type: "object",
          required: ["message"],
          additionalProperties: false,
          properties: {
            subject: { type: "string", maxLength: 200 },
            message: { type: "string", minLength: 10, maxLength: 5000 },
          },
        },
      },
      config: {
        rateLimit: {
          max: 5,
          timeWindow: "1 hour",
        },
      },
    },
    async (request, reply) => {
      const tokenUserId = request.user?.sub;
      if (!tokenUserId) {
        reply.code(401);
        return { error: "Authentification requise" };
      }
      const body = request.body as
        | { subject?: string; message?: string }
        | undefined;
      const message = body?.message?.trim();
      const subject = body?.subject?.trim() || undefined;
      if (!message || message.length < 10) {
        reply.code(400);
        return { error: "Message trop court (minimum 10 caracteres)" };
      }
      const user = await getUserById(db, tokenUserId);
      if (!user) {
        reply.code(404);
        return { error: "Utilisateur introuvable" };
      }
      if (user.guest) {
        reply.code(403);
        return { error: "Fonctionnalite reservee aux comptes inscrits" };
      }
      await recordAudit(db, {
        userId: tokenUserId,
        action: "contact.send",
        meta: { subject: subject ?? null, length: message.length },
        ip: request.ip,
        userAgent: request.headers["user-agent"]?.toString() ?? null,
        installationId:
          request.headers["x-installation-id"]?.toString() ?? null,
      });
      if (!mailer) {
        request.log.warn("contact_email_skipped: no mailer configured");
        return { ok: true, sent: false };
      }
      try {
        const email = buildContactEmail({
          userId: user.id,
          email: user.email,
          displayName: user.displayName,
          subject,
          message,
          ip: request.ip ?? null,
          userAgent: request.headers["user-agent"]?.toString() ?? null,
        });
        const fromEmail = config.smtp.fromEmail ?? "no-reply@rollerlogic.app";
        const fromLabel = config.smtp.fromName
          ? `${config.smtp.fromName} <${fromEmail}>`
          : fromEmail;
        await mailer.sendMail({
          from: fromLabel,
          to: fromEmail,
          replyTo: user.email,
          subject: `[Contact] ${email.subject}`,
          text: email.text,
          html: email.html,
        });
        return { ok: true, sent: true };
      } catch (error) {
        request.log.error({ err: error }, "contact_email_failed");
        reply.code(500);
        return { error: "Echec de l'envoi, reessaie plus tard" };
      }
    },
  );
};

export const registerAdsRoutes = (app: FastifyInstance, ctx: RouteContext) => {
  const { db, requireAuth, config } = ctx;
  const adsEnabled = Boolean(config.ads?.enabled);

  // POST /ads/reward/start
  app.post(
    "/ads/reward/start",
    {
      preHandler: requireAuth,
      schema: {
        body: {
          type: "object",
          required: ["userId", "rewardType"],
          additionalProperties: false,
          properties: {
            userId: { type: "number" },
            rewardType: {
              type: "string",
              enum: ["daily_reward", "double_points", "free_solution"],
            },
            source: { type: "string", enum: ["ad", "vip"] },
            basePoints: { type: "number", minimum: 1, maximum: 100 },
            gameMode: { type: "string", minLength: 1, maxLength: 20 },
            difficulty: { type: "string", minLength: 1, maxLength: 20 },
            levelIndex: { type: "number", minimum: 0, maximum: 999 },
          },
        },
      },
      config: {
        rateLimit: {
          max: 50,
          timeWindow: "1 minute",
        },
      },
    },
    async (request, reply) => {
      const body = request.body as
        | {
            userId?: number;
            rewardType?: AdEventType;
            source?: AdEventSource;
            basePoints?: number;
            gameMode?: string;
            difficulty?: string;
            levelIndex?: number;
          }
        | undefined;

      const userId = Number(body?.userId);
      const tokenUserId = request.user?.sub;
      const rewardType = body?.rewardType;
      const source: AdEventSource =
        body?.source ?? (rewardType === "double_points" ? "ad" : "ad");
      const today = getTodayDateKey();
      const expiresAt = new Date(Date.now() + 10 * 60 * 1000);

      if (!Number.isFinite(userId) || !rewardType) {
        reply.code(400);
        return { error: "Payload invalide" };
      }
      if (!tokenUserId || tokenUserId !== userId) {
        reply.code(403);
        return { error: "Acces refuse" };
      }
      if (
        (rewardType === "daily_reward" || rewardType === "free_solution") &&
        source !== "ad"
      ) {
        reply.code(400);
        return { error: "Source invalide pour ce type de récompense" };
      }
      if (!adsEnabled && source === "ad") {
        reply.code(503);
        return { error: "Publicités désactivées" };
      }
      if (source === "ad" && !(await requireAdsUiEnabled(db, reply))) {
        return;
      }
      if (source === "ad" && !(await requireAdsConsent(db, userId, reply))) {
        return;
      }

      let rewardAmount = 0;
      if (rewardType === "daily_reward") {
        rewardAmount = HINTS_PER_AD;
        const [rows] = await db.execute<RowDataPacket[]>(
          "SELECT hints_earned FROM ad_rewards WHERE user_id = ? AND reward_date = ?",
          [userId, today],
        );
        const watchedCount = rows[0]?.hints_earned ?? 0;
        if (watchedCount >= MAX_DAILY_ADS) {
          reply.code(429);
          return { error: "Limite quotidienne atteinte", remainingAds: 0 };
        }
      }

      if (rewardType === "double_points") {
        rewardAmount = Number(body?.basePoints);
        if (
          !Number.isFinite(rewardAmount) ||
          rewardAmount < 1 ||
          rewardAmount > 100
        ) {
          reply.code(400);
          return { error: "basePoints invalide" };
        }
        if (source === "vip") {
          const [vipRows] = await db.execute<RowDataPacket[]>(
            "SELECT vip_no_ads, vip_expires_at FROM users WHERE id = ? LIMIT 1",
            [userId],
          );
          const row = vipRows[0];
          const isVipActive =
            Boolean(row?.vip_no_ads) &&
            (!row?.vip_expires_at || new Date(row.vip_expires_at) > new Date());
          if (!isVipActive) {
            reply.code(403);
            return { error: "VIP requis ou expiré" };
          }
        }
      }

      const sessionNonce = createAdSessionNonce();
      const meta: Record<string, unknown> = {
        stage: "start",
        gameMode:
          typeof body?.gameMode === "string" && body.gameMode.trim()
            ? body.gameMode.trim()
            : undefined,
        difficulty:
          typeof body?.difficulty === "string" && body.difficulty.trim()
            ? body.difficulty.trim()
            : undefined,
        levelIndex:
          typeof body?.levelIndex === "number" && Number.isFinite(body.levelIndex)
            ? body.levelIndex
            : undefined,
      };
      if (rewardType === "double_points") {
        meta.basePoints = rewardAmount;
      }

      await recordAdRewardEvent(db, {
        request,
        userId,
        rewardDate: today,
        adType: rewardType,
        source,
        rewardStatus: "started",
        sessionNonce,
        rewardAmount,
        expiresAt,
        meta,
      });

      return {
        ok: true,
        rewardType,
        source,
        sessionNonce,
        expiresAt: expiresAt.toISOString(),
        expectedRewardAmount: rewardAmount,
      };
    },
  );

  // POST /ads/reward/claim
  app.post(
    "/ads/reward/claim",
    {
      preHandler: requireAuth,
      schema: {
        body: {
          type: "object",
          required: ["userId", "rewardType", "sessionNonce"],
          additionalProperties: false,
          properties: {
            userId: { type: "number" },
            rewardType: {
              type: "string",
              enum: ["daily_reward", "double_points", "free_solution"],
            },
            sessionNonce: { type: "string", minLength: 16, maxLength: 96 },
            idempotencyKey: { type: "string", minLength: 8, maxLength: 128 },
          },
        },
      },
      config: {
        rateLimit: {
          max: 50,
          timeWindow: "1 minute",
        },
      },
    },
    async (request, reply) => {
      const body = request.body as
        | {
            userId?: number;
            rewardType?: AdEventType;
            sessionNonce?: string;
            idempotencyKey?: string;
          }
        | undefined;
      const userId = Number(body?.userId);
      const tokenUserId = request.user?.sub;
      const rewardType = body?.rewardType;
      const sessionNonce = body?.sessionNonce?.trim();
      const idempotencyKey =
        typeof body?.idempotencyKey === "string" && body.idempotencyKey.trim()
          ? body.idempotencyKey.trim()
          : undefined;

      if (!Number.isFinite(userId) || !rewardType || !sessionNonce) {
        reply.code(400);
        return { error: "Payload invalide" };
      }
      if (!tokenUserId || tokenUserId !== userId) {
        reply.code(403);
        return { error: "Acces refuse" };
      }

      const connection = await db.getConnection();
      try {
        await connection.beginTransaction();

        const [sessionRows] = await connection.execute<RowDataPacket[]>(
          `SELECT
             id,
             DATE_FORMAT(reward_date, '%Y-%m-%d') as rewardDate,
             ad_type as adType,
             source,
             reward_status as rewardStatus,
             reward_amount as rewardAmount,
             session_nonce as sessionNonce,
             idempotency_key as idempotencyKey,
             watched_count_after as watchedCountAfter,
             expires_at as expiresAt,
             claimed_at as claimedAt,
             meta
           FROM ad_reward_events
           WHERE user_id = ? AND session_nonce = ?
           LIMIT 1
           FOR UPDATE`,
          [userId, sessionNonce],
        );
        const sessionRow = sessionRows[0];
        if (!sessionRow) {
          await connection.rollback();
          reply.code(404);
          return { error: "Session pub introuvable" };
        }

        const sessionId = Number(sessionRow.id);
        const sessionRewardType = sessionRow.adType as AdEventType;
        const source = sessionRow.source as AdEventSource;
        const rewardStatus = sessionRow.rewardStatus as AdRewardStatus;
        const rewardDate =
          typeof sessionRow.rewardDate === "string"
            ? sessionRow.rewardDate
            : getTodayDateKey();

        if (sessionRewardType !== rewardType) {
          await connection.rollback();
          reply.code(400);
          return { error: "Session incompatible avec ce type de récompense" };
        }

        if (!adsEnabled && source === "ad") {
          await connection.rollback();
          reply.code(503);
          return { error: "Publicités désactivées" };
        }
        if (source === "ad" && !(await requireAdsUiEnabled(connection, reply))) {
          await connection.rollback();
          return;
        }
        if (source === "ad") {
          const consentGranted = await fetchAdsConsent(connection, userId);
          if (!consentGranted) {
            await connection.execute(
              "UPDATE ad_reward_events SET reward_status = 'rejected', claimed_at = NOW() WHERE id = ?",
              [sessionId],
            );
            await connection.commit();
            reply.code(403);
            return {
              error:
                "Consentement publicitaire requis. Ouvre Parametres > Centre de confidentialite.",
            };
          }
        }

        if (rewardStatus === "claimed") {
          await connection.commit();
          const wallet = await fetchWallet(db, userId);
          if (rewardType === "daily_reward") {
            const [rows] = await db.execute<RowDataPacket[]>(
              "SELECT hints_earned FROM ad_rewards WHERE user_id = ? AND reward_date = ?",
              [userId, rewardDate],
            );
            const watchedCount = rows[0]?.hints_earned ?? 0;
            const hintsEarned = rows[0]?.hints_earned ?? 0;
            return {
              ok: true,
              alreadyClaimed: true,
              rewardType,
              hintsEarned: HINTS_PER_AD,
              bonusSolution: watchedCount >= BONUS_SOLUTION_AT,
              remainingAds: Math.max(0, MAX_DAILY_ADS - watchedCount),
              watchedCount,
              hintsEarnedToday: hintsEarned,
              wallet,
            };
          }
          if (rewardType === "double_points") {
            const bonusPoints = Number(sessionRow.rewardAmount) || 0;
            return {
              ok: true,
              alreadyClaimed: true,
              rewardType,
              bonusPoints,
              totalPoints: bonusPoints * 2,
              wallet,
            };
          }
          return { ok: true, alreadyClaimed: true, rewardType };
        }

        if (rewardStatus !== "started") {
          await connection.rollback();
          reply.code(409);
          return { error: "Session pub déjà utilisée ou invalide" };
        }

        const expiresAtDate = toComparableDate(sessionRow.expiresAt);
        if (expiresAtDate && expiresAtDate.getTime() < Date.now()) {
          await connection.execute(
            "UPDATE ad_reward_events SET reward_status = 'expired' WHERE id = ?",
            [sessionId],
          );
          await connection.commit();
          reply.code(410);
          return { error: "Session pub expirée" };
        }

        const resolvedIdempotencyKey =
          idempotencyKey ?? defaultAdIdempotencyKey(userId, sessionNonce);
        let watchedCountAfter: number | null = null;
        let claimMeta: Record<string, unknown> = {};

        if (rewardType === "daily_reward") {
          const [rows] = await connection.execute<RowDataPacket[]>(
            "SELECT hints_earned FROM ad_rewards WHERE user_id = ? AND reward_date = ?",
            [userId, rewardDate],
          );
          const currentCount = rows[0]?.hints_earned ?? 0;
          if (currentCount >= MAX_DAILY_ADS) {
            await connection.execute(
              "UPDATE ad_reward_events SET reward_status = 'rejected', idempotency_key = ?, claimed_at = NOW() WHERE id = ?",
              [resolvedIdempotencyKey, sessionId],
            );
            await connection.commit();
            reply.code(429);
            return { error: "Limite quotidienne atteinte", remainingAds: 0 };
          }

          watchedCountAfter = currentCount + 1;
          const isBonusSolution = watchedCountAfter === BONUS_SOLUTION_AT;
          await connection.execute(
            `INSERT INTO ad_rewards (user_id, reward_date, watched_count, hints_earned, last_watched_at)
             VALUES (?, ?, 1, ?, NOW())
             ON DUPLICATE KEY UPDATE
               watched_count = watched_count + 1,
               hints_earned = hints_earned + ?,
               last_watched_at = NOW()`,
            [userId, rewardDate, HINTS_PER_AD, HINTS_PER_AD],
          );

          await connection.execute(
            "INSERT IGNORE INTO wallets (user_id) VALUES (?)",
            [userId],
          );
          await connection.execute(
            "UPDATE wallets SET hints = hints + ? WHERE user_id = ?",
            [HINTS_PER_AD, userId],
          );
          if (isBonusSolution) {
            await connection.execute(
              "UPDATE wallets SET replays = replays + 1 WHERE user_id = ?",
              [userId],
            );
          }
          const adChanges: { resource: "hints" | "replays"; delta: number }[] = [
            { resource: "hints", delta: HINTS_PER_AD },
          ];
          if (isBonusSolution) adChanges.push({ resource: "replays", delta: 1 });
          await recordWalletChange(
            connection,
            userId,
            adChanges,
            "ads.reward",
            { bonusSolution: isBonusSolution, via: "claim" },
          );
          await recordAudit(connection, {
            userId,
            action: "ads.daily_reward.claim",
            meta: { hints: HINTS_PER_AD, bonusSolution: isBonusSolution },
            ip: request.ip,
            userAgent: request.headers["user-agent"]?.toString() ?? null,
            installationId:
              request.headers["x-installation-id"]?.toString() ?? null,
          });
          claimMeta = {
            hintsEarned: HINTS_PER_AD,
            bonusSolution: isBonusSolution,
          };
        } else if (rewardType === "double_points") {
          const basePoints = Number(sessionRow.rewardAmount);
          if (
            !Number.isFinite(basePoints) ||
            basePoints < 1 ||
            basePoints > 100
          ) {
            await connection.execute(
              "UPDATE ad_reward_events SET reward_status = 'rejected', idempotency_key = ?, claimed_at = NOW() WHERE id = ?",
              [resolvedIdempotencyKey, sessionId],
            );
            await connection.commit();
            reply.code(400);
            return { error: "Session double points invalide" };
          }

          if (source === "vip") {
            const [vipRows] = await connection.execute<RowDataPacket[]>(
              "SELECT vip_no_ads, vip_expires_at FROM users WHERE id = ? LIMIT 1",
              [userId],
            );
            const row = vipRows[0];
            const isVipActive =
              Boolean(row?.vip_no_ads) &&
              (!row?.vip_expires_at || new Date(row.vip_expires_at) > new Date());
            if (!isVipActive) {
              await connection.execute(
                "UPDATE ad_reward_events SET reward_status = 'rejected', idempotency_key = ?, claimed_at = NOW() WHERE id = ?",
                [resolvedIdempotencyKey, sessionId],
              );
              await connection.commit();
              reply.code(403);
              return { error: "VIP requis ou expiré" };
            }
          } else {
            const [rows] = await connection.execute<RowDataPacket[]>(
              "SELECT watched_count FROM ad_rewards WHERE user_id = ? AND reward_date = ?",
              [userId, rewardDate],
            );
            const currentCount = rows[0]?.watched_count ?? 0;
            watchedCountAfter = currentCount + 1;
            await connection.execute(
              `INSERT INTO ad_rewards (user_id, reward_date, watched_count, bonus_points_earned, last_watched_at)
               VALUES (?, ?, 1, ?, NOW())
               ON DUPLICATE KEY UPDATE
                 watched_count = watched_count + 1,
                 bonus_points_earned = bonus_points_earned + ?,
                 last_watched_at = NOW()`,
              [userId, rewardDate, basePoints, basePoints],
            );
          }

          await connection.execute(
            "INSERT IGNORE INTO wallets (user_id) VALUES (?)",
            [userId],
          );
          await connection.execute(
            "UPDATE wallets SET points = points + ? WHERE user_id = ?",
            [basePoints, userId],
          );
          await recordWalletChange(
            connection,
            userId,
            [{ resource: "points", delta: basePoints }],
            "ads.double_points",
            { source, basePoints, via: "claim" },
          );
          await recordAudit(connection, {
            userId,
            action:
              source === "vip" ? "vip.double_points.claim" : "ads.double_points.claim",
            meta: {
              basePoints,
              bonusPoints: basePoints,
              totalPoints: basePoints * 2,
            },
            ip: request.ip,
            userAgent: request.headers["user-agent"]?.toString() ?? null,
            installationId:
              request.headers["x-installation-id"]?.toString() ?? null,
          });
          claimMeta = {
            bonusPoints: basePoints,
            totalPoints: basePoints * 2,
          };
        } else {
          await recordAudit(connection, {
            userId,
            action: "ads.free_solution.claim",
            meta: {},
            ip: request.ip,
            userAgent: request.headers["user-agent"]?.toString() ?? null,
            installationId:
              request.headers["x-installation-id"]?.toString() ?? null,
          });
          claimMeta = { freeSolution: true };
        }

        let startMeta: Record<string, unknown> = {};
        if (sessionRow.meta && typeof sessionRow.meta === "string") {
          try {
            startMeta = JSON.parse(sessionRow.meta) as Record<string, unknown>;
          } catch {
            startMeta = {};
          }
        } else if (
          sessionRow.meta &&
          typeof sessionRow.meta === "object" &&
          !Array.isArray(sessionRow.meta)
        ) {
          startMeta = sessionRow.meta as Record<string, unknown>;
        }

        await connection.execute(
          `UPDATE ad_reward_events
           SET reward_status = 'claimed',
               idempotency_key = ?,
               watched_count_after = ?,
               claimed_at = NOW(),
               meta = ?
           WHERE id = ?`,
          [
            resolvedIdempotencyKey,
            watchedCountAfter,
            JSON.stringify({
              ...startMeta,
              claim: claimMeta,
              claimedAt: new Date().toISOString(),
            }),
            sessionId,
          ],
        );

        await connection.commit();

        const wallet = await fetchWallet(db, userId);
        if (rewardType === "daily_reward") {
          const [rows] = await db.execute<RowDataPacket[]>(
            "SELECT hints_earned FROM ad_rewards WHERE user_id = ? AND reward_date = ?",
            [userId, rewardDate],
          );
          const watchedCount = rows[0]?.hints_earned ?? 0;
          return {
            ok: true,
            rewardType,
            hintsEarned: HINTS_PER_AD,
            bonusSolution: watchedCount >= BONUS_SOLUTION_AT,
            remainingAds: Math.max(0, MAX_DAILY_ADS - watchedCount),
            watchedCount,
            hintsEarnedToday: rows[0]?.hints_earned ?? watchedCount,
            wallet,
          };
        }
        if (rewardType === "double_points") {
          const bonusPoints = Number(sessionRow.rewardAmount) || 0;
          return {
            ok: true,
            rewardType,
            bonusPoints,
            totalPoints: bonusPoints * 2,
            wallet,
          };
        }
        return { ok: true, rewardType };
      } catch (error) {
        await connection.rollback();
        request.log.error({ err: error, userId, rewardType }, "ads_claim_failed");
        reply.code(500);
        return { error: "Erreur lors de la validation de la récompense" };
      } finally {
        connection.release();
      }
    },
  );

  // GET /ads/daily/:userId
  app.get(
    "/ads/daily/:userId",
    {
      preHandler: requireAuth,
      schema: {
        params: paramsUserIdSchema,
      },
    },
    async (request, reply) => {
      const userId = Number((request.params as { userId: string }).userId);
      const tokenUserId = request.user?.sub;
      if (!Number.isFinite(userId)) {
        reply.code(400);
        return { error: "userId invalide" };
      }
      if (!tokenUserId || tokenUserId !== userId) {
        reply.code(403);
        return { error: "Acces refuse" };
      }

      const today = getTodayDateKey();
      const adsUiEnabled = await fetchAdsUiEnabled(db);
      if (!adsEnabled || !adsUiEnabled) {
        return {
          date: today,
          watchedCount: 0,
          hintsEarned: 0,
          bonusPointsEarned: 0,
          remainingAds: 0,
          maxDailyAds: MAX_DAILY_ADS,
          adsEnabled: false,
        };
      }

      const [rows] = await db.execute<import("mysql2/promise").RowDataPacket[]>(
        "SELECT hints_earned, bonus_points_earned FROM ad_rewards WHERE user_id = ? AND reward_date = ?",
        [userId, today],
      );
      const row = rows[0];
      const watchedCount = row?.hints_earned ?? 0;
      return {
        date: today,
        watchedCount,
        hintsEarned: row?.hints_earned ?? 0,
        bonusPointsEarned: row?.bonus_points_earned ?? 0,
        remainingAds: Math.max(0, MAX_DAILY_ADS - watchedCount),
        maxDailyAds: MAX_DAILY_ADS,
      };
    },
  );

  // POST /ads/daily/reward
  app.post(
    "/ads/daily/reward",
    {
      preHandler: requireAuth,
      schema: {
        body: {
          ...bodyUserIdSchema,
          required: ["userId"],
        },
      },
    },
    async (request, reply) => {
      if (!adsEnabled) {
        reply.code(503);
        return { error: "Publicités désactivées" };
      }
      if (!(await requireAdsUiEnabled(db, reply))) {
        return;
      }
      const body = request.body as { userId?: number } | undefined;
      const userId = Number(body?.userId);
      const tokenUserId = request.user?.sub;
      if (!Number.isFinite(userId)) {
        reply.code(400);
        return { error: "userId invalide" };
      }
      if (!tokenUserId || tokenUserId !== userId) {
        reply.code(403);
        return { error: "Acces refuse" };
      }
      if (!(await requireAdsConsent(db, userId, reply))) {
        return;
      }

      const today = getTodayDateKey();

      const [rows] = await db.execute<import("mysql2/promise").RowDataPacket[]>(
        "SELECT hints_earned FROM ad_rewards WHERE user_id = ? AND reward_date = ?",
        [userId, today],
      );
      const currentCount = rows[0]?.hints_earned ?? 0;
      if (currentCount >= MAX_DAILY_ADS) {
        reply.code(429);
        return { error: "Limite quotidienne atteinte", remainingAds: 0 };
      }

      const newCount = currentCount + 1;
      const isBonusSolution = newCount === BONUS_SOLUTION_AT;

      await db.execute(
        `INSERT INTO ad_rewards (user_id, reward_date, watched_count, hints_earned, last_watched_at)
         VALUES (?, ?, 1, ?, NOW())
         ON DUPLICATE KEY UPDATE
           watched_count = watched_count + 1,
           hints_earned = hints_earned + ?,
           last_watched_at = NOW()`,
        [userId, today, HINTS_PER_AD, HINTS_PER_AD],
      );

      await ensureWallet(db, userId);
      await db.execute(
        "UPDATE wallets SET hints = hints + ? WHERE user_id = ?",
        [HINTS_PER_AD, userId],
      );

      if (isBonusSolution) {
        await db.execute(
          "UPDATE wallets SET replays = replays + 1 WHERE user_id = ?",
          [userId],
        );
      }

      const adChanges: { resource: "hints" | "replays"; delta: number }[] = [
        { resource: "hints", delta: HINTS_PER_AD },
      ];
      if (isBonusSolution) adChanges.push({ resource: "replays", delta: 1 });
      await recordWalletChange(db, userId, adChanges, "ads.reward", {
        bonusSolution: isBonusSolution,
      });

      await recordAudit(db, {
        userId,
        action: "ads.daily_reward",
        meta: { hints: HINTS_PER_AD, bonusSolution: isBonusSolution },
        ip: request.ip,
        userAgent: request.headers["user-agent"]?.toString() ?? null,
        installationId:
          request.headers["x-installation-id"]?.toString() ?? null,
      });

      const wallet = await fetchWallet(db, userId);
      await recordAdRewardEvent(db, {
        request,
        userId,
        rewardDate: today,
        adType: "daily_reward",
        source: "ad",
        rewardAmount: HINTS_PER_AD,
        watchedCountAfter: newCount,
        meta: {
          hintsEarned: HINTS_PER_AD,
          bonusSolution: isBonusSolution,
        },
      });
      request.log.info(
        {
          userId,
          hintsEarned: HINTS_PER_AD,
          bonusSolution: isBonusSolution,
          watchedCount: newCount,
          remainingAds: MAX_DAILY_ADS - newCount,
          walletHints: wallet.inventory.hints,
          walletReplays: wallet.inventory.replays,
        },
        "ads_daily_reward_granted",
      );
      return {
        ok: true,
        hintsEarned: HINTS_PER_AD,
        bonusSolution: isBonusSolution,
        remainingAds: MAX_DAILY_ADS - newCount,
        watchedCount: newCount,
        wallet,
      };
    },
  );

  // POST /ads/double-points
  app.post(
    "/ads/double-points",
    {
      preHandler: requireAuth,
      schema: {
        body: {
          ...bodyUserIdSchema,
          required: ["userId", "basePoints"],
          properties: {
            ...bodyUserIdSchema.properties,
            basePoints: { type: "number", minimum: 1, maximum: 100 },
            source: { type: "string", enum: ["ad", "vip"] },
          },
        },
      },
      config: {
        rateLimit: {
          max: 30,
          timeWindow: "1 minute",
        },
      },
    },
    async (request, reply) => {
      const body = request.body as
        | { userId?: number; basePoints?: number; source?: "ad" | "vip" }
        | undefined;
      const userId = Number(body?.userId);
      const basePoints = Number(body?.basePoints);
      const source = body?.source ?? "ad";
      const tokenUserId = request.user?.sub;

      if (
        !Number.isFinite(userId) ||
        !Number.isFinite(basePoints) ||
        basePoints < 1 ||
        basePoints > 100
      ) {
        reply.code(400);
        return { error: "Payload invalide" };
      }
      if (!tokenUserId || tokenUserId !== userId) {
        reply.code(403);
        return { error: "Acces refuse" };
      }

      if (!adsEnabled && source === "ad") {
        reply.code(503);
        return { error: "Publicités désactivées" };
      }
      if (source === "ad" && !(await requireAdsUiEnabled(db, reply))) {
        return;
      }
      if (source === "ad" && !(await requireAdsConsent(db, userId, reply))) {
        return;
      }

      const today = getTodayDateKey();
      let watchedCountAfter: number | null = null;

      await ensureWallet(db, userId);

      if (source === "vip") {
        const [vipRows] = await db.execute<
          import("mysql2/promise").RowDataPacket[]
        >("SELECT vip_no_ads, vip_expires_at FROM users WHERE id = ? LIMIT 1", [
          userId,
        ]);
        const row = vipRows[0];
        const isVipActive =
          Boolean(row?.vip_no_ads) &&
          (!row?.vip_expires_at || new Date(row.vip_expires_at) > new Date());
        if (!isVipActive) {
          reply.code(403);
          return { error: "VIP requis ou expiré" };
        }
      } else {
        const [rows] = await db.execute<
          import("mysql2/promise").RowDataPacket[]
        >(
          "SELECT watched_count FROM ad_rewards WHERE user_id = ? AND reward_date = ?",
          [userId, today],
        );
        const watchedCount = rows[0]?.watched_count ?? 0;
        watchedCountAfter = watchedCount + 1;
      }

      await db.execute(
        "UPDATE wallets SET points = points + ? WHERE user_id = ?",
        [basePoints, userId],
      );

      await recordWalletChange(
        db,
        userId,
        [{ resource: "points", delta: basePoints }],
        "ads.double_points",
        { source, basePoints },
      );

      if (source === "ad") {
        await db.execute(
          `INSERT INTO ad_rewards (user_id, reward_date, watched_count, bonus_points_earned, last_watched_at)
           VALUES (?, ?, 1, ?, NOW())
           ON DUPLICATE KEY UPDATE
             watched_count = watched_count + 1,
             bonus_points_earned = bonus_points_earned + ?,
             last_watched_at = NOW()`,
          [userId, today, basePoints, basePoints],
        );
      }

      await recordAdRewardEvent(db, {
        request,
        userId,
        rewardDate: today,
        adType: "double_points",
        source,
        rewardAmount: basePoints,
        watchedCountAfter,
        meta: {
          bonusPoints: basePoints,
          totalPoints: basePoints * 2,
        },
      });

      await recordAudit(db, {
        userId,
        action: source === "vip" ? "vip.double_points" : "ads.double_points",
        meta: {
          basePoints,
          bonusPoints: basePoints,
          totalPoints: basePoints * 2,
        },
        ip: request.ip,
        userAgent: request.headers["user-agent"]?.toString() ?? null,
        installationId:
          request.headers["x-installation-id"]?.toString() ?? null,
      });

      const wallet = await fetchWallet(db, userId);
      request.log.info(
        {
          userId,
          source,
          basePoints,
          bonusPoints: basePoints,
          totalPoints: basePoints * 2,
          walletPoints: wallet.points,
        },
        "ads_double_points_granted",
      );
      return {
        ok: true,
        bonusPoints: basePoints,
        totalPoints: basePoints * 2,
        wallet,
      };
    },
  );

  // POST /ads/free-solution
  app.post(
    "/ads/free-solution",
    {
      preHandler: requireAuth,
      schema: {
        body: {
          ...bodyUserIdSchema,
          required: ["userId"],
          properties: {
            ...bodyUserIdSchema.properties,
            gameMode: { type: "string", minLength: 1, maxLength: 20 },
            difficulty: { type: "string", minLength: 1, maxLength: 20 },
            levelIndex: { type: "number", minimum: 0, maximum: 999 },
          },
        },
      },
    },
    async (request, reply) => {
      if (!adsEnabled) {
        reply.code(503);
        return { error: "Publicités désactivées" };
      }
      if (!(await requireAdsUiEnabled(db, reply))) {
        return;
      }
      const body = request.body as
        | {
            userId?: number;
            gameMode?: string;
            difficulty?: string;
            levelIndex?: number;
          }
        | undefined;
      const userId = Number(body?.userId);
      const tokenUserId = request.user?.sub;

      if (!Number.isFinite(userId)) {
        reply.code(400);
        return { error: "userId invalide" };
      }
      if (!tokenUserId || tokenUserId !== userId) {
        reply.code(403);
        return { error: "Acces refuse" };
      }
      if (!(await requireAdsConsent(db, userId, reply))) {
        return;
      }
      const today = getTodayDateKey();
      const gameMode =
        typeof body?.gameMode === "string" && body.gameMode.trim()
          ? body.gameMode.trim()
          : undefined;
      const difficulty =
        typeof body?.difficulty === "string" && body.difficulty.trim()
          ? body.difficulty.trim()
          : undefined;
      const levelIndex =
        Number.isFinite(body?.levelIndex) && typeof body?.levelIndex === "number"
          ? body.levelIndex
          : undefined;

      await recordAdRewardEvent(db, {
        request,
        userId,
        rewardDate: today,
        adType: "free_solution",
        source: "ad",
        rewardAmount: 0,
        watchedCountAfter: null,
        meta: {
          gameMode,
          difficulty,
          levelIndex,
        },
      });

      await recordAudit(db, {
        userId,
        action: "ads.free_solution",
        meta: {},
        ip: request.ip,
        userAgent: request.headers["user-agent"]?.toString() ?? null,
        installationId:
          request.headers["x-installation-id"]?.toString() ?? null,
      });
      request.log.info(
        { userId, gameMode, difficulty, levelIndex },
        "ads_free_solution_recorded",
      );

      return { ok: true };
    },
  );
};

export const registerPrivacyRoutes = (
  app: FastifyInstance,
  ctx: RouteContext,
) => {
  const { db, requireAuth } = ctx;

  app.get(
    "/privacy/consent/:userId",
    {
      preHandler: requireAuth,
      schema: {
        params: paramsUserIdSchema,
      },
    },
    async (request, reply) => {
      const userId = Number((request.params as { userId: string }).userId);
      const tokenUserId = request.user?.sub;
      if (!Number.isFinite(userId)) {
        reply.code(400);
        return { error: "userId invalide" };
      }
      if (!tokenUserId || tokenUserId !== userId) {
        reply.code(403);
        return { error: "Acces refuse" };
      }
      const consent = await fetchPrivacyConsentState(db, userId);
      return { consent };
    },
  );

  app.put(
    "/privacy/consent/:userId",
    {
      preHandler: requireAuth,
      schema: {
        params: paramsUserIdSchema,
        body: {
          type: "object",
          required: ["adsConsent", "personalizedAdsConsent", "analyticsConsent"],
          additionalProperties: false,
          properties: {
            consentVersion: { type: "string", minLength: 1, maxLength: 20 },
            adsConsent: { type: "boolean" },
            personalizedAdsConsent: { type: "boolean" },
            analyticsConsent: { type: "boolean" },
            consentSource: { type: "string", minLength: 1, maxLength: 40 },
          },
        },
      },
    },
    async (request, reply) => {
      const userId = Number((request.params as { userId: string }).userId);
      const tokenUserId = request.user?.sub;
      if (!Number.isFinite(userId)) {
        reply.code(400);
        return { error: "userId invalide" };
      }
      if (!tokenUserId || tokenUserId !== userId) {
        reply.code(403);
        return { error: "Acces refuse" };
      }

      const body = request.body as
        | {
            consentVersion?: string;
            adsConsent?: boolean;
            personalizedAdsConsent?: boolean;
            analyticsConsent?: boolean;
            consentSource?: string;
          }
        | undefined;
      if (!body) {
        reply.code(400);
        return { error: "Payload invalide" };
      }

      const adsConsent = Boolean(body.adsConsent);
      const personalizedAdsConsent = adsConsent
        ? Boolean(body.personalizedAdsConsent)
        : false;
      const analyticsConsent = Boolean(body.analyticsConsent);
      const consentVersion =
        typeof body.consentVersion === "string" && body.consentVersion.trim()
          ? body.consentVersion.trim()
          : DEFAULT_CONSENT_VERSION;
      const consentSource =
        typeof body.consentSource === "string" && body.consentSource.trim()
          ? body.consentSource.trim()
          : "app";

      const isAllAccepted =
        adsConsent && personalizedAdsConsent && analyticsConsent;
      const isAllRejected =
        !adsConsent && !personalizedAdsConsent && !analyticsConsent;
      const consentStatus: "accepted" | "rejected" | "custom" = isAllAccepted
        ? "accepted"
        : isAllRejected
          ? "rejected"
          : "custom";

      await db.execute(
        `INSERT INTO user_privacy_consents (
          user_id,
          consent_version,
          consent_status,
          ads_consent,
          personalized_ads_consent,
          analytics_consent,
          consent_source,
          installation_id,
          ip,
          user_agent,
          granted_at
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
        ON DUPLICATE KEY UPDATE
          consent_version = VALUES(consent_version),
          consent_status = VALUES(consent_status),
          ads_consent = VALUES(ads_consent),
          personalized_ads_consent = VALUES(personalized_ads_consent),
          analytics_consent = VALUES(analytics_consent),
          consent_source = VALUES(consent_source),
          installation_id = VALUES(installation_id),
          ip = VALUES(ip),
          user_agent = VALUES(user_agent),
          granted_at = VALUES(granted_at),
          updated_at = CURRENT_TIMESTAMP`,
        [
          userId,
          consentVersion,
          consentStatus,
          adsConsent,
          personalizedAdsConsent,
          analyticsConsent,
          consentSource,
          request.headers["x-installation-id"]?.toString() ?? null,
          request.ip,
          request.headers["user-agent"]?.toString() ?? null,
        ],
      );

      await recordAudit(db, {
        userId,
        action: "privacy.consent_update",
        meta: {
          consentVersion,
          consentStatus,
          adsConsent,
          personalizedAdsConsent,
          analyticsConsent,
          consentSource,
        },
        ip: request.ip,
        userAgent: request.headers["user-agent"]?.toString() ?? null,
        installationId:
          request.headers["x-installation-id"]?.toString() ?? null,
      });

      const consent = await fetchPrivacyConsentState(db, userId);
      return { consent };
    },
  );
};
