import {
  Injectable,
  Logger,
  HttpException,
  HttpStatus,
  OnModuleDestroy,
  OnModuleInit,
  Optional,
} from "@nestjs/common";
import { PrismaService } from "../../database/prisma.service";
import { ConfigService } from "@nestjs/config";
import { QueueEvents } from "bullmq";
import { PredictionsQueue } from "./queues/predictions.queue";
import { PREDICTIONS_QUEUE } from "./queues/predictions.types";
import {
  MatchPredictionDto,
  PredictionHistoryResponseDto,
  UpcomingPredictionsDto,
  ValueBetDto,
  AIHealthDto,
} from "./dto";
import { Prisma } from "@prisma/client";
import { FeederService } from "../feeder/feeder.service";
import {
  isMatchCompleted,
  isMatchLive,
} from "../../common/utils/match-status.util";
import * as fs from "node:fs";
import * as path from "node:path";
import {
  AiEngineClient,
  AiEngineRequestError,
} from "../../common/utils/ai-engine-client";
import { generateExpertCommentary } from "../../common/utils/ollama-commentary";

type ConfidenceBand = "HIGH" | "MEDIUM" | "LOW";

interface ConfidenceInterval {
  lower: number;
  upper: number;
  width: number;
  band: ConfidenceBand;
  threshold_met: boolean;
}

interface MatchContext {
  leagueId: string | null;
  isTopLeague: boolean;
}

@Injectable()
export class PredictionsService implements OnModuleInit, OnModuleDestroy {
  private readonly logger = new Logger(PredictionsService.name);
  private queueEvents: QueueEvents | null = null;
  private readonly aiEngineUrl: string;
  private readonly aiEngineClient: AiEngineClient;
  private readonly qualifiedLeagueIds = new Set<string>();
  private readonly reasonTranslations: Record<string, string> = {
    confidence_below_threshold: "Güven eşiğin altında",
    confidence_interval_too_wide: "Güven aralığı çok geniş",
    confidence_interval_too_wide_for_main_pick:
      "Ana seçim için güven aralığı çok geniş",
    confidence_band_low: "Güven bandı düşük",
    playable_edge_found: "Model avantaj sinyali bulundu",
    market_signal_dominant: "Piyasa sinyali baskın",
    team_form_signal_dominant: "Takım formuna dayalı sinyaller çok baskın",
    lineup_signal_strong: "İlk on bir sinyali güçlü",
    lineup_signal_weak: "İlk on bir sinyali zayıf",
    lineup_probable_xi_used: "Muhtemel ilk on bir kullanıldı",
    lineup_probable_not_confirmed: "Muhtemel ilk on bir henüz doğrulanmadı",
    lineup_unavailable: "İlk on bir bilgisi mevcut değil",
    lineup_incomplete: "İlk on bir bilgisi eksik",
    missing_referee: "Hakem verisi eksik",
    draw_probability_elevated: "Beraberlik olasılığı yükselmiş görünüyor",
    balanced_match_risk: "Maç dengeli, sürpriz riski yükseliyor",
    draw_pressure: "Beraberlik baskısı yüksek",
    upset_risk_detected: "Sürpriz riski tespit edildi",
    limited_data_confidence: "Veri kısıtlı olduğu için güven sınırlı",
    data_quality_issue: "Veri kalitesi sorunu var",
    high_risk_low_data_quality: "Risk yüksek, veri kalitesi düşük",
    insufficient_play_score: "Model sinyali yetersiz",
    odds_band_confirms_value: "Tarihsel oran bandı değeri doğruluyor",
    odds_band_sample_too_low: "Tarihsel oran bandı örneklemi yetersiz",
    odds_band_missing_probability: "Tarihsel oran bandı olasılığı yok",
    odds_band_unavailable: "Tarihsel oran bandı kullanılamıyor",
    odds_band_not_aligned: "Model ve tarihsel oran bandı aynı yönde değil",
    no_bet_conditions_met: "Bahis koşulları oluşmadı",
    market_passed_all_gates: "Market tüm güvenlik kontrollerini geçti",
    no_ev_edge_minimum_stake:
      "Beklenen avantaj oluşmadı, minimum bahis önerildi",
    player_form_signal_strong: "Oyuncu formu sinyali güçlü",
    player_form_signal_limited: "Oyuncu formu sinyali sınırlı",
    live_state_impossible_market:
      "Canlı maç durumu bu marketi geçersiz kılıyor",
    live_score_exceeds_under_line: "Mevcut skor bu alt seçeneğiyle çelişiyor",
    score_model_conflicts_with_under_pick:
      "Skor modeli alt seçeneğiyle çelişiyor",
    score_model_conflicts_with_over_pick:
      "Skor modeli üst seçeneğiyle çelişiyor",
    market_stack_conflict_over25:
      "Üst 2.5 sinyaliyle çeliştiği için zayıflatıldı",
    market_stack_conflict_btts:
      "Karşılıklı gol sinyaliyle çeliştiği için zayıflatıldı",
    first_half_result_conflicts_with_goalless_half:
      "İlk yarı sonucu beklentisi golsüz ilk yarıyla çelişiyor",
    first_half_htft_conflicts_with_goalless_half:
      "İlk yarı/maç sonu beklentisi golsüz ilk yarıyla çelişiyor",
    first_half_draw_conflicts_with_goal_pick:
      "İlk yarı beraberlik baskısı erken gol beklentisiyle çelişiyor",
    first_half_goalless_conflicts_with_result_pick:
      "Golsüz ilk yarı beklentisi ilk yarı sonuç seçimiyle çelişiyor",
    first_half_goalless_conflicts_with_htft_pick:
      "Golsüz ilk yarı beklentisi ilk yarı/maç sonu seçimiyle çelişiyor",
    first_half_goal_pressure_conflicts_with_htft_draw:
      "İlk yarı gol baskısı ilk yarı beraberlik kurgusuyla çelişiyor",
    live_total_goals_close_to_line: "Canlı toplam gol çizgisine çok yakın",
    score_model_conflicts_with_btts_no:
      "Skor modeli KG Yok seçeneğiyle çelişiyor",
    score_model_conflicts_with_draw_pick:
      "Skor modeli beraberlik seçeneğiyle çelişiyor",
    score_model_conflicts_with_home_pick:
      "Skor modeli ev sahibi seçeneğiyle çelişiyor",
    score_model_conflicts_with_away_pick:
      "Skor modeli deplasman seçeneğiyle çelişiyor",
    high_total_goal_volatility: "Toplam gol volatilitesi yüksek",
    mutual_goal_pressure: "İki takımın da gol baskısı yüksek",
    late_goal_swing_risk: "Geç gol kaynaklı kırılma riski var",
    live_match_open_state: "Canlı maç açık oyuna dönmüş durumda",
    live_match_active_state: "Canlı maç aktif ve dalgalı ilerliyor",
  };

  constructor(
    private readonly prisma: PrismaService,
    private readonly configService: ConfigService,
    private readonly feederService: FeederService,
    @Optional() private readonly predictionsQueue?: PredictionsQueue,
  ) {
    this.aiEngineUrl = this.resolveAiEngineUrl();
    this.aiEngineClient = new AiEngineClient({
      baseUrl: this.aiEngineUrl,
      logger: this.logger,
      serviceName: PredictionsService.name,
      timeoutMs: 60000,
      maxRetries: 2,
      retryDelayMs: 750,
    });
    this.qualifiedLeagueIds = this.loadQualifiedLeagueIds();
  }

  onModuleInit() {
    if (this.predictionsQueue) {
      this.queueEvents = new QueueEvents(PREDICTIONS_QUEUE, {
        connection: {
          host: this.configService.get("redis.host", "localhost"),
          port: this.configService.get("redis.port", 6379),
          password: this.configService.get("redis.password"),
        },
      });
      this.logger.log("Queue mode enabled for predictions");
    } else {
      this.logger.log("Direct HTTP mode enabled for predictions (no Redis)");
    }
  }

  private predictionMemCache = new Map<
    string,
    { timestamp: number; payload: MatchPredictionDto }
  >();

  async onModuleDestroy() {
    if (this.queueEvents) {
      await this.queueEvents.close();
    }
  }

  async checkHealth(): Promise<AIHealthDto> {
    const circuit = this.aiEngineClient.getSnapshot();

    try {
      const response = await this.aiEngineClient.get<{
        status?: string;
        model_loaded?: boolean;
        prediction_service_ready?: boolean;
      }>("/health", {
        timeout: 5000,
        retryCount: 0,
      });

      return {
        status: response.data?.status || "healthy",
        modelLoaded: response.data?.model_loaded ?? true,
        predictionServiceReady: response.data?.prediction_service_ready ?? true,
        aiEngineReachable: true,
        circuitState: circuit.state,
        consecutiveFailures: circuit.consecutiveFailures,
        endpoint: this.aiEngineUrl,
        mode:
          typeof (response.data as Record<string, unknown>)?.mode === "string"
            ? String((response.data as Record<string, unknown>).mode)
            : this.configService.get("AI_ENGINE_MODE", "v28-pro-max"),
      };
    } catch (error: unknown) {
      const requestError =
        error instanceof AiEngineRequestError
          ? error
          : new AiEngineRequestError("AI health check failed");

      return {
        status: requestError.isCircuitOpen ? "circuit_open" : "unhealthy",
        modelLoaded: false,
        predictionServiceReady: false,
        aiEngineReachable: false,
        circuitState: this.aiEngineClient.getSnapshot().state,
        consecutiveFailures:
          this.aiEngineClient.getSnapshot().consecutiveFailures,
        endpoint: this.aiEngineUrl,
        detail:
          typeof requestError.detail === "string"
            ? requestError.detail
            : requestError.message,
        mode: this.configService.get("AI_ENGINE_MODE", "v28-pro-max"),
      };
    }
  }

  async getPredictionById(matchId: string): Promise<MatchPredictionDto | null> {
    await this.ensurePredictionDataReady(matchId);
    const matchContext = await this.getMatchContext(matchId);

    // Queue mode (Redis enabled) - REMOVED per user request to always fetch from scratch
    // Direct HTTP mode (no Redis)
    try {
      const response = await this.aiEngineClient.post<MatchPredictionDto>(
        `/v20plus/analyze/${matchId}`,
        { simulate: true, is_simulation: true, pre_match_only: true },
      );
      const prediction = this.enrichPredictionResponse(
        response.data,
        matchContext,
      );

      await this.recordPredictionRun(matchId, response.data);
      await this.cachePrediction(matchId, prediction);
      return prediction;
    } catch (e: unknown) {
      const requestError =
        e instanceof AiEngineRequestError
          ? e
          : new AiEngineRequestError("AI Engine request failed");
      const status = requestError.status;
      const detail = requestError.detail || requestError.message;

      // ── Cooldown fallback cascade: memCache → DB stored → DB cached → wait & retry ──
      if (
        status === HttpStatus.SERVICE_UNAVAILABLE &&
        this.hasCooldown(detail)
      ) {
        // 1) In-memory cache (10min TTL)
        const memCached = this.predictionMemCache.get(matchId);
        if (memCached && Date.now() - memCached.timestamp < 10 * 60 * 1000) {
          this.logger.warn(
            `AI Engine cooldown for ${matchId}; returning mem-cached prediction`,
          );
          return memCached.payload;
        }

        // 2) DB stored prediction (no TTL filter)
        const storedPrediction = await this.getStoredPrediction(matchId);
        if (storedPrediction) {
          this.logger.warn(
            `AI Engine cooldown for ${matchId}; returning stored prediction`,
          );
          return this.enrichPredictionResponse(storedPrediction, matchContext);
        }

        // 3) DB cached prediction (with model version check)
        const cachedPrediction = await this.getCachedPrediction(matchId);
        if (cachedPrediction) {
          this.logger.warn(
            `AI Engine cooldown for ${matchId}; returning cached prediction`,
          );
          return this.enrichPredictionResponse(cachedPrediction, matchContext);
        }

        // 4) No cached data at all — return null gracefully
        this.logger.warn(
          `AI Engine cooldown for ${matchId}; no cached data available — returning null gracefully`,
        );
        return null;
      }

      // ── Non-cooldown errors (e.g. AI Engine 500 for this match) ──
      // Try DB fallback before giving up
      const storedFallback = await this.getStoredPrediction(matchId);
      if (storedFallback) {
        this.logger.warn(
          `AI Engine failed for ${matchId} (status=${status}); returning stored prediction as fallback`,
        );
        return this.enrichPredictionResponse(storedFallback, matchContext);
      }

      const cachedFallback = await this.getCachedPrediction(matchId);
      if (cachedFallback) {
        this.logger.warn(
          `AI Engine failed for ${matchId} (status=${status}); returning cached prediction as fallback`,
        );
        return this.enrichPredictionResponse(cachedFallback, matchContext);
      }

      this.logger.error(
        `Direct AI Engine call failed for ${matchId}: status=${status}, detail=${JSON.stringify(detail)}`,
      );

      // Forward AI Engine's actual error for client-meaningful statuses
      if (status === 404) {
        throw new HttpException(
          `Match not found in AI Engine: ${matchId}`,
          HttpStatus.NOT_FOUND,
        );
      }
      if (status === 422) {
        throw new HttpException(
          `AI Engine: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`,
          HttpStatus.UNPROCESSABLE_ENTITY,
        );
      }

      // For server errors (500, 503 etc.) return null instead of throwing
      // This prevents the user from seeing raw 503 errors
      this.logger.warn(
        `AI Engine server error for ${matchId}; returning null gracefully instead of ${status}`,
      );
      return null;
    }
  }

  async getPredictionWithData(matchDetails: {
    matchId: string;
  }): Promise<MatchPredictionDto | null> {
    return this.getPredictionById(matchDetails.matchId);
  }

  async testPrediction(matchId: string): Promise<MatchPredictionDto | null> {
    this.logger.log(`[TEST PREDICTION] Syncing match data for ${matchId}...`);
    // refreshMatch triggers the feeder scraper to get all match info, odds, and lineups and write to DB
    const refreshResult = await this.feederService.refreshMatch(matchId, "all");

    if (!refreshResult.success) {
      this.logger.warn(
        `Failed to refresh match ${matchId} before test prediction. Proceeding with existing data anyway.`,
      );
    } else {
      this.logger.log(
        `Successfully refreshed match ${matchId}. Calling AI Engine...`,
      );
    }

    return this.getPredictionById(matchId);
  }

  async getUpcomingPredictions(): Promise<UpcomingPredictionsDto> {
    const upcoming = await this.prisma.prediction.findMany({
      where: {
        match: {
          status: "NS",
          mstUtc: { gte: Math.floor(Date.now() / 1000) },
        },
      },
      include: {
        match: {
          include: { homeTeam: true, awayTeam: true, league: true },
        },
      },
      orderBy: { match: { mstUtc: "asc" } },
      take: 50,
    });

    return {
      count: upcoming.length,
      modelVersion: "v28-pro-max",
      matches: upcoming.map((p) => {
        const out = p.predictionJson as Record<string, unknown>;
        const matchInfo = (out?.match_info || {}) as Record<string, unknown>;
        return {
          ...out,
          match_info: {
            ...matchInfo,
            match_name: `${p.match.homeTeam?.name} vs ${p.match.awayTeam?.name}`,
            match_date_ms: Number(p.match.mstUtc) * 1000,
            league: p.match.league?.name || "",
            league_id: p.match.leagueId,
            is_top_league: this.qualifiedLeagueIds.has(p.match.leagueId ?? ""),
          },
        } as unknown as MatchPredictionDto;
      }),
    };
  }

  private loadQualifiedLeagueIds(): Set<string> {
    try {
      const filePath = path.join(process.cwd(), "qualified_leagues.json");
      if (!fs.existsSync(filePath)) {
        this.logger.warn(
          "qualified_leagues.json not found — all leagues allowed",
        );
        return new Set<string>();
      }

      const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
      if (!Array.isArray(raw)) {
        return new Set<string>();
      }

      const ids = new Set(
        raw
          .map((value) => String(value).trim())
          .filter((value) => value.length > 0),
      );
      this.logger.log(`Loaded ${ids.size} qualified league IDs`);
      return ids;
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      this.logger.warn(`Failed to load qualified_leagues.json: ${message}`);
      return new Set<string>();
    }
  }

  private resolveAiEngineUrl(): string {
    const configuredUrl = this.configService.get(
      "AI_ENGINE_URL",
      "http://localhost:8000",
    );
    const localEnvUrl = this.readLocalEnvValue("AI_ENGINE_URL");

    if (
      process.env.NODE_ENV !== "production" &&
      localEnvUrl &&
      localEnvUrl !== configuredUrl &&
      this.isLocalhostUrl(configuredUrl) &&
      this.isLocalhostUrl(localEnvUrl)
    ) {
      this.logger.warn(
        `AI_ENGINE_URL inherited from parent process (${configuredUrl}) differs from .env.local (${localEnvUrl}); using .env.local for local development`,
      );
      return localEnvUrl;
    }

    return configuredUrl;
  }

  private readLocalEnvValue(key: string): string | null {
    const filePath = path.join(process.cwd(), ".env.local");
    if (!fs.existsSync(filePath)) {
      return null;
    }

    const line = fs
      .readFileSync(filePath, "utf8")
      .split(/\r?\n/u)
      .find((entry) => entry.trim().startsWith(`${key}=`));

    if (!line) {
      return null;
    }

    return line
      .slice(line.indexOf("=") + 1)
      .trim()
      .replace(/^['"]|['"]$/gu, "");
  }

  private isLocalhostUrl(value: string): boolean {
    try {
      const url = new URL(value);
      return ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
    } catch {
      return false;
    }
  }

  private async getMatchContext(matchId: string): Promise<MatchContext> {
    const match = await this.prisma.match.findUnique({
      where: { id: matchId },
      select: { leagueId: true },
    });

    if (match) {
      return {
        leagueId: match.leagueId ?? null,
        isTopLeague: this.qualifiedLeagueIds.has(match.leagueId ?? ""),
      };
    }

    const liveMatch = await this.prisma.liveMatch.findUnique({
      where: { id: matchId },
      select: { leagueId: true },
    });

    return {
      leagueId: liveMatch?.leagueId ?? null,
      isTopLeague: this.qualifiedLeagueIds.has(liveMatch?.leagueId ?? ""),
    };
  }

  private enrichPredictionResponse(
    prediction: MatchPredictionDto,
    matchContext: MatchContext,
  ): MatchPredictionDto {
    const response = prediction as MatchPredictionDto & Record<string, unknown>;
    const dataQuality = this.asRecord(response.data_quality);
    const risk = this.asRecord(response.risk);
    const marketBoard = this.asRecord(response.market_board);
    const matchInfo = {
      ...this.asRecord(response.match_info),
      league_id:
        this.asRecord(response.match_info).league_id ?? matchContext.leagueId,
      is_top_league:
        this.asRecord(response.match_info).is_top_league ??
        matchContext.isTopLeague,
    };

    const mainPick = this.enrichPick(
      response.main_pick,
      response,
      matchContext,
      marketBoard,
    );
    const valuePick = this.enrichPick(
      response.value_pick,
      response,
      matchContext,
      marketBoard,
    );
    const aggressivePick = this.enrichPick(
      response.aggressive_pick,
      response,
      matchContext,
      marketBoard,
    );

    const supportingPicks = Array.isArray(response.supporting_picks)
      ? response.supporting_picks
          .map((pick) =>
            this.enrichPick(pick, response, matchContext, marketBoard),
          )
          .filter((pick): pick is NonNullable<typeof pick> => pick !== null)
      : [];

    const betSummary = Array.isArray(response.bet_summary)
      ? response.bet_summary.map((item) =>
          this.enrichSummary(item, response, matchContext, marketBoard),
        )
      : [];

    const mainBand = this.asRecord(mainPick?.confidence_interval).band ?? "LOW";
    const minConfidenceForPlay = this.getMinConfidenceForPlay(
      this.asRecord(mainPick).market,
      matchContext.isTopLeague,
    );
    const isMainPlayable =
      Boolean(this.asRecord(mainPick).playable) &&
      Boolean(this.asRecord(mainPick?.confidence_interval).threshold_met);
    const mainSignalTier = this.classifySignalTier(
      this.asRecord(mainPick),
      this.asRecord(mainPick?.confidence_interval),
    );

    const reasoningFactors = Array.isArray(response.reasoning_factors)
      ? response.reasoning_factors.map((reason) =>
          this.translateReason(String(reason)),
        )
      : [];

    if (mainPick && !isMainPlayable) {
      reasoningFactors.unshift(
        this.translateReason("confidence_interval_too_wide_for_main_pick"),
      );
    }

    const betAdvice = {
      ...this.asRecord(response.bet_advice),
      playable: isMainPlayable,
      confidence_band: mainBand,
      min_confidence_for_play: minConfidenceForPlay,
      signal_tier: mainSignalTier,
      reason: this.translateReason(
        isMainPlayable
          ? String(
              this.asRecord(response.bet_advice).reason ||
                "playable_edge_found",
            )
          : "confidence_below_threshold",
      ),
      suggested_stake_units: isMainPlayable
        ? Number(this.asRecord(response.bet_advice).suggested_stake_units ?? 0)
        : 0,
    };

    const enrichedMarketBoard = Object.fromEntries(
      Object.entries(marketBoard).map(([market, entry]) => {
        const record = this.asRecord(entry);
        const pickName = String(record.pick ?? "");
        if (!pickName || !record.probs || typeof record.probs !== "object") {
          return [market, record];
        }

        const syntheticPick = {
          market,
          pick: pickName,
          probability: this.lookupProbability(
            record.probs as Record<string, unknown>,
            pickName,
          ),
          confidence: Number(record.confidence ?? 0),
          calibrated_confidence: Number(record.confidence ?? 0),
          raw_confidence: Number(record.confidence ?? 0),
          min_required_confidence: 0,
          odds: 0,
          edge: 0,
          ev_edge: 0,
          implied_prob: 0,
          play_score: 0,
          playable: false,
          bet_grade: "PASS",
          stake_units: 0,
          decision_reasons: [],
        };

        const enriched = this.enrichPick(
          syntheticPick,
          response,
          matchContext,
          marketBoard,
        );

        return [
          market,
          {
            ...record,
            confidence_interval: this.asRecord(enriched?.confidence_interval),
            confidence_band:
              this.asRecord(enriched?.confidence_interval).band ?? "LOW",
          },
        ];
      }),
    );

    return {
      ...response,
      match_info: matchInfo as MatchPredictionDto["match_info"],
      data_quality: {
        ...dataQuality,
        lineup_source: String(dataQuality.lineup_source ?? "none"),
      } as MatchPredictionDto["data_quality"],
      risk: {
        ...risk,
        surprise_type: this.translateReason(String(risk.surprise_type ?? "")),
        surprise_reasons: Array.isArray(risk.surprise_reasons)
          ? risk.surprise_reasons.map((reason) =>
              this.translateReason(String(reason)),
            )
          : [],
        warnings: Array.isArray(risk.warnings)
          ? risk.warnings.map((warning) =>
              this.translateReason(String(warning)),
            )
          : [],
      } as MatchPredictionDto["risk"],
      main_pick: mainPick,
      value_pick: valuePick,
      aggressive_pick: aggressivePick,
      supporting_picks: supportingPicks,
      bet_summary: betSummary,
      bet_advice: betAdvice as MatchPredictionDto["bet_advice"],
      market_board: enrichedMarketBoard,
      reasoning_factors: reasoningFactors,
      model_version: "v28-pro-max",
    };
  }

  private enrichPick(
    pick: unknown,
    prediction: Record<string, unknown>,
    matchContext: MatchContext,
    marketBoard: Record<string, unknown>,
  ): MatchPredictionDto["main_pick"] {
    if (!pick || typeof pick !== "object") {
      return null;
    }

    const record = this.asRecord(pick);
    const market = String(record.market ?? "");
    const pickName = String(record.pick ?? "");
    const probs = this.resolveMarketProbabilities(marketBoard, market);
    const probability =
      this.asNumber(record.probability) ||
      this.lookupProbability(probs, pickName);
    const calibratedConfidence =
      this.asNumber(record.calibrated_confidence) ||
      this.asNumber(record.confidence);
    const impliedProb =
      this.asNumber(record.implied_prob) ||
      this.impliedProbabilityFromOdds(this.asNumber(record.odds));
    const evEdge = this.asNumber(record.ev_edge) || this.asNumber(record.edge);
    const interval = this.estimateConfidenceInterval({
      market,
      probability,
      calibratedConfidence,
      impliedProb,
      evEdge,
      marketBoardProbs: probs,
      dataQualityScore: this.normalizeScore(
        this.asRecord(prediction.data_quality).score,
      ),
      riskScore: this.normalizeScore(this.asRecord(prediction.risk).score),
      lineupSource: String(
        this.asRecord(prediction.data_quality).lineup_source ?? "none",
      ),
      isTopLeague: matchContext.isTopLeague,
    });

    const nextReasons = Array.isArray(record.decision_reasons)
      ? [...record.decision_reasons]
      : [];
    if (!interval.threshold_met) {
      nextReasons.push("confidence_interval_too_wide");
    }
    if (interval.band === "LOW") {
      nextReasons.push("confidence_band_low");
    }

    const displayOdds = this.normalizeDisplayOdds(
      this.asNumber(record.odds),
      impliedProb,
    );

    return {
      ...(record as MatchPredictionDto["main_pick"]),
      market,
      pick: pickName,
      probability,
      confidence: calibratedConfidence || this.asNumber(record.confidence),
      calibrated_confidence: calibratedConfidence,
      raw_confidence:
        this.asNumber(record.raw_confidence) ||
        calibratedConfidence ||
        this.asNumber(record.confidence),
      min_required_confidence: this.asNumber(record.min_required_confidence),
      odds: displayOdds,
      edge: this.asNumber(record.edge),
      play_score: this.asNumber(record.play_score),
      bet_grade: String(record.bet_grade || "PASS") as "A" | "B" | "C" | "PASS",
      implied_prob: impliedProb,
      ev_edge: evEdge,
      playable: Boolean(record.playable) && interval.threshold_met,
      stake_units:
        Boolean(record.playable) && interval.threshold_met
          ? this.asNumber(record.stake_units)
          : 0,
      decision_reasons: Array.from(new Set(nextReasons)).map((reason) =>
        this.translateReason(String(reason)),
      ),
      confidence_interval: interval,
      signal_tier: this.classifySignalTier(record, interval),
      is_guaranteed: false,
    };
  }

  private enrichSummary(
    item: unknown,
    prediction: Record<string, unknown>,
    matchContext: MatchContext,
    marketBoard: Record<string, unknown>,
  ): MatchPredictionDto["bet_summary"][number] {
    const record = this.asRecord(item);
    const market = String(record.market ?? "");
    const pickName = String(record.pick ?? "");
    const probs = this.resolveMarketProbabilities(marketBoard, market);
    const probability = this.lookupProbability(probs, pickName);
    const calibratedConfidence =
      this.asNumber(record.calibrated_confidence) ||
      this.asNumber(record.raw_confidence);
    const odds = this.asNumber(record.odds);
    const impliedProb =
      this.asNumber(record.implied_prob) ||
      this.impliedProbabilityFromOdds(odds);
    const evEdge = this.asNumber(record.ev_edge);

    const interval = this.estimateConfidenceInterval({
      market,
      probability,
      calibratedConfidence,
      impliedProb,
      evEdge,
      marketBoardProbs: probs,
      dataQualityScore: this.normalizeScore(
        this.asRecord(prediction.data_quality).score,
      ),
      riskScore: this.normalizeScore(this.asRecord(prediction.risk).score),
      lineupSource: String(
        this.asRecord(prediction.data_quality).lineup_source ?? "none",
      ),
      isTopLeague: matchContext.isTopLeague,
    });

    return {
      ...(record as MatchPredictionDto["bet_summary"][number]),
      odds: this.normalizeDisplayOdds(odds, impliedProb),
      implied_prob: impliedProb,
      ev_edge: evEdge,
      playable: Boolean(record.playable) && interval.threshold_met,
      stake_units:
        Boolean(record.playable) && interval.threshold_met
          ? this.asNumber(record.stake_units)
          : 0,
      reasons: Array.isArray(record.reasons)
        ? record.reasons.map((reason) => this.translateReason(String(reason)))
        : [],
      confidence_interval: interval,
      signal_tier: this.classifySignalTier(
        {
          ...record,
          odds,
          implied_prob: impliedProb,
          ev_edge: evEdge,
          calibrated_confidence: calibratedConfidence,
        },
        interval,
      ),
    };
  }

  private normalizeDisplayOdds(odds: number, impliedProb: number): number {
    if (odds <= 1.01 && impliedProb <= 0) {
      return 0;
    }

    return odds;
  }

  private translateReason(reason: string): string {
    if (!reason) {
      return "";
    }

    const normalized = reason.startsWith("risk:") ? reason.slice(5) : reason;

    if (this.reasonTranslations[normalized]) {
      return this.reasonTranslations[normalized];
    }

    const evMatch = normalized.match(/^ev_edge_([-+][\d.]+%)_grade_(\w)$/);
    if (evMatch) {
      return `Teorik avantaj sinyali: Not ${evMatch[2]}`;
    }

    const negativeEdgeMatch = normalized.match(
      /^negative_model_edge_([-+]?[\d.]+)$/,
    );
    if (negativeEdgeMatch) {
      return `Model avantajı negatif (${negativeEdgeMatch[1]})`;
    }

    const bandNoValueMatch = normalized.match(
      /^odds_band_no_value_([-+]?[\d.]+)$/,
    );
    if (bandNoValueMatch) {
      return `Tarihsel oran bandı değeri doğrulamadı (${bandNoValueMatch[1]})`;
    }

    const edgeThresholdMatch = normalized.match(
      /^below_market_edge_threshold_([-+]?[\d.]+)$/,
    );
    if (edgeThresholdMatch) {
      return `Piyasa avantaj eşiğinin altında (${edgeThresholdMatch[1]})`;
    }

    return normalized;
  }

  private classifySignalTier(
    record: Record<string, unknown>,
    interval: {
      band?: "HIGH" | "MEDIUM" | "LOW";
      threshold_met?: boolean;
    },
  ): "CORE" | "VALUE" | "LEAN" | "LONGSHOT" | "PASS" {
    const playable =
      Boolean(record.playable) && Boolean(interval.threshold_met);
    const calibratedConfidence = this.asNumber(record.calibrated_confidence);
    const odds = this.asNumber(record.odds);
    const evEdge = this.asNumber(record.ev_edge) || this.asNumber(record.edge);
    const playScore = this.asNumber(record.play_score);
    const band = String(interval.band ?? "LOW").toUpperCase();

    if (
      playable &&
      band === "HIGH" &&
      calibratedConfidence >= 72 &&
      evEdge >= 0.02 &&
      playScore >= 68
    ) {
      return "CORE";
    }

    if (calibratedConfidence >= 52 && odds >= 1.75 && evEdge >= 0.04) {
      return playable ? "VALUE" : "LONGSHOT";
    }

    if (
      calibratedConfidence >= 46 &&
      (band === "HIGH" || band === "MEDIUM" || evEdge > 0)
    ) {
      return "LEAN";
    }

    if (odds >= 2.2 && calibratedConfidence >= 38) {
      return "LONGSHOT";
    }

    return "PASS";
  }

  private estimateConfidenceInterval(input: {
    market: string;
    probability: number;
    calibratedConfidence: number;
    impliedProb: number;
    evEdge: number;
    marketBoardProbs: Record<string, unknown>;
    dataQualityScore: number;
    riskScore: number;
    lineupSource: string;
    isTopLeague: boolean;
  }): ConfidenceInterval {
    const probability = this.clamp(input.probability, 0.01, 0.99);
    const sortedProbs = Object.values(input.marketBoardProbs)
      .map((value) => this.asNumber(value))
      .filter((value) => value > 0)
      .sort((a, b) => b - a);
    const secondProb = sortedProbs[1] ?? 0;
    const topProb = sortedProbs[0] ?? probability;
    const margin = Math.max(0, topProb - secondProb);
    const normalizedConfidence = this.normalizePercent(
      input.calibratedConfidence,
    );

    const baseWidthByMarket: Record<string, number> = {
      MS: 0.18,
      OU25: 0.14,
      BTTS: 0.14,
      HT_OU15: 0.16,
      CARDS: 0.2,
      HCAP: 0.22,
    };
    const baseWidth = baseWidthByMarket[input.market] ?? 0.19;
    const lineupPenalty =
      input.lineupSource === "confirmed_live"
        ? -0.015
        : input.lineupSource === "probable_xi"
          ? 0
          : 0.02;
    const width = this.clamp(
      baseWidth -
        margin * 0.22 -
        normalizedConfidence * 0.05 +
        (1 - input.dataQualityScore) * 0.09 +
        input.riskScore * 0.08 -
        (input.isTopLeague ? 0.012 : 0) +
        lineupPenalty,
      0.08,
      0.34,
    );

    const lower = this.clamp(probability - width / 2, 0.01, 0.99);
    const upper = this.clamp(probability + width / 2, 0.01, 0.99);
    const minConfidence = this.getMinConfidenceForPlay(
      input.market,
      input.isTopLeague,
    );
    const thresholdMet =
      input.calibratedConfidence >= minConfidence &&
      width <= this.getMaxAllowedWidth(input.market) &&
      input.dataQualityScore >= 0.58 &&
      input.evEdge >= this.getMinEdge(input.market) &&
      upper - input.impliedProb >= 0.03;

    let band: ConfidenceBand = "LOW";
    if (input.calibratedConfidence >= 69 && width <= 0.12 && margin >= 0.07) {
      band = "HIGH";
    } else if (
      input.calibratedConfidence >= 58 &&
      width <= 0.18 &&
      margin >= 0.035
    ) {
      band = "MEDIUM";
    }

    return {
      lower: Number((lower * 100).toFixed(1)),
      upper: Number((upper * 100).toFixed(1)),
      width: Number((width * 100).toFixed(1)),
      band,
      threshold_met: thresholdMet,
    };
  }

  private getMinConfidenceForPlay(
    market: string,
    isTopLeague: boolean,
  ): number {
    const baseline: Record<string, number> = {
      MS: 62,
      OU25: 60,
      BTTS: 60,
      HT_OU15: 61,
      CARDS: 64,
      HCAP: 65,
    };

    const marketBaseline = baseline[market] ?? 62;
    return isTopLeague ? marketBaseline : marketBaseline + 2;
  }

  private getMaxAllowedWidth(market: string): number {
    const byMarket: Record<string, number> = {
      MS: 16,
      OU25: 14,
      BTTS: 14,
      HT_OU15: 15,
      CARDS: 18,
      HCAP: 18,
    };
    return byMarket[market] ?? 16;
  }

  private getMinEdge(market: string): number {
    const byMarket: Record<string, number> = {
      MS: 0.02,
      OU25: 0.018,
      BTTS: 0.018,
      HT_OU15: 0.02,
      CARDS: 0.025,
      HCAP: 0.025,
    };
    return byMarket[market] ?? 0.02;
  }

  private resolveMarketProbabilities(
    marketBoard: Record<string, unknown>,
    market: string,
  ): Record<string, unknown> {
    const entry = this.asRecord(marketBoard[market]);
    const probs = entry.probs;
    return probs && typeof probs === "object"
      ? (probs as Record<string, unknown>)
      : {};
  }

  private lookupProbability(
    probabilities: Record<string, unknown>,
    pickName: string,
  ): number {
    if (!pickName) {
      return 0;
    }

    const normalizedPick = this.normalizePickKey(pickName);
    for (const [key, value] of Object.entries(probabilities)) {
      if (this.normalizePickKey(key) === normalizedPick) {
        return this.asNumber(value);
      }
    }
    return 0;
  }

  private normalizePickKey(value: string): string {
    const normalized = value.trim().toUpperCase();
    const aliases: Record<string, string> = {
      ÜST: "OVER",
      UST: "OVER",
      OVER: "OVER",
      ALT: "UNDER",
      UNDER: "UNDER",
      "KG VAR": "YES",
      VAR: "YES",
      YES: "YES",
      "KG YOK": "NO",
      YOK: "NO",
      NO: "NO",
      TEK: "ODD",
      ODD: "ODD",
      ÇİFT: "EVEN",
      CIFT: "EVEN",
      EVEN: "EVEN",
    };

    return aliases[normalized] ?? normalized;
  }

  private impliedProbabilityFromOdds(odds: number): number {
    if (odds <= 1) {
      return 0;
    }
    return Number((1 / odds).toFixed(4));
  }

  private normalizeScore(value: unknown): number {
    const numeric = this.asNumber(value);
    return numeric > 1
      ? this.clamp(numeric / 100, 0, 1)
      : this.clamp(numeric, 0, 1);
  }

  private normalizePercent(value: number): number {
    return value > 1 ? this.clamp(value / 100, 0, 1) : this.clamp(value, 0, 1);
  }

  private asRecord(value: unknown): Record<string, any> {
    return value && typeof value === "object"
      ? (value as Record<string, any>)
      : {};
  }

  private asNumber(value: unknown): number {
    return typeof value === "number"
      ? value
      : typeof value === "string"
        ? Number(value) || 0
        : 0;
  }

  private clamp(value: number, min: number, max: number): number {
    return Math.min(Math.max(value, min), max);
  }

  async getValueBets(): Promise<ValueBetDto[]> {
    const predictions = await this.prisma.prediction.findMany({
      where: { match: { status: "NS" } },
      include: { match: { include: { homeTeam: true, awayTeam: true } } },
    });

    const valueBets: ValueBetDto[] = [];
    for (const p of predictions) {
      const out = p.predictionJson as Record<string, unknown>;
      const valueBetsList = out.value_bets as
        | Record<string, unknown>[]
        | undefined;
      if (Array.isArray(valueBetsList)) {
        valueBetsList.forEach((vb) => {
          valueBets.push({
            matchId: p.matchId,
            matchName: `${p.match.homeTeam?.name} vs ${p.match.awayTeam?.name}`,
            betType: (vb.market || vb.betType || "") as string,
            prediction: (vb.pick || vb.prediction || "") as string,
            confidence: typeof vb.confidence === "number" ? vb.confidence : 0,
            odd: typeof vb.odd === "number" ? vb.odd : 0,
            expectedValue:
              typeof vb.edge === "number"
                ? vb.edge
                : typeof vb.expectedValue === "number"
                  ? vb.expectedValue
                  : 0,
          });
        });
      }
    }

    return valueBets
      .sort((a, b) => b.expectedValue - a.expectedValue)
      .slice(0, 50);
  }

  async getSmartCoupon(
    matchIds: string[],
    strategy: string = "BALANCED",
    options: { maxMatches?: number; minConfidence?: number } = {},
  ): Promise<any> {
    await this.ensureSmartCouponDataReady(matchIds);

    // Queue mode (Redis enabled)
    if (this.predictionsQueue && this.queueEvents) {
      try {
        const job = await this.predictionsQueue.addSmartCouponJob({
          matchIds,
          strategy,
          options,
        });
        return await job.waitUntilFinished(this.queueEvents, 60000);
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        this.logger.error(`Smart coupon queue failed: ${message}`);
        this.throwAiError(message);
      }
    }

    // Direct HTTP mode — coupon needs longer timeout (many matches)
    try {
      const response = await this.aiEngineClient.post("/smart-coupon", {
        match_ids: matchIds,
        strategy,
        ...options,
      }, { timeout: 300000 }); // 5 dakika
      return response.data;
    } catch (error: unknown) {
      const message =
        error instanceof AiEngineRequestError
          ? error.message
          : error instanceof Error
            ? error.message
            : String(error);
      this.logger.error(`Direct smart coupon call failed: ${message}`);
      this.throwAiError(message);
    }
  }

  private throwAiError(message: string): never {
    if (
      message.includes("timed out") ||
      message.includes("AI_ENGINE_TIMEOUT") ||
      message.includes("AI_ENGINE_504")
    ) {
      throw new HttpException(
        "Prediction request timed out",
        HttpStatus.GATEWAY_TIMEOUT,
      );
    }
    if (message.includes("AI_ENGINE_502")) {
      throw new HttpException(
        "AI Engine upstream returned 502",
        HttpStatus.BAD_GATEWAY,
      );
    }
    if (message.includes("circuit breaker is open")) {
      throw new HttpException(
        "AI Engine is temporarily unavailable",
        HttpStatus.SERVICE_UNAVAILABLE,
      );
    }
    throw new HttpException(
      "Failed to get prediction from AI Engine",
      HttpStatus.SERVICE_UNAVAILABLE,
    );
  }

  getPredictionHistory(): Promise<PredictionHistoryResponseDto> {
    return Promise.resolve({
      stats: {
        totalPredictions: 0,
        totalResolved: 0,
        correctPredictions: 0,
        accuracyRate: 0,
      },
      history: [],
    });
  }

  async cachePrediction(matchId: string, prediction: MatchPredictionDto) {
    this.predictionMemCache.set(matchId, {
      timestamp: Date.now(),
      payload: prediction,
    });
    if (this.predictionMemCache.size > 500) {
      const firstKey = this.predictionMemCache.keys().next().value;
      if (firstKey) this.predictionMemCache.delete(firstKey);
    }

    const payload = prediction as unknown as Prisma.InputJsonObject;
    try {
      const existsInMatch = await this.prisma.match.findUnique({
        where: { id: matchId },
        select: { id: true },
      });

      if (!existsInMatch) {
        return;
      }

      await this.prisma.prediction.upsert({
        where: { matchId },
        update: {
          predictionJson: payload,
          updatedAt: new Date(),
        },
        create: {
          matchId,
          predictionJson: payload,
        },
      });
    } catch (error) {
      this.logger.warn(`Failed to cache prediction for ${matchId}`, error);
    }
  }

  async getAiCommentary(matchId: string): Promise<string | null> {
    // Use cached prediction data to generate commentary
    const cached = await this.getStoredPrediction(matchId);
    if (!cached) return null;
    try {
      return await generateExpertCommentary(
        cached as unknown as Record<string, unknown>,
      );
    } catch {
      return null;
    }
  }

  async getCachedPrediction(
    matchId: string,
  ): Promise<MatchPredictionDto | null> {
    const memCached = this.predictionMemCache.get(matchId);
    if (memCached) {
      if (Date.now() - memCached.timestamp < 5 * 60 * 1000) {
        // 5 mins TTL
        return memCached.payload;
      } else {
        this.predictionMemCache.delete(matchId);
      }
    }

    const prediction = await this.prisma.prediction.findUnique({
      where: { matchId },
    });

    if (!prediction) {
      return null;
    }

    const cacheAge = Date.now() - prediction.updatedAt.getTime();
    if (cacheAge > 1 * 60 * 60 * 1000) {
      return null;
    }

    const cached = prediction.predictionJson as Record<string, unknown>;
    const modelVersion = cached["model_version"];
    if (typeof modelVersion !== "string") {
      return null;
    }

    if (!modelVersion.startsWith("v28-pro-max")) {
      return null;
    }

    return prediction.predictionJson as unknown as MatchPredictionDto;
  }

  private async getStoredPrediction(
    matchId: string,
  ): Promise<MatchPredictionDto | null> {
    const prediction = await this.prisma.prediction.findUnique({
      where: { matchId },
    });

    return prediction
      ? (prediction.predictionJson as unknown as MatchPredictionDto)
      : null;
  }

  private hasCooldown(detail: unknown): boolean {
    if (typeof detail === "string") {
      return detail.includes("cooldownRemainingMs");
    }

    if (detail && typeof detail === "object") {
      return "cooldownRemainingMs" in detail;
    }

    return false;
  }

  private extractCooldownMs(detail: unknown): number {
    if (
      detail &&
      typeof detail === "object" &&
      "cooldownRemainingMs" in detail
    ) {
      return (
        Number((detail as Record<string, unknown>).cooldownRemainingMs) || 0
      );
    }

    if (typeof detail === "string") {
      const match = detail.match(/cooldownRemainingMs[":\s]+(\d+)/);
      return match ? Number(match[1]) : 0;
    }

    return 0;
  }

  private async ensureSmartCouponDataReady(matchIds: string[]): Promise<void> {
    const uniqueMatchIds = [...new Set(matchIds.filter((id) => !!id))];
    if (uniqueMatchIds.length === 0) {
      throw new HttpException(
        "No matchIds provided for smart coupon generation",
        HttpStatus.BAD_REQUEST,
      );
    }

    await Promise.all(
      uniqueMatchIds.map((matchId) => this.ensurePredictionDataReady(matchId)),
    );
  }

  private async ensurePredictionDataReady(matchId: string): Promise<void> {
    const [liveMatch, persistedMatch, oddCategoryCount, lineupCount] =
      await Promise.all([
        this.prisma.liveMatch.findUnique({
          where: { id: matchId },
          select: {
            id: true,
            odds: true,
            state: true,
            status: true,
            scoreHome: true,
            scoreAway: true,
            leagueId: true,
          },
        }),
        this.prisma.match.findUnique({
          where: { id: matchId },
          select: {
            id: true,
            state: true,
            status: true,
            scoreHome: true,
            scoreAway: true,
            leagueId: true,
          },
        }),
        this.prisma.oddCategory.count({
          where: { matchId },
        }),
        this.prisma.matchPlayerParticipation.count({
          where: { matchId },
        }),
      ]);

    const hasLiveOdds =
      !!liveMatch?.odds &&
      typeof liveMatch.odds === "object" &&
      !Array.isArray(liveMatch.odds) &&
      Object.keys(liveMatch.odds as Record<string, unknown>).length > 0;
    const matchExists = !!liveMatch?.id || !!persistedMatch?.id;

    if (!matchExists) {
      throw new HttpException(
        `Match not found: ${matchId}`,
        HttpStatus.NOT_FOUND,
      );
    }

    // League qualification gate: reject predictions for leagues without
    // sufficient historical training data (odds + lineups + stats)
    const leagueId = liveMatch?.leagueId || persistedMatch?.leagueId;
    if (
      this.qualifiedLeagueIds.size > 0 &&
      (!leagueId || !this.qualifiedLeagueIds.has(leagueId))
    ) {
      throw new HttpException(
        `Bu lig için yeterli geçmiş veri bulunmuyor. Tahmin yapılamaz.`,
        HttpStatus.UNPROCESSABLE_ENTITY,
      );
    }

    const state = liveMatch?.state || persistedMatch?.state;
    const status = liveMatch?.status || persistedMatch?.status;
    const scoreHome = liveMatch?.scoreHome ?? persistedMatch?.scoreHome;
    const scoreAway = liveMatch?.scoreAway ?? persistedMatch?.scoreAway;

    const isFinished = isMatchCompleted({
      state: state ?? null,
      status: status ?? null,
      scoreHome,
      scoreAway,
    });

    const isLive = isMatchLive({
      state: state ?? null,
      status: status ?? null,
    });

    const hasOdds = hasLiveOdds || oddCategoryCount > 0;

    if (hasOdds || isFinished || isLive) {
      // ── Lineup guard: fetch lineups if missing before analysis ──
      // A proper football lineup has at least 11 starting players (22 total
      // with subs). If we have fewer than 11 participation records, the
      // lineup data is likely missing — attempt to fetch it from source.
      if (lineupCount < 11) {
        this.logger.log(
          `[${matchId}] ⚠️ Lineups missing (${lineupCount} players in DB). Fetching from source before analysis...`,
        );
        try {
          const refreshResult = await this.feederService.refreshMatch(
            matchId,
            "lineups",
          );
          if (refreshResult.success) {
            this.logger.log(
              `[${matchId}] ✅ Lineups fetched successfully before analysis`,
            );
          } else {
            this.logger.warn(
              `[${matchId}] ⚠️ Lineup fetch returned failure — proceeding with existing data. Error: ${refreshResult.error ?? "unknown"}`,
            );
          }
        } catch (err: unknown) {
          const message = err instanceof Error ? err.message : String(err);
          this.logger.warn(
            `[${matchId}] ⚠️ Lineup fetch exception — proceeding with existing data. ${message}`,
          );
        }
      }
      return;
    }

    throw new HttpException(
      `Prediction prerequisites are missing for match ${matchId}: odds`,
      HttpStatus.UNPROCESSABLE_ENTITY,
    );
  }

  private async recordPredictionRun(
    matchId: string,
    payload: MatchPredictionDto,
  ): Promise<void> {
    try {
      // Finished-match re-analyses (manual validation runs) must not pollute
      // the forward track record: they would bias settlement ROI, the
      // per-league karne and engine-version comparisons. Tag them into their
      // own engine_version bucket so every GROUP BY engine_version isolates
      // them automatically — the data is kept, the live karne stays clean.
      const auditMatch = await this.prisma.match.findUnique({
        where: { id: matchId },
        select: {
          state: true,
          status: true,
          scoreHome: true,
          scoreAway: true,
          mstUtc: true,
        },
      });
      const kickoffMs =
        auditMatch?.mstUtc != null ? Number(auditMatch.mstUtc) : null;
      const kickoffLongPast =
        kickoffMs !== null && Date.now() - kickoffMs > 3 * 60 * 60 * 1000;
      const isCompletedRun =
        isMatchCompleted({
          state: auditMatch?.state ?? null,
          status: auditMatch?.status ?? null,
          scoreHome: auditMatch?.scoreHome,
          scoreAway: auditMatch?.scoreAway,
        }) || kickoffLongPast;
      const baseVersion = String(payload.model_version || "unknown");
      const engineVersion = isCompletedRun
        ? `${baseVersion}.sim-finished`
        : baseVersion;

      const oddsSnapshot = await this.getPredictionOddsSnapshot(matchId);
      const payloadSummary = this.buildPredictionPayloadSummary(payload);
      await this.prisma.$executeRawUnsafe(
        `
          INSERT INTO prediction_runs (
            match_id,
            engine_version,
            decision_trace_id,
            odds_snapshot,
            payload_summary
          )
          VALUES ($1, $2, $3, $4::jsonb, $5::jsonb)
        `,
        matchId,
        engineVersion,
        typeof payload.decision_trace_id === "string"
          ? payload.decision_trace_id
          : null,
        JSON.stringify(oddsSnapshot),
        JSON.stringify(payloadSummary),
      );
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      this.logger.warn(
        `Prediction run audit skipped for ${matchId}: ${message}`,
      );
    }
  }

  private async getPredictionOddsSnapshot(
    matchId: string,
  ): Promise<Record<string, unknown>> {
    const liveMatch = await this.prisma.liveMatch.findUnique({
      where: { id: matchId },
      select: {
        odds: true,
        oddsUpdatedAt: true,
        state: true,
        status: true,
        scoreHome: true,
        scoreAway: true,
      },
    });
    if (liveMatch) {
      return {
        source: "live_match",
        odds: liveMatch.odds ?? {},
        odds_updated_at: liveMatch.oddsUpdatedAt?.toISOString() ?? null,
        state: liveMatch.state ?? null,
        status: liveMatch.status ?? null,
        score_home: liveMatch.scoreHome ?? null,
        score_away: liveMatch.scoreAway ?? null,
      };
    }

    const oddCategoryCount = await this.prisma.oddCategory.count({
      where: { matchId },
    });
    return {
      source: "historical_match",
      odd_category_count: oddCategoryCount,
    };
  }

  private buildPredictionPayloadSummary(
    payload: MatchPredictionDto,
  ): Record<string, unknown> {
    const topSummary = Array.isArray(payload.bet_summary)
      ? payload.bet_summary.slice(0, 5).map((item) => ({
          market: item.market,
          pick: item.pick,
          playable: item.playable,
          bet_grade: item.bet_grade,
          odds: item.odds,
          model_edge: item.model_edge,
          calibrated_probability: item.calibrated_probability,
          calibrated_confidence: item.calibrated_confidence,
          ev_edge: item.ev_edge ?? 0,
          odds_band_probability: item.odds_band_probability,
          odds_band_sample: item.odds_band_sample,
          odds_band_edge: item.odds_band_edge,
          odds_band_aligned: item.odds_band_aligned,
          stake_units: item.stake_units,
        }))
      : [];

    // ── Forward-test capture (V31e) ────────────────────────────────────
    // Persist EVERY market's probability + the rationale behind each pick so
    // the settlement job can later score each market against reality and the
    // admin "Model Performance" page can show per-market calibration
    // (model% → actual%) and decision reasons. Compact projection only.
    // Some runtime fields (betting_brain, is_underdog_reference) are present
    // in the AI payload but not declared on the DTO — read them via a cast.
    const marketsFull = Array.isArray(payload.bet_summary)
      ? payload.bet_summary.map((item) => {
          const loose = item as unknown as Record<string, unknown>;
          const bb = (loose.betting_brain ?? {}) as Record<string, unknown>;
          return {
            market: item.market,
            pick: item.pick,
            odds: item.odds ?? null,
            model_probability: item.model_probability ?? null,
            calibrated_confidence: item.calibrated_confidence ?? null,
            raw_confidence: item.raw_confidence ?? null,
            calibrated_probability: item.calibrated_probability ?? null,
            implied_prob: item.implied_prob ?? null,
            ev_edge: item.ev_edge ?? 0,
            playable: item.playable ?? false,
            bet_grade: item.bet_grade ?? "PASS",
            signal_tier: item.signal_tier ?? null,
            stake_units: item.stake_units ?? 0,
            is_underdog_reference: Boolean(loose.is_underdog_reference),
            action: (bb.action as string | undefined) ?? null,
            value_tier: (bb.value_tier as string | undefined) ?? null,
            model_market_gap: (bb.model_market_gap as number | undefined) ?? null,
            trap_market_flag: Boolean(bb.trap_market_flag),
            vetoes: Array.isArray(bb.vetoes)
              ? (bb.vetoes as unknown[]).slice(0, 6)
              : [],
            positives: Array.isArray(bb.positives)
              ? (bb.positives as unknown[]).slice(0, 6)
              : [],
            reasons: Array.isArray(item.reasons) ? item.reasons.slice(0, 6) : [],
          };
        })
      : [];

    // Per-outcome probability distribution for each market (graph bars).
    const marketBoardProbs =
      payload.market_board && typeof payload.market_board === "object"
        ? Object.fromEntries(
            Object.entries(
              payload.market_board as Record<string, { probs?: unknown }>,
            ).map(([mkt, entry]) => [
              mkt,
              entry && typeof entry === "object" ? (entry.probs ?? null) : null,
            ]),
          )
        : {};

    return {
      markets_full: marketsFull,
      market_board_probs: marketBoardProbs,
      betting_brain_version:
        (
          (payload as unknown as Record<string, unknown>).betting_brain as
            | { version?: string }
            | undefined
        )?.version ?? null,
      model_version: payload.model_version,
      calibration_version: payload.calibration_version ?? null,
      shadow_engine_version: payload.shadow_engine_version ?? null,
      decision_trace_id: payload.decision_trace_id ?? null,
      main_pick: payload.main_pick
        ? {
            market: payload.main_pick.market,
            pick: payload.main_pick.pick,
            playable: payload.main_pick.playable,
            bet_grade: payload.main_pick.bet_grade,
            odds: payload.main_pick.odds,
            model_edge: payload.main_pick.model_edge,
            calibrated_probability: payload.main_pick.calibrated_probability,
            calibrated_confidence: payload.main_pick.calibrated_confidence,
            ev_edge: payload.main_pick.ev_edge ?? 0,
            odds_band_probability: payload.main_pick.odds_band_probability,
            odds_band_sample: payload.main_pick.odds_band_sample,
            odds_band_edge: payload.main_pick.odds_band_edge,
            odds_band_aligned: payload.main_pick.odds_band_aligned,
            stake_units: payload.main_pick.stake_units,
          }
        : null,
      value_pick: payload.value_pick
        ? {
            market: payload.value_pick.market,
            pick: payload.value_pick.pick,
            playable: payload.value_pick.playable,
            bet_grade: payload.value_pick.bet_grade,
            odds: payload.value_pick.odds,
            model_edge: payload.value_pick.model_edge,
            calibrated_confidence: payload.value_pick.calibrated_confidence,
            ev_edge: payload.value_pick.ev_edge ?? 0,
          }
        : null,
      bet_advice: {
        playable: payload.bet_advice?.playable ?? false,
        suggested_stake_units: payload.bet_advice?.suggested_stake_units ?? 0,
        reason: payload.bet_advice?.reason ?? null,
      },
      top_summary: topSummary,
      market_reliability: payload.market_reliability ?? {},
      shadow_engine: payload.shadow_engine ?? null,
    };
  }
}
