import {
  BadRequestException,
  ForbiddenException,
  Injectable,
  NotFoundException,
  OnModuleInit,
} from '@nestjs/common';
import {
  PredictionActivityType,
  PredictionCard,
  PredictionCardType,
  PredictionValueType,
  UserRole,
} from '@prisma/client';
import { DEFAULT_PROJECT_ID } from '../projects/project-scope.constants';
import { PrismaService } from '../prisma/prisma.service';
import { CardFieldDto } from './dto/card-field.dto';
import { CardOptionDto } from './dto/card-option.dto';
import { CreateCardDto } from './dto/create-card.dto';
import {
  SetOutcomesDto,
  UpsertPredictionEntryDto,
  ValidateScoresDto,
} from './dto/prediction-value.dto';
import { UpdateCardDto } from './dto/update-card.dto';

@Injectable()
export class PredictionsService implements OnModuleInit {
  private readonly contestFinalizedMarker = '[CONTEST_FINALIZED]';

  constructor(private readonly prismaService: PrismaService) {}

  private resolveProjectId(projectId?: string) {
    return projectId ?? DEFAULT_PROJECT_ID;
  }

  private buildProjectScopeFilter(projectId: string) {
    if (projectId === DEFAULT_PROJECT_ID) {
      return {
        OR: [{ projectId: DEFAULT_PROJECT_ID }, { projectId: null }],
      };
    }

    return { projectId };
  }

  private normalizeSelectedBabyIndex(selectedBabyIndex?: number) {
    if (!selectedBabyIndex || Number.isNaN(selectedBabyIndex)) {
      return 1;
    }

    return Math.max(1, Math.trunc(selectedBabyIndex));
  }

  private buildBabyScopeFilter(selectedBabyIndex: number) {
    const normalized = this.normalizeSelectedBabyIndex(selectedBabyIndex);

    if (normalized === 1) {
      return {
        OR: [{ selectedBabyIndex: 1 }, { selectedBabyIndex: null }],
      };
    }

    return { selectedBabyIndex: normalized };
  }

  private isCardInProjectScope(cardProjectId: string | null, projectId: string) {
    if (projectId === DEFAULT_PROJECT_ID) {
      return cardProjectId == null || cardProjectId === DEFAULT_PROJECT_ID;
    }

    return cardProjectId === projectId;
  }

  private async assertProjectAccess(userId: string, projectId: string, adminOnly = false) {
    const [user, project, membership] = await Promise.all([
      this.prismaService.user.findUnique({
        where: { id: userId },
        select: { id: true, role: true, workspaceId: true },
      }),
      this.prismaService.project.findUnique({
        where: { id: projectId },
        select: { id: true, workspaceId: true },
      }),
      this.prismaService.projectMembership.findUnique({
        where: { userId },
        select: { projectId: true },
      }),
    ]);

    if (!user) {
      throw new NotFoundException('Utilisateur introuvable');
    }

    if (!project) {
      if (projectId === DEFAULT_PROJECT_ID) {
        return;
      }
      throw new NotFoundException('Projet introuvable');
    }

    if (user.workspaceId && user.workspaceId !== project.workspaceId) {
      throw new ForbiddenException('Acces refuse a ce projet');
    }

    if (user.role === UserRole.ADMIN) {
      return;
    }

    if (adminOnly) {
      throw new ForbiddenException('Action reservee aux admins');
    }

    if (membership?.projectId === projectId) {
      return;
    }

    if (projectId === DEFAULT_PROJECT_ID) {
      return;
    }

    throw new ForbiddenException('Vous n etes pas rattache a ce projet');
  }

  private async getGameForProject(projectId: string) {
    if (projectId === DEFAULT_PROJECT_ID) {
      const defaultProject = await this.prismaService.project.findUnique({
        where: { id: DEFAULT_PROJECT_ID },
        select: { id: true },
      });

      return this.prismaService.predictionGame.upsert({
        where: { id: 'singleton' },
        update: {
          projectId: defaultProject ? DEFAULT_PROJECT_ID : null,
        },
        create: {
          id: 'singleton',
          ...(defaultProject ? { projectId: DEFAULT_PROJECT_ID } : {}),
        },
      });
    }

    const existing = await this.prismaService.predictionGame.findFirst({
      where: { projectId },
    });

    if (existing) {
      return existing;
    }

    const project = await this.prismaService.project.findUnique({
      where: { id: projectId },
      select: {
        id: true,
        name: true,
        status: true,
      },
    });

    if (!project) {
      throw new NotFoundException('Projet introuvable');
    }

    return this.prismaService.predictionGame.create({
      data: {
        id: projectId,
        projectId,
        title: `Pronostics ${project.name}`,
        status: project.status === 'CLOSED' || project.status === 'FINALIZED' ? 'CLOSED' : 'OPEN',
      },
    });
  }

  async onModuleInit() {
    await this.ensureGame();
    await this.ensureLegacyCards();
  }

  private async ensureGame(projectId = DEFAULT_PROJECT_ID) {
    await this.getGameForProject(projectId);
  }

  private async ensureLegacyCards() {
    const admin = await this.prismaService.user.findFirst({
      where: { role: UserRole.ADMIN },
      orderBy: { createdAt: 'asc' },
    });

    if (!admin) {
      return;
    }

    await this.upsertLegacyCard(admin.id, {
      code: 'legacy_weight',
      title: 'Poids du bebe',
      description: 'Pronostic du poids de naissance',
      basePoints: 100,
      styleId: 1,
      valueType: PredictionValueType.NUMBER,
      unit: 'kg',
      sortOrder: 1,
      fields: [
        {
          label: 'Poids',
          points: 100,
          isRequired: true,
          minNumber: 1.5,
          maxNumber: 5.995,
          stepNumber: 0.005,
        },
      ],
      options: [],
    });

    await this.upsertLegacyCard(admin.id, {
      code: 'legacy_sex',
      title: 'Sexe du bebe',
      description: 'Pronostic du sexe du bebe',
      styleId: 2,
      valueType: PredictionValueType.SELECT,
      sortOrder: 2,
      fields: [{ label: 'Sexe', points: 20, isRequired: true }],
      options: [
        { label: 'Fille', value: 'fille' },
        { label: 'Garcon', value: 'garçon' },
      ],
    });

    await this.upsertLegacyCard(admin.id, {
      code: 'legacy_names',
      title: 'Prenoms du bebe',
      description: 'Jusqua 6 propositions de prenoms',
      styleId: 3,
      valueType: PredictionValueType.MULTI_TEXT,
      sortOrder: 3,
      fields: [
        { label: 'Prenom principal', points: 30, isPrimary: true, isRequired: true },
        { label: 'Prenom secondaire 1', points: 15 },
        { label: 'Prenom secondaire 2', points: 15 },
        { label: 'Prenom secondaire 3', points: 15 },
        { label: 'Prenom secondaire 4', points: 15 },
        { label: 'Prenom secondaire 5', points: 15 },
      ],
      options: [],
    });

    await this.upsertLegacyCard(admin.id, {
      code: 'legacy_head',
      title: 'Perimetre cranien',
      description: 'Pronostic du perimetre cranien',
      styleId: 4,
      valueType: PredictionValueType.NUMBER,
      unit: 'cm',
      sortOrder: 4,
      fields: [
        {
          label: 'Perimetre cranien',
          points: 15,
          isRequired: true,
          minNumber: 30,
          maxNumber: 45,
          stepNumber: 0.1,
        },
      ],
      options: [],
    });

    await this.upsertLegacyCard(admin.id, {
      code: 'legacy_height',
      title: 'Taille du bebe',
      description: 'Pronostic de la taille a la naissance',
      styleId: 5,
      valueType: PredictionValueType.NUMBER,
      unit: 'cm',
      sortOrder: 5,
      fields: [
        {
          label: 'Taille',
          points: 15,
          isRequired: true,
          minNumber: 40,
          maxNumber: 65,
          stepNumber: 0.1,
        },
      ],
      options: [],
    });
  }

  private async upsertLegacyCard(
    userId: string,
    payload: {
      code: string;
      title: string;
      description: string;
      basePoints?: number;
      styleId: number;
      valueType: PredictionValueType;
      unit?: string;
      sortOrder: number;
      fields: CardFieldDto[];
      options: CardOptionDto[];
    },
  ) {
    const existingCards = await this.prismaService.predictionCard.findMany({
      where: { code: payload.code },
      orderBy: [{ projectId: 'asc' }, { createdAt: 'asc' }],
    });

    const cardsToSync = await Promise.all(
      existingCards.map((card) =>
        this.prismaService.predictionCard.update({
          where: { id: card.id },
          data: {
            projectId: card.projectId === DEFAULT_PROJECT_ID ? null : card.projectId,
            title: payload.title,
            description: payload.description,
            basePoints: payload.basePoints ?? 0,
            type: PredictionCardType.LEGACY,
            valueType: payload.valueType,
            styleId: payload.styleId,
            unit: payload.unit,
            sortOrder: payload.sortOrder,
            isActive: true,
            isDeletable: false,
          },
        }),
      ),
    );

    if (!cardsToSync.some((card) => card.projectId == null)) {
      cardsToSync.push(
        await this.prismaService.predictionCard.create({
          data: {
            projectId: null,
            code: payload.code,
            title: payload.title,
            description: payload.description,
            basePoints: payload.basePoints ?? 0,
            type: PredictionCardType.LEGACY,
            valueType: payload.valueType,
            styleId: payload.styleId,
            unit: payload.unit,
            sortOrder: payload.sortOrder,
            isActive: true,
            isDeletable: false,
            createdById: userId,
          },
        }),
      );
    }

    for (const card of cardsToSync) {
      for (const field of payload.fields) {
        await this.prismaService.predictionCardField.upsert({
          where: {
            cardId_label: {
              cardId: card.id,
              label: field.label,
            },
          },
          update: {
            sortOrder: field.sortOrder ?? 0,
            points: field.points ?? 0,
            isPrimary: field.isPrimary ?? false,
            isRequired: field.isRequired ?? false,
            minNumber: field.minNumber,
            maxNumber: field.maxNumber,
            stepNumber: field.stepNumber,
          },
          create: {
            cardId: card.id,
            label: field.label,
            sortOrder: field.sortOrder ?? 0,
            points: field.points ?? 0,
            isPrimary: field.isPrimary ?? false,
            isRequired: field.isRequired ?? false,
            minNumber: field.minNumber,
            maxNumber: field.maxNumber,
            stepNumber: field.stepNumber,
          },
        });
      }

      for (const option of payload.options) {
        await this.prismaService.predictionCardOption.upsert({
          where: {
            cardId_value: {
              cardId: card.id,
              value: option.value,
            },
          },
          update: {
            label: option.label,
            sortOrder: option.sortOrder ?? 0,
          },
          create: {
            cardId: card.id,
            label: option.label,
            value: option.value,
            sortOrder: option.sortOrder ?? 0,
          },
        });
      }
    }
  }

  private normalizeText(input?: string | null) {
    return (input ?? '').trim().toLowerCase();
  }

  private normalizeDateOnly(value?: string | Date | null) {
    if (!value) {
      return null;
    }

    if (typeof value === 'string') {
      return value.slice(0, 10);
    }

    return value.toISOString().slice(0, 10);
  }

  private async isContestFinalizedSinceLastOpen(projectId = DEFAULT_PROJECT_ID) {
    const scopeFilter = this.buildProjectScopeFilter(projectId);

    const [latestOpenActivity, latestFinalizationActivity] = await Promise.all([
      this.prismaService.predictionActivity.findFirst({
        where: { ...scopeFilter, type: PredictionActivityType.GAME_OPENED },
        orderBy: { createdAt: 'desc' },
      }),
      this.prismaService.predictionActivity.findFirst({
        where: {
          ...scopeFilter,
          type: PredictionActivityType.SCORE_VALIDATED,
          message: { startsWith: this.contestFinalizedMarker },
        },
        orderBy: { createdAt: 'desc' },
      }),
    ]);

    if (!latestFinalizationActivity) {
      return false;
    }

    if (!latestOpenActivity) {
      return true;
    }

    return latestFinalizationActivity.createdAt > latestOpenActivity.createdAt;
  }

  private hasOutcomeValue(
    outcome:
      | {
          valueText: string | null;
          valueNumber: number | null;
          valueDate: Date | null;
        }
      | undefined,
    valueType: PredictionValueType,
  ) {
    if (!outcome) {
      return false;
    }

    if (valueType === PredictionValueType.NUMBER) {
      return outcome.valueNumber != null;
    }

    if (valueType === PredictionValueType.DATE) {
      return outcome.valueDate != null;
    }

    return (outcome.valueText ?? '').trim().length > 0;
  }

  private async assertRequiredOutcomesAreFilled(projectId = DEFAULT_PROJECT_ID) {
    const scopeFilter = this.buildProjectScopeFilter(projectId);

    const projectBabyCount =
      projectId === DEFAULT_PROJECT_ID
        ? 1
        : (
            await this.prismaService.project.findUnique({
              where: { id: projectId },
              select: { babyCount: true },
            })
          )?.babyCount ?? 1;

    const cards = await this.prismaService.predictionCard.findMany({
      where: { ...scopeFilter, isActive: true },
      include: {
        fields: { orderBy: { sortOrder: 'asc' } },
        outcomes: true,
      },
      orderBy: { sortOrder: 'asc' },
    });

    const missing: string[] = [];

    for (const card of cards) {
      const outcomesByFieldAndBaby = new Map(
        card.outcomes.map((outcome) => [
          `${outcome.fieldId}::${this.normalizeSelectedBabyIndex(outcome.selectedBabyIndex ?? 1)}`,
          outcome,
        ]),
      );

      for (const field of card.fields) {
        if (!field.isRequired) {
          continue;
        }

        for (let babyIndex = 1; babyIndex <= Math.max(1, projectBabyCount); babyIndex += 1) {
          const outcome = outcomesByFieldAndBaby.get(`${field.id}::${babyIndex}`);
          if (!this.hasOutcomeValue(outcome, card.valueType)) {
            missing.push(`${card.title} / ${field.label} / Bebe ${babyIndex}`);
          }
        }
      }
    }

    if (missing.length > 0) {
      throw new BadRequestException(
        `Resultats finaux manquants pour: ${missing.join(', ')}`,
      );
    }

    return cards;
  }

  private hasValueForType(
    value:
      | {
          valueText: string | null;
          valueNumber: number | null;
          valueDate: string | null;
        }
      | undefined,
    valueType: PredictionValueType,
  ) {
    if (!value) {
      return false;
    }

    if (valueType === PredictionValueType.NUMBER) {
      return value.valueNumber != null;
    }

    if (valueType === PredictionValueType.DATE) {
      return value.valueDate != null;
    }

    return (value.valueText ?? '').trim().length > 0;
  }

  private formatValueForMessage(
    value: { valueText: string | null; valueNumber: number | null; valueDate: string | null },
  ) {
    if (value.valueNumber != null) {
      return `${value.valueNumber}`;
    }

    if (value.valueText && value.valueText.trim().length > 0) {
      return value.valueText.trim();
    }

    if (value.valueDate) {
      return value.valueDate;
    }

    return '(vide)';
  }

  private areEntryValuesEqual(
    left: { valueText: string | null; valueNumber: number | null; valueDate: string | null },
    right: { valueText: string | null; valueNumber: number | null; valueDate: string | null },
  ) {
    return (
      this.normalizeText(left.valueText) === this.normalizeText(right.valueText) &&
      left.valueNumber === right.valueNumber &&
      this.normalizeDateOnly(left.valueDate) === this.normalizeDateOnly(right.valueDate)
    );
  }

  private async createActivity(payload: {
    projectId?: string;
    userId?: string;
    cardId?: string;
    entryId?: string;
    type: PredictionActivityType;
    message: string;
  }) {
    const scopedProjectId = this.resolveProjectId(payload.projectId);
    const game = await this.getGameForProject(scopedProjectId);

    await this.prismaService.predictionActivity.create({
      data: {
        gameId: game.id,
        projectId: scopedProjectId,
        type: payload.type,
        userId: payload.userId,
        cardId: payload.cardId,
        entryId: payload.entryId,
        message: payload.message,
      },
    });
  }

  async closeGame(userId: string, projectId?: string) {
    const scopedProjectId = this.resolveProjectId(projectId);
    await this.assertProjectAccess(userId, scopedProjectId, true);
    await this.assertRequiredOutcomesAreFilled(scopedProjectId);

    const game = await this.getGameForProject(scopedProjectId);

    await this.prismaService.predictionGame.update({
      where: { id: game.id },
      data: { status: 'CLOSED', closedAt: new Date() },
    });

    await this.createActivity({
      projectId: scopedProjectId,
      userId,
      type: PredictionActivityType.GAME_CLOSED,
      message: 'Le jeu de pronostics a ete cloture',
    });

    return { status: 'CLOSED' };
  }

  async openGame(userId: string, projectId?: string) {
    const scopedProjectId = this.resolveProjectId(projectId);
    await this.assertProjectAccess(userId, scopedProjectId, true);

    const game = await this.getGameForProject(scopedProjectId);

    if (!game) {
      throw new NotFoundException('Game not found');
    }

    const isFinalized = await this.isContestFinalizedSinceLastOpen(scopedProjectId);
    if (isFinalized) {
      throw new ForbiddenException('Le concours a deja ete valide et ne peut plus etre reouvert');
    }

    await this.prismaService.predictionGame.update({
      where: { id: game.id },
      data: { status: 'OPEN', reopenedAt: new Date() },
    });

    await this.createActivity({
      projectId: scopedProjectId,
      userId,
      type: PredictionActivityType.GAME_OPENED,
      message: 'Le jeu de pronostics est de nouveau ouvert',
    });

    return { status: 'OPEN' };
  }

  async createCard(userId: string, createCardDto: CreateCardDto, projectId?: string) {
    const scopedProjectId = this.resolveProjectId(projectId);
    await this.assertProjectAccess(userId, scopedProjectId, true);

    if (!createCardDto.fields?.length) {
      throw new BadRequestException('A card must contain at least one field');
    }

    const game = await this.getGameForProject(scopedProjectId);

    const card = await this.prismaService.predictionCard.create({
      data: {
        gameId: game.id,
        projectId: scopedProjectId,
        title: createCardDto.title,
        description: createCardDto.description,
        type: createCardDto.type,
        valueType: createCardDto.valueType,
        styleId: createCardDto.styleId ?? 0,
        unit: createCardDto.unit,
        sortOrder: createCardDto.sortOrder ?? 0,
        basePoints: createCardDto.basePoints ?? 0,
        isActive: true,
        isDeletable: createCardDto.type !== PredictionCardType.LEGACY,
        createdById: userId,
      },
    });

    for (const field of createCardDto.fields) {
      await this.prismaService.predictionCardField.create({
        data: {
          cardId: card.id,
          label: field.label,
          sortOrder: field.sortOrder ?? 0,
          points: field.points ?? 0,
          isPrimary: field.isPrimary ?? false,
          isRequired: field.isRequired ?? false,
          minNumber: field.minNumber,
          maxNumber: field.maxNumber,
          stepNumber: field.stepNumber,
        },
      });
    }

    for (const option of createCardDto.options ?? []) {
      await this.prismaService.predictionCardOption.create({
        data: {
          cardId: card.id,
          label: option.label,
          value: option.value,
          sortOrder: option.sortOrder ?? 0,
        },
      });
    }

    await this.createActivity({
      projectId: scopedProjectId,
      userId,
      cardId: card.id,
      type: PredictionActivityType.CARD_CREATED,
      message: `Nouvelle carte creee: ${card.title}`,
    });

    return this.getCard(card.id, scopedProjectId);
  }

  async updateCard(userId: string, cardId: string, updateCardDto: UpdateCardDto, projectId?: string) {
    const scopedProjectId = this.resolveProjectId(projectId);
    await this.assertProjectAccess(userId, scopedProjectId, true);

    const card = await this.prismaService.predictionCard.findUnique({
      where: { id: cardId },
    });

    if (!card || !this.isCardInProjectScope(card.projectId, scopedProjectId)) {
      throw new NotFoundException('Card not found');
    }

    if (card.type === PredictionCardType.LEGACY && updateCardDto.valueType) {
      throw new ForbiddenException('Legacy card type cannot be changed');
    }

    await this.prismaService.predictionCard.update({
      where: { id: cardId },
      data: {
        title: updateCardDto.title,
        description: updateCardDto.description,
        valueType: updateCardDto.valueType,
        styleId: updateCardDto.styleId,
        unit: updateCardDto.unit,
        sortOrder: updateCardDto.sortOrder,
        basePoints: updateCardDto.basePoints,
        isActive: updateCardDto.isActive,
      },
    });

    if (updateCardDto.fields) {
      await this.prismaService.predictionCardField.deleteMany({ where: { cardId } });
      for (const field of updateCardDto.fields) {
        await this.prismaService.predictionCardField.create({
          data: {
            cardId,
            label: field.label,
            sortOrder: field.sortOrder ?? 0,
            points: field.points ?? 0,
            isPrimary: field.isPrimary ?? false,
            isRequired: field.isRequired ?? false,
            minNumber: field.minNumber,
            maxNumber: field.maxNumber,
            stepNumber: field.stepNumber,
          },
        });
      }
    }

    if (updateCardDto.options) {
      await this.prismaService.predictionCardOption.deleteMany({ where: { cardId } });
      for (const option of updateCardDto.options) {
        await this.prismaService.predictionCardOption.create({
          data: {
            cardId,
            label: option.label,
            value: option.value,
            sortOrder: option.sortOrder ?? 0,
          },
        });
      }
    }

    await this.createActivity({
      projectId: scopedProjectId,
      userId,
      cardId,
      type: PredictionActivityType.CARD_UPDATED,
      message: `Carte mise a jour: ${updateCardDto.title ?? card.title}`,
    });

    return this.getCard(cardId, scopedProjectId);
  }

  async deleteCard(userId: string, cardId: string, projectId?: string) {
    const scopedProjectId = this.resolveProjectId(projectId);
    await this.assertProjectAccess(userId, scopedProjectId, true);

    const card = await this.prismaService.predictionCard.findUnique({
      where: { id: cardId },
    });

    if (!card || !this.isCardInProjectScope(card.projectId, scopedProjectId)) {
      throw new NotFoundException('Card not found');
    }

    if (!card.isDeletable) {
      throw new ForbiddenException('Legacy cards cannot be deleted');
    }

    await this.prismaService.predictionCard.delete({ where: { id: cardId } });

    await this.createActivity({
      projectId: scopedProjectId,
      userId,
      type: PredictionActivityType.CARD_DELETED,
      message: `Carte supprimee: ${card.title}`,
    });

    return { deleted: true };
  }

  async listCards(includeInactive = false, projectId?: string) {
    const scopedProjectId = this.resolveProjectId(projectId);
    const scopeFilter = this.buildProjectScopeFilter(scopedProjectId);

    return this.prismaService.predictionCard.findMany({
      where: includeInactive ? scopeFilter : { ...scopeFilter, isActive: true },
      include: {
        fields: { orderBy: { sortOrder: 'asc' } },
        options: { orderBy: { sortOrder: 'asc' } },
      },
      orderBy: { sortOrder: 'asc' },
    });
  }

  async getCard(cardId: string, projectId?: string) {
    const scopedProjectId = this.resolveProjectId(projectId);

    const card = await this.prismaService.predictionCard.findUnique({
      where: { id: cardId },
      include: {
        fields: { orderBy: { sortOrder: 'asc' } },
        options: { orderBy: { sortOrder: 'asc' } },
      },
    });

    if (!card) {
      throw new NotFoundException('Card not found');
    }

    if (!this.isCardInProjectScope(card.projectId, scopedProjectId)) {
      throw new NotFoundException('Card not found');
    }

    return card;
  }

  async upsertMyPrediction(
    userId: string,
    cardId: string,
    dto: UpsertPredictionEntryDto,
    projectId?: string,
  ) {
    const scopedProjectId = this.resolveProjectId(projectId);
    await this.assertProjectAccess(userId, scopedProjectId);

    const game = await this.getGameForProject(scopedProjectId);

    if (!game || game.status !== 'OPEN') {
      throw new ForbiddenException('The game is closed');
    }

    const card = await this.getCard(cardId, scopedProjectId);

    if (!card.isActive) {
      throw new NotFoundException('Card not found');
    }

    const fieldById = new Map(card.fields.map((field) => [field.id, field]));

    const selectedBabyIndex = dto.selectedBabyIndex ?? 1;
    const scopeFilter = this.buildProjectScopeFilter(scopedProjectId);
    const babyScopeFilter =
      selectedBabyIndex === 1
        ? {
            OR: [{ selectedBabyIndex }, { selectedBabyIndex: null }],
          }
        : { selectedBabyIndex };

    const existing = await this.prismaService.predictionEntry.findFirst({
      where: {
        userId,
        cardId,
        AND: [scopeFilter, babyScopeFilter],
      },
      include: { values: true },
    });

    if (!dto.values.length) {
      throw new BadRequestException('At least one value is required');
    }

    const incomingByField = new Map<
      string,
      { valueText: string | null; valueNumber: number | null; valueDate: string | null }
    >();

    for (const value of dto.values) {
      const field = fieldById.get(value.fieldId);
      if (!field) {
        throw new BadRequestException('Unknown field for this card');
      }

      const normalizedText = value.valueText?.trim() ?? '';

      if (card.valueType === PredictionValueType.NUMBER && value.valueNumber == null) {
        throw new BadRequestException(`Field ${field.label} requires a number value`);
      }

      if (card.valueType === PredictionValueType.DATE && !value.valueDate) {
        throw new BadRequestException(`Field ${field.label} requires a date value`);
      }

      if (card.valueType === PredictionValueType.SELECT && normalizedText.length > 0) {
        const allowed = card.options.some(
          (option) => this.normalizeText(option.value) === this.normalizeText(normalizedText),
        );
        if (!allowed) {
          throw new BadRequestException(`Value ${normalizedText} is not in allowed options`);
        }
      }

      incomingByField.set(value.fieldId, {
        valueText:
          card.valueType === PredictionValueType.TEXT ||
          card.valueType === PredictionValueType.MULTI_TEXT ||
          card.valueType === PredictionValueType.SELECT
            ? normalizedText || null
            : value.valueText ?? null,
        valueNumber: value.valueNumber ?? null,
        valueDate: this.normalizeDateOnly(value.valueDate),
      });
    }

    const mergedByField = new Map<
      string,
      { valueText: string | null; valueNumber: number | null; valueDate: string | null }
    >();

    for (const current of existing?.values ?? []) {
      mergedByField.set(current.fieldId, {
        valueText: current.valueText,
        valueNumber: current.valueNumber,
        valueDate: this.normalizeDateOnly(current.valueDate),
      });
    }

    for (const [fieldId, incoming] of incomingByField.entries()) {
      mergedByField.set(fieldId, incoming);
    }

    for (const field of card.fields) {
      if (!field.isRequired) {
        continue;
      }

      const merged = mergedByField.get(field.id);
      if (!this.hasValueForType(merged, card.valueType)) {
        throw new BadRequestException(`Field ${field.label} requires a value`);
      }
    }

    type ChangedValue = {
      fieldId: string;
      previous: { valueText: string | null; valueNumber: number | null; valueDate: string | null } | null;
      next: { valueText: string | null; valueNumber: number | null; valueDate: string | null };
    };

    const changedValues: ChangedValue[] = dto.values.reduce<ChangedValue[]>((accumulator, value) => {
        const normalizedText = value.valueText?.trim() ?? '';
        const next = {
          valueText:
            card.valueType === PredictionValueType.TEXT ||
            card.valueType === PredictionValueType.MULTI_TEXT ||
            card.valueType === PredictionValueType.SELECT
              ? normalizedText || null
              : value.valueText ?? null,
          valueNumber: value.valueNumber ?? null,
          valueDate: this.normalizeDateOnly(value.valueDate),
        };

        const previousRaw = existing?.values.find((item) => item.fieldId === value.fieldId);
        const previous = previousRaw
          ? {
              valueText: previousRaw.valueText,
              valueNumber: previousRaw.valueNumber,
              valueDate: this.normalizeDateOnly(previousRaw.valueDate),
            }
          : null;

        if (!previous) {
          accumulator.push({
            fieldId: value.fieldId,
            previous: null,
            next,
          });
          return accumulator;
        }

        if (this.areEntryValuesEqual(previous, next)) {
          return accumulator;
        }

        accumulator.push({
          fieldId: value.fieldId,
          previous,
          next,
        });

        return accumulator;
      }, []);

    if (existing && changedValues.length === 0) {
      return this.prismaService.predictionEntry.findUnique({
        where: { id: existing.id },
        include: {
          card: true,
          values: { include: { field: true } },
        },
      });
    }

    const entry = existing
      ? await this.prismaService.predictionEntry.update({
          where: { id: existing.id },
          data: {
            gameId: game.id,
            projectId: scopedProjectId,
            selectedBabyIndex,
            values: {
              upsert: changedValues.map((value) => ({
                where: {
                  entryId_fieldId: {
                    entryId: existing.id,
                    fieldId: value.fieldId,
                  },
                },
                update: {
                  valueText: value.next.valueText,
                  valueNumber: value.next.valueNumber,
                  valueDate: value.next.valueDate ? new Date(value.next.valueDate) : null,
                },
                create: {
                  fieldId: value.fieldId,
                  valueText: value.next.valueText,
                  valueNumber: value.next.valueNumber,
                  valueDate: value.next.valueDate ? new Date(value.next.valueDate) : undefined,
                },
              })),
            },
          },
        })
      : await this.prismaService.predictionEntry.create({
          data: {
            userId,
            cardId,
            gameId: game.id,
            projectId: scopedProjectId,
            selectedBabyIndex,
            values: {
              create: dto.values.map((value) => ({
                fieldId: value.fieldId,
                valueText: value.valueText ?? null,
                valueNumber: value.valueNumber ?? null,
                valueDate: value.valueDate ? new Date(value.valueDate) : undefined,
              })),
            },
          },
        });

    const user = await this.prismaService.user.findUnique({ where: { id: userId } });
    const actorName = user?.displayName ?? user?.username ?? 'Membre';

    const fieldLabelById = new Map(card.fields.map((field) => [field.id, field.label]));
    const changedSummary = (existing ? changedValues : dto.values.map((value) => ({
      fieldId: value.fieldId,
      next: {
        valueText: value.valueText ?? null,
        valueNumber: value.valueNumber ?? null,
        valueDate: this.normalizeDateOnly(value.valueDate),
      },
    })))
      .map((item) => `${fieldLabelById.get(item.fieldId) ?? item.fieldId}: ${this.formatValueForMessage(item.next)}`)
      .join(' | ');

    await this.prismaService.predictionEntryHistory.create({
      data: {
        entryId: entry.id,
        userId,
        snapshot: (existing ? changedValues : dto.values.map((value) => ({
          fieldId: value.fieldId,
          next: {
            valueText: value.valueText ?? null,
            valueNumber: value.valueNumber ?? null,
            valueDate: this.normalizeDateOnly(value.valueDate),
          },
        }))).map((item) => ({
          fieldId: item.fieldId,
          valueText: item.next.valueText,
          valueNumber: item.next.valueNumber,
          valueDate: item.next.valueDate,
        })),
        message: existing
          ? `${actorName} a mis a jour ${card.title}: ${changedSummary}`
          : `${actorName} a cree son pronostic sur ${card.title}`,
      },
    });

    await this.createActivity({
      projectId: scopedProjectId,
      userId,
      entryId: entry.id,
      cardId,
      type: existing
        ? PredictionActivityType.PREDICTION_UPDATED
        : PredictionActivityType.PREDICTION_CREATED,
      message: existing
        ? `${actorName} a change ${card.title}: ${changedSummary}`
        : `${actorName} a pronostique ${card.title}`,
    });

    return this.prismaService.predictionEntry.findUnique({
      where: { id: entry.id },
      include: {
        card: true,
        values: { include: { field: true } },
      },
    });
  }

  async listMyEntries(userId: string, projectId?: string) {
    const scopedProjectId = this.resolveProjectId(projectId);
    await this.assertProjectAccess(userId, scopedProjectId);

    return this.prismaService.predictionEntry.findMany({
      where: {
        userId,
        ...this.buildProjectScopeFilter(scopedProjectId),
      },
      include: {
        card: true,
        values: { include: { field: true } },
        history: { orderBy: { createdAt: 'desc' }, take: 20 },
        scores: { include: { field: true } },
      },
      orderBy: { updatedAt: 'desc' },
    });
  }

  async listActivity(userId: string, projectId?: string) {
    const scopedProjectId = this.resolveProjectId(projectId);
    await this.assertProjectAccess(userId, scopedProjectId);

    return this.prismaService.predictionActivity.findMany({
      where: this.buildProjectScopeFilter(scopedProjectId),
      include: {
        user: true,
        card: true,
        entry: {
          include: {
            values: {
              include: { field: true },
            },
          },
        },
      },
      orderBy: { createdAt: 'desc' },
      take: 200,
    });
  }

  async setCardOutcomes(userId: string, cardId: string, dto: SetOutcomesDto, projectId?: string) {
    const scopedProjectId = this.resolveProjectId(projectId);
    await this.assertProjectAccess(userId, scopedProjectId, true);
    const selectedBabyIndex = this.normalizeSelectedBabyIndex(dto.selectedBabyIndex);

    const game = await this.getGameForProject(scopedProjectId);

    if (!game || game.status !== 'OPEN') {
      throw new ForbiddenException('Les resultats finaux sont verrouilles apres la cloture du concours');
    }

    const card = await this.getCard(cardId, scopedProjectId);
    const fieldById = new Set(card.fields.map((field) => field.id));

    for (const value of dto.values) {
      if (!fieldById.has(value.fieldId)) {
        throw new BadRequestException('Unknown field in outcomes');
      }

      const existingOutcome = await this.prismaService.predictionOutcome.findFirst({
        where: {
          cardId,
          fieldId: value.fieldId,
          ...this.buildProjectScopeFilter(scopedProjectId),
          ...this.buildBabyScopeFilter(selectedBabyIndex),
        },
      });

      if (existingOutcome) {
        await this.prismaService.predictionOutcome.update({
          where: { id: existingOutcome.id },
          data: {
            projectId: scopedProjectId,
            selectedBabyIndex,
            valueText: value.valueText,
            valueNumber: value.valueNumber,
            valueDate: value.valueDate ? new Date(value.valueDate) : null,
            setById: userId,
            setAt: new Date(),
          },
        });
        continue;
      }

      await this.prismaService.predictionOutcome.create({
        data: {
          projectId: scopedProjectId,
          cardId,
          fieldId: value.fieldId,
          selectedBabyIndex,
          valueText: value.valueText,
          valueNumber: value.valueNumber,
          valueDate: value.valueDate ? new Date(value.valueDate) : undefined,
          setById: userId,
        },
      });
    }

    await this.createActivity({
      projectId: scopedProjectId,
      userId,
      cardId,
      type: PredictionActivityType.OUTCOME_SET,
      message: `Resultat reel defini pour ${card.title} (Bebe ${selectedBabyIndex})`,
    });

    return this.prismaService.predictionOutcome.findMany({
      where: {
        cardId,
        ...this.buildProjectScopeFilter(scopedProjectId),
        ...this.buildBabyScopeFilter(selectedBabyIndex),
      },
      include: { field: true },
    });
  }

  async suggestScoresForCard(
    userId: string,
    cardId: string,
    projectId?: string,
    selectedBabyIndex?: number,
  ) {
    const scopedProjectId = this.resolveProjectId(projectId);
    const normalizedBabyIndex = this.normalizeSelectedBabyIndex(selectedBabyIndex);
    await this.assertProjectAccess(userId, scopedProjectId, true);

    const game = await this.getGameForProject(scopedProjectId);

    if (!game || game.status !== 'CLOSED') {
      throw new ForbiddenException('Le concours doit etre cloture pour lancer le calcul des points');
    }

    const isFinalized = await this.isContestFinalizedSinceLastOpen(scopedProjectId);
    if (isFinalized) {
      throw new ForbiddenException('Le concours est deja valide');
    }

    const card = await this.prismaService.predictionCard.findFirst({
      where: {
        id: cardId,
        ...this.buildProjectScopeFilter(scopedProjectId),
      },
      include: {
        fields: true,
        outcomes: {
          where: this.buildBabyScopeFilter(normalizedBabyIndex),
        },
        entries: {
          where: this.buildBabyScopeFilter(normalizedBabyIndex),
          include: {
            values: true,
            user: true,
          },
        },
      },
    });

    if (!card) {
      throw new NotFoundException('Card not found');
    }

    const outcomesByField = new Map(card.outcomes.map((outcome) => [outcome.fieldId, outcome]));
    const allOutcomeTexts = card.outcomes
      .map((outcome) => this.normalizeText(outcome.valueText))
      .filter((value) => value.length > 0);

    const nearestDiffByField = new Map<string, number>();
    if (card.valueType === PredictionValueType.NUMBER) {
      for (const field of card.fields) {
        const outcome = outcomesByField.get(field.id);
        if (!outcome || outcome.valueNumber == null) {
          continue;
        }

        const distances = card.entries
          .map((entry) => entry.values.find((value) => value.fieldId === field.id)?.valueNumber)
          .filter((value): value is number => value != null)
          .map((value) => Math.abs(value - outcome.valueNumber!));

        if (distances.length === 0) {
          continue;
        }

        nearestDiffByField.set(field.id, Math.min(...distances));
      }
    }

    for (const entry of card.entries) {
      for (const field of card.fields) {
        const predictedValue = entry.values.find((value) => value.fieldId === field.id);
        const outcome = outcomesByField.get(field.id);

        let suggestedPoints = 0;

        if (predictedValue && outcome) {
          if (card.valueType === PredictionValueType.NUMBER) {
            const predicted = predictedValue.valueNumber;
            const actual = outcome.valueNumber;
            const nearestDiff = nearestDiffByField.get(field.id);
            if (predicted != null && actual != null && nearestDiff != null) {
              const diff = Math.abs(predicted - actual);
              if (Math.abs(diff - nearestDiff) < 0.0000001) {
                suggestedPoints = field.points;
              }
            }
          }

          if (
            card.valueType === PredictionValueType.TEXT ||
            card.valueType === PredictionValueType.SELECT
          ) {
            if (
              this.normalizeText(predictedValue.valueText) ===
              this.normalizeText(outcome.valueText)
            ) {
              suggestedPoints = field.points;
            }
          }

          if (card.valueType === PredictionValueType.DATE) {
            const predicted = predictedValue.valueDate?.toISOString();
            const actual = outcome.valueDate?.toISOString();
            if (predicted && actual && predicted === actual) {
              suggestedPoints = field.points;
            }
          }
        }

        if (card.valueType === PredictionValueType.MULTI_TEXT && predictedValue?.valueText) {
          const normalized = this.normalizeText(predictedValue.valueText);

          if (field.isPrimary) {
            if (allOutcomeTexts.includes(normalized)) {
              suggestedPoints = field.points;
            }
          } else if (allOutcomeTexts.includes(normalized)) {
            suggestedPoints = field.points;
          }
        }

        await this.prismaService.predictionFieldScore.upsert({
          where: {
            entryId_fieldId: {
              entryId: entry.id,
              fieldId: field.id,
            },
          },
          update: {
            projectId: scopedProjectId,
            suggestedPoints,
          },
          create: {
            projectId: scopedProjectId,
            entryId: entry.id,
            fieldId: field.id,
            suggestedPoints,
          },
        });
      }
    }

    await this.createActivity({
      projectId: scopedProjectId,
      userId,
      cardId,
      type: PredictionActivityType.SCORE_SUGGESTED,
      message: `Suggestion de score calculee pour ${card.title} (Bebe ${normalizedBabyIndex})`,
    });

    return this.prismaService.predictionEntry.findMany({
      where: {
        cardId,
        ...this.buildProjectScopeFilter(scopedProjectId),
        ...this.buildBabyScopeFilter(normalizedBabyIndex),
      },
      include: {
        user: true,
        scores: { include: { field: true } },
      },
      orderBy: { updatedAt: 'desc' },
    });
  }

  async validateScores(userId: string, entryId: string, dto: ValidateScoresDto, projectId?: string) {
    const scopedProjectId = this.resolveProjectId(projectId);
    await this.assertProjectAccess(userId, scopedProjectId, true);

    const game = await this.getGameForProject(scopedProjectId);

    if (!game || game.status !== 'CLOSED') {
      throw new ForbiddenException('Le concours doit etre cloture pour valider des points');
    }

    const isFinalized = await this.isContestFinalizedSinceLastOpen(scopedProjectId);
    if (isFinalized) {
      throw new ForbiddenException('Le concours est deja valide');
    }

    const entry = await this.prismaService.predictionEntry.findFirst({
      where: {
        id: entryId,
        ...this.buildProjectScopeFilter(scopedProjectId),
      },
    });

    if (!entry) {
      throw new NotFoundException('Entry not found');
    }

    for (const score of dto.scores) {
      await this.prismaService.predictionFieldScore.upsert({
        where: {
          entryId_fieldId: {
            entryId,
            fieldId: score.fieldId,
          },
        },
        update: {
          projectId: scopedProjectId,
          awardedPoints: score.awardedPoints,
          isValidated: true,
          validatedById: userId,
          validatedAt: new Date(),
          note: score.note,
        },
        create: {
          projectId: scopedProjectId,
          entryId,
          fieldId: score.fieldId,
          suggestedPoints: 0,
          awardedPoints: score.awardedPoints,
          isValidated: true,
          validatedById: userId,
          validatedAt: new Date(),
          note: score.note,
        },
      });
    }

    const scores = await this.prismaService.predictionFieldScore.findMany({
      where: { entryId },
    });

    const totalPoints = scores.reduce(
      (total, score) => total + (score.awardedPoints ?? 0),
      0,
    );

    const updatedEntry = await this.prismaService.predictionEntry.update({
      where: { id: entryId },
      data: {
        totalPoints,
        isScored: true,
      },
    });

    await this.createActivity({
      projectId: scopedProjectId,
      userId,
      entryId,
      cardId: entry.cardId,
      type: PredictionActivityType.SCORE_VALIDATED,
      message: 'Score valide manuellement',
    });

    return updatedEntry;
  }

  async finalizeGame(userId: string, projectId?: string) {
    const scopedProjectId = this.resolveProjectId(projectId);
    await this.assertProjectAccess(userId, scopedProjectId, true);

    const game = await this.getGameForProject(scopedProjectId);

    if (!game || game.status !== 'CLOSED') {
      throw new ForbiddenException('Le concours doit etre cloture avant la validation finale');
    }

    const alreadyFinalized = await this.isContestFinalizedSinceLastOpen(scopedProjectId);
    if (alreadyFinalized) {
      throw new BadRequestException('Le concours est deja valide');
    }

    const cards = await this.assertRequiredOutcomesAreFilled(scopedProjectId);
    const activeCardIds = cards.map((card) => card.id);

    if (activeCardIds.length === 0) {
      throw new BadRequestException('Aucune categorie active a valider');
    }

    const entries = await this.prismaService.predictionEntry.findMany({
      where: {
        cardId: { in: activeCardIds },
        ...this.buildProjectScopeFilter(scopedProjectId),
      },
      select: {
        id: true,
        isScored: true,
      },
    });

    if (entries.length === 0) {
      throw new BadRequestException('Aucun pronostic participant a valider');
    }

    const remainingToScore = entries.filter((entry) => !entry.isScored).length;
    if (remainingToScore > 0) {
      throw new BadRequestException(
        `Validation impossible: ${remainingToScore} pronostic(s) n'ont pas encore ete notes`,
      );
    }

    const scoreboard = await this.getScoreboard(scopedProjectId);
    if (scoreboard.length === 0) {
      throw new BadRequestException('Classement indisponible');
    }

    const winnerPoints = scoreboard[0].totalPoints;
    const winners = scoreboard.filter((participant) => participant.totalPoints === winnerPoints);

    await this.createActivity({
      projectId: scopedProjectId,
      userId,
      type: PredictionActivityType.SCORE_VALIDATED,
      message: `${this.contestFinalizedMarker} Gagnant(s): ${winners
        .map((winner) => winner.displayName ?? winner.username)
        .join(', ')} (${winnerPoints} pts)`,
    });

    return {
      finalized: true,
      winners,
      scoreboard,
    };
  }

  async getBoard(userId: string, projectId?: string, selectedBabyIndex?: number) {
    const scopedProjectId = this.resolveProjectId(projectId);
    const normalizedSelectedBabyIndex =
      selectedBabyIndex == null
        ? undefined
        : this.normalizeSelectedBabyIndex(selectedBabyIndex);
    await this.assertProjectAccess(userId, scopedProjectId);

    const gamePromise =
      scopedProjectId === DEFAULT_PROJECT_ID
        ? this.prismaService.predictionGame.findUnique({ where: { id: 'singleton' } })
        : this.prismaService.predictionGame.findFirst({ where: { projectId: scopedProjectId } });

    const [game, isFinalized] = await Promise.all([
      gamePromise,
      this.isContestFinalizedSinceLastOpen(scopedProjectId),
    ]);

    const scopeFilter = this.buildProjectScopeFilter(scopedProjectId);

    const cards = await this.prismaService.predictionCard.findMany({
      where: {
        isActive: true,
        ...scopeFilter,
      },
      include: {
        fields: { orderBy: { sortOrder: 'asc' } },
        options: { orderBy: { sortOrder: 'asc' } },
        outcomes: {
          ...(normalizedSelectedBabyIndex != null
            ? {
                where: this.buildBabyScopeFilter(normalizedSelectedBabyIndex),
              }
            : {}),
          include: { field: true },
          orderBy: { field: { sortOrder: 'asc' } },
        },
        entries: {
          ...(normalizedSelectedBabyIndex != null
            ? {
                where: this.buildBabyScopeFilter(normalizedSelectedBabyIndex),
              }
            : {}),
          include: {
            user: true,
            values: { include: { field: true } },
          },
        },
      },
      orderBy: { sortOrder: 'asc' },
    });

    const byCard = cards.map((card) => {
      const values = card.entries.flatMap((entry) => entry.values);

      let averageNumber: number | null = null;
      if (card.valueType === PredictionValueType.NUMBER) {
        const numbers = values
          .map((value) => value.valueNumber)
          .filter((value): value is number => value != null);
        if (numbers.length > 0) {
          averageNumber = numbers.reduce((total, value) => total + value, 0) / numbers.length;
        }
      }

      const textCounts = new Map<string, number>();
      for (const value of values) {
        if (!value.valueText) {
          continue;
        }
        const normalized = this.normalizeText(value.valueText);
        if (!normalized) {
          continue;
        }
        textCounts.set(normalized, (textCounts.get(normalized) ?? 0) + 1);
      }

      const topTexts = Array.from(textCounts.entries())
        .sort((a, b) => b[1] - a[1])
        .slice(0, 5)
        .map(([value, count]) => ({ value, count }));

      return {
        id: card.id,
        title: card.title,
        type: card.valueType,
        unit: card.unit,
        styleId: card.styleId,
        totalPredictions: card.entries.length,
        averageNumber,
        topTexts,
        outcomes: card.outcomes.map((outcome) => ({
          fieldId: outcome.fieldId,
          fieldLabel: outcome.field.label,
          selectedBabyIndex: outcome.selectedBabyIndex,
          valueText: outcome.valueText,
          valueNumber: outcome.valueNumber,
          valueDate: outcome.valueDate,
        })),
        entries: card.entries.map((entry) => ({
          entryId: entry.id,
          userId: entry.userId,
          selectedBabyIndex: entry.selectedBabyIndex,
          username: entry.user.username,
          displayName: entry.user.displayName,
          profileImageUrl: entry.user.profileImageUrl,
          profileBgColor: entry.user.profileBgColor,
          isScored: entry.isScored,
          totalPoints: entry.totalPoints,
          values: entry.values.map((value) => ({
            fieldId: value.fieldId,
            fieldLabel: value.field.label,
            valueText: value.valueText,
            valueNumber: value.valueNumber,
            valueDate: value.valueDate,
          })),
          updatedAt: entry.updatedAt,
        })),
      };
    });

    return {
      game: game
        ? {
            ...game,
            finalized: game.status === 'CLOSED' && isFinalized,
          }
        : null,
      cards: byCard,
    };
  }

  async getScoreboard(projectId?: string) {
    const scopedProjectId = this.resolveProjectId(projectId);

    const entries = await this.prismaService.predictionEntry.findMany({
      where: this.buildProjectScopeFilter(scopedProjectId),
      include: { user: true },
    });

    const map = new Map<
      string,
      {
        userId: string;
        username: string;
        displayName: string | null;
        profileImageUrl: string | null;
        profileBgColor: string | null;
        totalPoints: number;
      }
    >();

    for (const entry of entries) {
      const existing = map.get(entry.userId);
      if (!existing) {
        map.set(entry.userId, {
          userId: entry.userId,
          username: entry.user.username,
          displayName: entry.user.displayName,
          profileImageUrl: entry.user.profileImageUrl,
          profileBgColor: entry.user.profileBgColor,
          totalPoints: entry.totalPoints,
        });
        continue;
      }
      existing.totalPoints += entry.totalPoints;
    }

    return Array.from(map.values()).sort((a, b) => b.totalPoints - a.totalPoints);
  }
}
