import type { MemoryItem, TasteSignal } from "@app/shared-types";
import type {
  CookingAssumption,
  ProvenanceEntry,
  RecommendationCandidate,
  RecommendationContext,
  ScoredRecommendation,
  ScoringWeights,
} from "./types.js";
import { DEFAULT_WEIGHTS } from "./types.js";
import { buildWhySv } from "./explain.js";

/**
 * Poängsätt en kandidat (0–100-skala per delkomponent, viktad summa).
 * Helt deterministisk: samma indata → samma poäng → förklarbar rekommendation.
 */
export function scoreCandidate(
  candidate: RecommendationCandidate,
  ctx: RecommendationContext,
  weights: ScoringWeights = DEFAULT_WEIGHTS,
): ScoredRecommendation {
  const parts: Record<string, number> = {};
  const provenance: ProvenanceEntry[] = [];

  // 1. Ingredienstäckning – kärnan i "utgå från vad som finns hemma".
  parts.coverage = candidate.coverage.coverage;

  // 2. Utgångsdatum: störst poäng när receptet räddar varor som snart går ut.
  const expiring = candidate.coverage.expiringUsed;
  if (expiring.length === 0) {
    parts.expiry = 0;
  } else {
    const mostUrgent = Math.min(
      ...expiring.map((m) => (m.mostUrgentDaysLeft == null ? 99 : m.mostUrgentDaysLeft)),
    );
    parts.expiry = mostUrgent <= 1 ? 1 : mostUrgent <= 3 ? 0.8 : 0.5;
  }

  // 3. Näringsfit mot återstående dagsmål.
  parts.nutritionFit = nutritionFit(candidate, ctx);

  // 4. Smak: favoritkök + styrka nära preferens.
  parts.taste = ctx.favoriteCuisines.includes(candidate.cuisine) ? 1 : 0.4;

  // 5. Betyg: hushållets egna betyg väger tyngst, annars community (kräver volym).
  if (candidate.householdRating != null) {
    parts.rating = clamp01((candidate.householdRating - 2.5) / 2.5);
  } else if (candidate.ratingAverage != null && candidate.ratingCount >= 5) {
    parts.rating = clamp01((candidate.ratingAverage - 3) / 2);
  } else {
    parts.rating = 0.5;
  }

  // 6–7. Säsong & högtid.
  parts.season = candidate.peakSeasons.includes(ctx.currentSeason) ? 1 : 0.3;
  parts.holiday =
    ctx.activeHolidayTags.length > 0 &&
    candidate.holidayTags.some((t) => ctx.activeHolidayTags.includes(t))
      ? 1
      : 0;

  // 8. Tid: hård maxgräns om satt, annars vardagsbonus för snabbt.
  if (ctx.maxMinutes != null && candidate.totalTimeMinutes > ctx.maxMinutes) {
    parts.time = -1; // diskvalificerande straff hanteras i rankAll
  } else if (ctx.isWeekday) {
    parts.time =
      candidate.totalTimeMinutes <= 25 ? 1 : candidate.totalTimeMinutes <= 45 ? 0.6 : 0.2;
  } else {
    parts.time = candidate.totalTimeMinutes <= 90 ? 0.7 : 0.5;
  }

  // 9. Budget.
  const cost = candidate.estimatedCostMinorPerPortion;
  if (ctx.maxCostMinorPerPortion != null && cost != null) {
    parts.budget = cost <= ctx.maxCostMinorPerPortion ? 1 : -0.5;
  } else if (cost != null) {
    parts.budget = cost <= 25 ? 1 : cost <= 45 ? 0.6 : 0.3;
  } else {
    parts.budget = 0.4;
  }

  // 10. Variation: nyligen lagat straffas (spec §25: variation).
  const days = candidate.daysSinceLastCooked;
  parts.variety = days == null ? 0.8 : days < 7 ? 0 : days < 14 ? 0.4 : 1;

  // 11. Väder (spec §29): varmt → grill/sallad, kallt → gryta/soppa.
  parts.weather = weatherFit(candidate, ctx);

  // 12. "Jag är sugen på" (spec §19).
  parts.craving = cravingFit(candidate, ctx);

  // 13–15. S1 personalisering — endast om samtycke granted.
  if (ctx.personalizationEnabled) {
    const memoryResult = memoryFit(candidate, ctx);
    parts.memoryFit = memoryResult.score;
    provenance.push(...memoryResult.provenance);

    const tasteResult = tasteFit(candidate, ctx);
    parts.tasteFit = tasteResult.score;
    provenance.push(...tasteResult.provenance);

    const assumptionResult = cookingAssumptionFit(candidate, ctx);
    parts.cookingAssumptionFit = assumptionResult.score;
    provenance.push(...assumptionResult.provenance);
  } else {
    parts.memoryFit = 0;
    parts.tasteFit = 0;
    parts.cookingAssumptionFit = 0;
  }

  const score = weightedSum(parts, weights);

  return {
    recipeId: candidate.recipeId,
    titleSv: candidate.titleSv,
    score: Math.round(score * 10) / 10,
    parts,
    whySv: buildWhySv(candidate, ctx, parts, provenance),
    missingIngredients: candidate.coverage.missing
      .filter((m) => !m.optional)
      .map((m) => m.displayNameSv),
    usesExpiring: expiring.map((m) => ({
      nameSv: m.displayNameSv,
      daysLeft: m.mostUrgentDaysLeft,
    })),
    coveragePercent: Math.round(candidate.coverage.coverage * 100),
    provenance,
  };
}

/** Ranka alla kandidater; kandidater över tidsgränsen filtreras bort. */
export function rankAll(
  candidates: RecommendationCandidate[],
  ctx: RecommendationContext,
  weights: ScoringWeights = DEFAULT_WEIGHTS,
  limit = 5,
): ScoredRecommendation[] {
  return candidates
    .map((c) => scoreCandidate(c, ctx, weights))
    .filter((s) => (s.parts.time ?? 0) >= 0)
    .sort((a, b) => b.score - a.score)
    .slice(0, limit);
}

function nutritionFit(candidate: RecommendationCandidate, ctx: RecommendationContext): number {
  let fit = 0.5;
  const protein = candidate.nutritionPerPortion.proteinG;
  if (ctx.remainingProteinG != null && ctx.remainingProteinG > 0) {
    // Ju närmare receptet fyller proteinluckan, desto bättre (upp till 1).
    fit = clamp01(protein / Math.max(20, ctx.remainingProteinG * 0.6));
  }
  if (ctx.isTrainingDay && protein >= 35) fit = Math.min(1, fit + 0.3);
  if (ctx.remainingKcal != null && ctx.remainingKcal > 0) {
    const kcal = candidate.nutritionPerPortion.kcal;
    if (kcal > ctx.remainingKcal * 1.3) fit *= 0.5;
  }
  return clamp01(fit);
}

function weatherFit(candidate: RecommendationCandidate, ctx: RecommendationContext): number {
  if (!ctx.weather || ctx.weather === "unknown") return 0.5;
  const title = candidate.titleSv.toLowerCase();
  const has = (words: string[]) =>
    words.some((w) => title.includes(w)) ||
    candidate.tags.some((t) => words.includes(t)) ||
    words.includes(candidate.cuisine);
  switch (ctx.weather) {
    case "hot":
    case "warm":
      return has(["grill", "sallad", "kall", "bowl", "wrap"]) ? 1 : 0.4;
    case "cold":
    case "snow":
      return has(["soppa", "gryta", "långkok", "ugns", "pytt"]) ? 1 : 0.4;
    case "rain":
      return has(["gryta", "långkok", "soppa", "paj"]) ? 0.9 : 0.5;
    default:
      return 0.5;
  }
}

function cravingFit(candidate: RecommendationCandidate, ctx: RecommendationContext): number {
  const tags = ctx.cravingTags ?? [];
  if (tags.length === 0 && !ctx.cravingCuisine && ctx.cravingMaxKcal == null) return 0.5;
  let hits = 0;
  let checks = 0;

  if (ctx.cravingCuisine) {
    checks += 1;
    if (candidate.cuisine === ctx.cravingCuisine) hits += 1;
  }
  if (ctx.cravingMaxKcal != null) {
    checks += 1;
    if (candidate.nutritionPerPortion.kcal <= ctx.cravingMaxKcal) hits += 1;
  }
  if (tags.length > 0) {
    checks += 1;
    const title = candidate.titleSv.toLowerCase();
    const candidateTags = new Set<string>(candidate.tags);
    const matched = tags.some(
      (t) =>
        candidateTags.has(t) ||
        title.includes(t) ||
        (t === "spicy" && candidate.spiceLevel >= 3) ||
        (t === "quick" && candidate.totalTimeMinutes <= 25) ||
        (t === "high_protein" && candidate.nutritionPerPortion.proteinG >= 35),
    );
    if (matched) hits += 1;
  }

  return checks === 0 ? 0.5 : hits / checks;
}

interface FitResult {
  score: number;
  provenance: ProvenanceEntry[];
}

function memoryFit(candidate: RecommendationCandidate, ctx: RecommendationContext): FitResult {
  const memories = ctx.memoryItems ?? [];
  if (memories.length === 0 || !ctx.personalizationEnabled) {
    return { score: 0, provenance: [] };
  }

  let score = 0;
  const provenance: ProvenanceEntry[] = [];

  for (const memory of memories) {
    if (memory.paused) continue;
    const value = (memory.value ?? {}) as Record<string, unknown>;

    // Favoritkök
    if (memory.kind === "structured_fact" && value.favoriteCuisine === candidate.cuisine) {
      const weight = memory.verifiedByUser || memory.origin === "user_stated" ? 1 : memory.origin === "observed" ? 0.7 : 0.4;
      score = Math.max(score, weight);
      if (weight >= 0.7) {
        provenance.push({
          key: "favoriteCuisine",
          args: { cuisine: String(value.favoriteCuisine) },
        });
      }
    }

    // Gillade rätter / receptminne
    if (memory.kind === "recipe_memory" && value.recipeId === candidate.recipeId) {
      const weight = memory.verifiedByUser || memory.origin === "user_stated" ? 1 : 0.6;
      score = Math.max(score, weight);
    }

    // Gillade ingredienser
    if (
      memory.kind === "structured_fact" &&
      typeof value.likedIngredientId === "string" &&
      candidate.ingredientIds?.includes(value.likedIngredientId)
    ) {
      const weight = memory.verifiedByUser || memory.origin === "user_stated" ? 0.9 : 0.5;
      score = Math.max(score, weight);
    }
  }

  // Matlagningsfrekvens (observed events, ej AI-gissning)
  if (candidate.daysSinceLastCooked != null && candidate.daysSinceLastCooked <= 30) {
    // Ingen boost för nyligen lagat (variety straffar redan), men vi noterar mönster.
  }

  return { score: clamp01(score), provenance };
}

function tasteFit(candidate: RecommendationCandidate, ctx: RecommendationContext): FitResult {
  const signals = ctx.tasteSignals ?? [];
  if (signals.length === 0 || !ctx.personalizationEnabled) {
    return { score: 0, provenance: [] };
  }

  let total = 0;
  let count = 0;
  const provenance: ProvenanceEntry[] = [];

  // Mappa recept till axlar via tags/cuisine/ingredienser (förenklad heuristik).
  const recipeAxes = detectRecipeAxes(candidate);

  for (const signal of signals) {
    if (!recipeAxes.includes(signal.axis)) continue;
    const contribution = signal.direction * signal.strength;
    total += contribution;
    count += 1;
    if (Math.abs(contribution) >= 0.5) {
      provenance.push({
        key: "tastePreference",
        args: { axis: signal.axis },
      });
    }
  }

  if (count === 0) return { score: 0, provenance: [] };
  const raw = total / count; // -1 … +1
  const score = clamp01((raw + 1) / 2); // 0 … 1
  return { score, provenance };
}

function detectRecipeAxes(candidate: RecommendationCandidate): string[] {
  const axes: string[] = [];
  const title = candidate.titleSv.toLowerCase();
  const tags = new Set(candidate.tags.map((t) => t.toLowerCase()));

  if (candidate.spiceLevel >= 3 || tags.has("spicy")) axes.push("spice");
  if (tags.has("sött") || tags.has("dessert") || title.includes("socker")) axes.push("sweetness");
  if (tags.has("syrligt") || title.includes("citron") || title.includes("lime")) axes.push("acid");
  if (
    title.includes("krämig") ||
    title.includes("grädd") ||
    tags.has("creamy") ||
    tags.has("krämig")
  )
    axes.push("creaminess");
  if (title.includes("vitlök") || tags.has("garlic")) axes.push("garlic");
  if (tags.has("herby") || title.includes("dill") || title.includes("basilika")) axes.push("herbs");
  if (tags.has("umami") || title.includes("soja") || title.includes("svamp")) axes.push("umami");

  return axes;
}

function cookingAssumptionFit(
  candidate: RecommendationCandidate,
  ctx: RecommendationContext,
): FitResult {
  const assumptions = ctx.cookingAssumptions ?? [];
  if (assumptions.length === 0 || !ctx.personalizationEnabled) {
    return { score: 0, provenance: [] };
  }

  const ids = candidate.ingredientIds ?? [];
  let total = 0;
  let matched = 0;
  let bestIngredient: string | null = null;
  let bestScore = -1;

  for (const assumption of assumptions) {
    if (!ids.includes(assumption.canonicalIngredientId)) continue;
    const eaten = assumption.averageEatenPortions ?? 0;
    const leftovers = assumption.averageLeftoverPortions ?? 0;
    const observed = assumption.observationCount;
    if (observed < 2) continue;

    const finishRate = eaten > 0 ? eaten / (eaten + leftovers) : 0;
    const score = clamp01(finishRate * Math.min(1, observed / 5));
    total += score;
    matched += 1;

    if (score > bestScore) {
      bestScore = score;
      bestIngredient = assumption.canonicalIngredientId;
    }
  }

  if (matched === 0) return { score: 0, provenance: [] };

  const provenance: ProvenanceEntry[] = [];
  if (bestIngredient && bestScore >= 0.7) {
    provenance.push({ key: "usesStapleYouFinish", args: { ingredient: bestIngredient } });
  }

  return { score: clamp01(total / matched), provenance };
}

function weightedSum(parts: Record<string, number>, weights: ScoringWeights): number {
  let total = 0;
  for (const [key, value] of Object.entries(parts)) {
    const weight = (weights as unknown as Record<string, number>)[key] ?? 0;
    total += clampPart(value) * weight;
  }
  return total;
}

function clamp01(v: number): number {
  return Math.max(0, Math.min(1, v));
}

function clampPart(v: number): number {
  return Math.max(-1, Math.min(1, v));
}
