import { describe, expect, it } from "vitest";
import type { CoverageResult } from "@app/recipe-engine";
import type { MemoryItem, TasteSignal } from "@app/shared-types";
import {
  containsForbiddenCopy,
  DEFAULT_WEIGHTS,
  depersonalize,
  easterSunday,
  HEALTH_VIEW_WEIGHTS,
  isEventActive,
  midsummerEve,
  NON_PERSONALIZED_WEIGHTS,
  parseCraving,
  PANTRY_VIEW_WEIGHTS,
  rankAll,
  renderProvenance,
  scoreCandidate,
  seasonForDate,
  TASTE_VIEW_WEIGHTS,
  viewWeights,
  type CookingAssumption,
  type RecommendationCandidate,
  type RecommendationContext,
} from "../src/index.js";

const fullCoverage: CoverageResult = { coverage: 1, matches: [], missing: [], expiringUsed: [] };
const nutrition = {
  kcal: 550,
  proteinG: 45,
  carbsG: 50,
  fatG: 18,
  saturatedFatG: 6,
  fiberG: 6,
  sugarG: 4,
  saltG: 1.5,
};

function candidate(overrides: Partial<RecommendationCandidate> = {}): RecommendationCandidate {
  return {
    recipeId: "r1",
    titleSv: "Kycklinggryta",
    cuisine: "swedish",
    tags: [],
    totalTimeMinutes: 30,
    nutritionPerPortion: nutrition,
    estimatedCostMinorPerPortion: 2200,
    ratingAverage: null,
    ratingCount: 0,
    peakSeasons: [],
    holidayTags: [],
    spiceLevel: 1,
    coverage: fullCoverage,
    ...overrides,
  };
}

const ctx: RecommendationContext = {
  mealType: "dinner",
  persons: 4,
  currentSeason: "summer",
  activeHolidayTags: [],
  isWeekday: true,
  favoriteCuisines: ["swedish"],
  remainingProteinG: 60,
  remainingKcal: 800,
  personalizationEnabled: false,
};

describe("poängsättning (spec §18)", () => {
  it("utgångsvaror lyfter rekommendationen", () => {
    const expiring = candidate({
      recipeId: "expiring",
      coverage: {
        coverage: 1,
        matches: [],
        missing: [],
        expiringUsed: [
          {
            canonicalIngredientId: "chicken",
            displayNameSv: "kycklingen",
            required: 500,
            unit: "GRAM",
            availableInUnit: 600,
            covered: true,
            optional: false,
            mostUrgentDaysLeft: 1,
            usesExpiringItem: true,
          },
        ],
      },
    });
    const fresh = candidate({ recipeId: "fresh" });
    const ranked = rankAll([fresh, expiring], ctx);
    expect(ranked[0]?.recipeId).toBe("expiring");
    expect(ranked[0]?.whySv).toContain("senast i morgon");
  });

  it("förklaringen innehåller täckning och protein (specens exempel)", () => {
    const scored = scoreCandidate(
      candidate({
        coverage: { ...fullCoverage, coverage: 0.92 },
        nutritionPerPortion: { ...nutrition, proteinG: 58 },
      }),
      ctx,
    );
    expect(scored.whySv).toContain("92 %");
    expect(scored.whySv).toContain("58 gram protein");
  });

  it("recept över tidsgränsen filtreras bort", () => {
    const slow = candidate({ recipeId: "slow", totalTimeMinutes: 90 });
    const ranked = rankAll([slow], { ...ctx, maxMinutes: 45 });
    expect(ranked).toHaveLength(0);
  });

  it("utan tidsgräns behålls långsamma recept (t.ex. vid uttrycklig högtid/bak)", () => {
    // Fixen: när användaren uttryckligen valt en högtid/tagg/sök sätter
    // endpointen maxMinutes=undefined, så kräftskiva och bak inte gömms av
    // vardagens tidsgräns. Långsamma recept ska då rankas – inte försvinna.
    const slow = candidate({ recipeId: "slow", totalTimeMinutes: 90 });
    const ranked = rankAll([slow], { ...ctx, maxMinutes: undefined });
    expect(ranked).toHaveLength(1);
    expect(ranked[0]!.recipeId).toBe("slow");
  });

  it("nyligen lagat straffas (variation)", () => {
    const recent = scoreCandidate(candidate({ daysSinceLastCooked: 2 }), ctx);
    const old = scoreCandidate(candidate({ daysSinceLastCooked: 30 }), ctx);
    expect(old.score).toBeGreaterThan(recent.score);
  });
});

describe("'jag är sugen på' (spec §19)", () => {
  it("tolkar kök, taggar och kcal-gräns", () => {
    const parsed = parseCraving("något krämigt och asiatiskt under 500 kcal");
    expect(parsed.cuisine).toBe("thai");
    expect(parsed.tags).toContain("creamy");
    expect(parsed.maxKcal).toBe(500);
  });
  it("tolkar svenska uttryck", () => {
    expect(parseCraving("snabb husmanskost").tags).toEqual(
      expect.arrayContaining(["quick", "comfort"]),
    );
    expect(parseCraving("barnvänliga tacos").cuisine).toBe("mexican");
  });
});

describe("säsongs- och eventmotor (spec §28)", () => {
  // Motorn räknar i UTC – testerna använder UTC-datum så de är sanna i alla tidszoner.
  it("årstider", () => {
    expect(seasonForDate(new Date("2026-07-15T00:00:00Z"))).toBe("summer");
    expect(seasonForDate(new Date("2026-01-15T00:00:00Z"))).toBe("winter");
  });
  it("midsommarafton är alltid en fredag 19–25 juni", () => {
    for (const year of [2025, 2026, 2027, 2028]) {
      const eve = midsummerEve(year);
      expect(eve.getUTCDay()).toBe(5);
      expect(eve.getUTCMonth()).toBe(5);
      expect(eve.getUTCDate()).toBeGreaterThanOrEqual(19);
      expect(eve.getUTCDate()).toBeLessThanOrEqual(25);
    }
  });
  it("påskdagen: kända referensår", () => {
    expect(easterSunday(2026).toISOString().slice(0, 10)).toBe("2026-04-05");
    expect(easterSunday(2027).toISOString().slice(0, 10)).toBe("2027-03-28");
  });
  it("event aktiveras inom leadDays", () => {
    const midsummer = {
      dateRule: { kind: "computed", algorithm: "midsummer" } as const,
      leadDays: 10,
    };
    expect(isEventActive(midsummer, new Date("2026-06-15T00:00:00Z"))).toBe(true);
    expect(isEventActive(midsummer, new Date("2026-03-01T00:00:00Z"))).toBe(false);
    const jul = {
      dateRule: { kind: "range", startMonthDay: "12-20", endMonthDay: "12-26" } as const,
      leadDays: 21,
    };
    expect(isEventActive(jul, new Date("2026-12-05T00:00:00Z"))).toBe(true);
    expect(isEventActive(jul, new Date("2026-08-02T00:00:00Z"))).toBe(false);
  });
});

describe("S1 personalisering", () => {
  const memoryBase: MemoryItem = {
    id: "m1",
    userId: "u1",
    kind: "structured_fact",
    key: "favorite_cuisine_swedish",
    summarySv: "Gillar svensk mat",
    value: { favoriteCuisine: "swedish" },
    origin: "user_stated",
    confidence: 1,
    verifiedByUser: true,
    paused: false,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  };

  const aiMemory: MemoryItem = {
    ...memoryBase,
    id: "m2",
    origin: "ai_inferred",
    confidence: 0.5,
    verifiedByUser: false,
    value: { favoriteCuisine: "swedish" },
  };

  const tastePositive: TasteSignal = {
    id: "t1",
    userId: "u1",
    axis: "spice",
    direction: 1,
    strength: 0.8,
    origin: "user_stated",
    createdAt: new Date().toISOString(),
  };

  const tasteNegative: TasteSignal = {
    ...tastePositive,
    id: "t2",
    direction: -1,
  };

  const cookingAssumption: CookingAssumption = {
    canonicalIngredientId: "chicken",
    averageEatenPortions: 4,
    averageLeftoverPortions: 0.5,
    observationCount: 5,
  };

  it("memoryFit boostar verified user_stated högre än ai_inferred", () => {
    const verifiedCtx: RecommendationContext = {
      ...ctx,
      personalizationEnabled: true,
      memoryItems: [memoryBase],
      tasteSignals: [],
      cookingAssumptions: [],
    };
    const aiCtx: RecommendationContext = {
      ...ctx,
      personalizationEnabled: true,
      memoryItems: [aiMemory],
      tasteSignals: [],
      cookingAssumptions: [],
    };
    const verified = scoreCandidate(candidate({ cuisine: "swedish" }), verifiedCtx);
    const ai = scoreCandidate(candidate({ cuisine: "swedish" }), aiCtx);
    expect(verified.parts.memoryFit ?? 0).toBeGreaterThan(ai.parts.memoryFit ?? 0);
  });

  it("tasteFit ger positiv riktning för positiv signal", () => {
    const spicyCandidate = candidate({ spiceLevel: 3 });
    const positiveCtx: RecommendationContext = {
      ...ctx,
      personalizationEnabled: true,
      memoryItems: [],
      tasteSignals: [tastePositive],
      cookingAssumptions: [],
    };
    const negativeCtx: RecommendationContext = {
      ...ctx,
      personalizationEnabled: true,
      memoryItems: [],
      tasteSignals: [tasteNegative],
      cookingAssumptions: [],
    };
    const positive = scoreCandidate(spicyCandidate, positiveCtx);
    const negative = scoreCandidate(spicyCandidate, negativeCtx);
    expect(positive.parts.tasteFit ?? 0).toBeGreaterThan(negative.parts.tasteFit ?? 0);
  });

  it("cookingAssumptionFit boostar ingrediens som hushållet brukar äta upp", () => {
    const ctxWithAssumption: RecommendationContext = {
      ...ctx,
      personalizationEnabled: true,
      memoryItems: [],
      tasteSignals: [],
      cookingAssumptions: [cookingAssumption],
    };
    const boosted = scoreCandidate(
      candidate({ ingredientIds: ["chicken", "onion"] }),
      ctxWithAssumption,
    );
    const noMatch = scoreCandidate(
      candidate({ ingredientIds: ["beef", "onion"] }),
      ctxWithAssumption,
    );
    expect(boosted.parts.cookingAssumptionFit ?? 0).toBeGreaterThan(
      noMatch.parts.cookingAssumptionFit ?? 0,
    );
  });

  it("utan personalizationEnabled är personliga delpoäng noll", () => {
    const noPersonalizationCtx: RecommendationContext = {
      ...ctx,
      personalizationEnabled: false,
      memoryItems: [memoryBase],
      tasteSignals: [tastePositive],
      cookingAssumptions: [cookingAssumption],
    };
    const scored = scoreCandidate(
      candidate({ cuisine: "swedish", spiceLevel: 3 }),
      noPersonalizationCtx,
    );
    expect(scored.parts.memoryFit).toBe(0);
    expect(scored.parts.tasteFit).toBe(0);
    expect(scored.parts.cookingAssumptionFit).toBe(0);
  });

  it("proveniens renderas från mall med grundade fakta", () => {
    const rendered = renderProvenance(
      [{ key: "favoriteCuisine", args: { cuisine: "svensk" } }],
      "sv-SE",
    );
    expect(rendered).toContain("svensk");
    expect(rendered).toContain("berättat");
  });

  it("förbjuden copy upptäcks", () => {
    expect(containsForbiddenCopy("Du har överskridit ditt kalorimål")).toBe(true);
    expect(containsForbiddenCopy("Passar ditt proteinmål")).toBe(false);
  });

  it("scoring är deterministisk", () => {
    const personalCtx: RecommendationContext = {
      ...ctx,
      personalizationEnabled: true,
      memoryItems: [memoryBase],
      tasteSignals: [tastePositive],
      cookingAssumptions: [cookingAssumption],
    };
    const c = candidate({ ingredientIds: ["chicken"], spiceLevel: 3 });
    const a = scoreCandidate(c, personalCtx);
    const b = scoreCandidate(c, personalCtx);
    expect(a.score).toBe(b.score);
    expect(a.whySv).toBe(b.whySv);
  });

  it("icke-personaliserade vikter har noll för S1-komponenter", () => {
    expect(NON_PERSONALIZED_WEIGHTS.memoryFit).toBe(0);
    expect(NON_PERSONALIZED_WEIGHTS.tasteFit).toBe(0);
    expect(NON_PERSONALIZED_WEIGHTS.cookingAssumptionFit).toBe(0);
    // Befintliga vikter ska vara oförändrade.
    expect(NON_PERSONALIZED_WEIGHTS.coverage).toBe(DEFAULT_WEIGHTS.coverage);
  });
});

describe("S4 rekommendationsvyer", () => {
  const viewCtx: RecommendationContext = {
    ...ctx,
    personalizationEnabled: true,
    memoryItems: [],
    tasteSignals: [],
    cookingAssumptions: [],
  };

  const tasty = candidate({
    recipeId: "tasty",
    titleSv: "Svensk köttbullsgryta",
    cuisine: "swedish",
    coverage: fullCoverage,
    nutritionPerPortion: { ...nutrition, proteinG: 20 },
  });

  const pantry = candidate({
    recipeId: "pantry",
    titleSv: "Italiensk pastarätt",
    cuisine: "italian",
    coverage: {
      ...fullCoverage,
      expiringUsed: [
        {
          canonicalIngredientId: "pasta",
          displayNameSv: "pastan",
          required: 200,
          unit: "GRAM",
          availableInUnit: 250,
          covered: true,
          optional: false,
          mostUrgentDaysLeft: 1,
          usesExpiringItem: true,
        },
      ],
    },
    nutritionPerPortion: { ...nutrition, proteinG: 15 },
  });

  const healthy = candidate({
    recipeId: "healthy",
    titleSv: "Kyckling och quinoa",
    cuisine: "greek",
    coverage: fullCoverage,
    nutritionPerPortion: { ...nutrition, proteinG: 50 },
  });

  it("smak-vyn höjer recept i favoritkök", () => {
    const ranked = rankAll([pantry, healthy, tasty], viewCtx, TASTE_VIEW_WEIGHTS);
    expect(ranked[0]?.recipeId).toBe("tasty");
  });

  it("lager-vyn höjer recept som räddar varor nära utgångsdatum", () => {
    const ranked = rankAll([tasty, healthy, pantry], viewCtx, PANTRY_VIEW_WEIGHTS);
    expect(ranked[0]?.recipeId).toBe("pantry");
  });

  it("hälsa-vyn höjer recept som matchar näringsmål", () => {
    const ranked = rankAll([tasty, pantry, healthy], viewCtx, HEALTH_VIEW_WEIGHTS);
    expect(ranked[0]?.recipeId).toBe("healthy");
  });

  it("viewWeights returnerar rätt vikter för varje vy", () => {
    expect(viewWeights("default").taste).toBe(DEFAULT_WEIGHTS.taste);
    expect(viewWeights("taste").taste).toBe(TASTE_VIEW_WEIGHTS.taste);
    expect(viewWeights("health").nutritionFit).toBe(HEALTH_VIEW_WEIGHTS.nutritionFit);
    expect(viewWeights("pantry").coverage).toBe(PANTRY_VIEW_WEIGHTS.coverage);
  });

  it("opersonliga vyerna fungerar utan samtycke genom depersonalize", () => {
    const nonPersonalCtx: RecommendationContext = {
      ...viewCtx,
      personalizationEnabled: false,
      memoryItems: undefined,
      tasteSignals: undefined,
      cookingAssumptions: undefined,
    };

    const tasty = candidate({
      recipeId: "tasty",
      titleSv: "Svensk köttbullsgryta",
      cuisine: "swedish",
      coverage: fullCoverage,
      nutritionPerPortion: { ...nutrition, proteinG: 20 },
    });

    const pantry = candidate({
      recipeId: "pantry",
      titleSv: "Italiensk pastarätt",
      cuisine: "italian",
      coverage: {
        ...fullCoverage,
        expiringUsed: [
          {
            canonicalIngredientId: "pasta",
            displayNameSv: "pastan",
            required: 200,
            unit: "GRAM",
            availableInUnit: 250,
            covered: true,
            optional: false,
            mostUrgentDaysLeft: 1,
            usesExpiringItem: true,
          },
        ],
      },
      nutritionPerPortion: { ...nutrition, proteinG: 15 },
    });

    const healthy = candidate({
      recipeId: "healthy",
      titleSv: "Kyckling och quinoa",
      cuisine: "greek",
      coverage: fullCoverage,
      nutritionPerPortion: { ...nutrition, proteinG: 50 },
    });

    // default utan samtycke = depersonalize(DEFAULT_WEIGHTS) == NON_PERSONALIZED_WEIGHTS
    const defaultRanked = rankAll(
      [pantry, healthy, tasty],
      nonPersonalCtx,
      depersonalize(viewWeights("default")),
    );
    const nonPersonalDefaultRanked = rankAll(
      [pantry, healthy, tasty],
      nonPersonalCtx,
      NON_PERSONALIZED_WEIGHTS,
    );
    expect(defaultRanked.map((r) => r.recipeId)).toEqual(
      nonPersonalDefaultRanked.map((r) => r.recipeId),
    );

    // vyerna är fortfarande skilda från default även utan samtycke
    expect(
      rankAll([tasty, healthy, pantry], nonPersonalCtx, depersonalize(viewWeights("pantry")))[0]
        ?.recipeId,
    ).toBe("pantry");
    expect(
      rankAll([tasty, pantry, healthy], nonPersonalCtx, depersonalize(viewWeights("health")))[0]
        ?.recipeId,
    ).toBe("healthy");

    // taste-vyn honoreras men personliga axlar är nollade
    const tasteWeights = depersonalize(viewWeights("taste"));
    expect(tasteWeights.memoryFit).toBe(0);
    expect(tasteWeights.tasteFit).toBe(0);
    expect(tasteWeights.cookingAssumptionFit).toBe(0);
    expect(tasteWeights.taste).toBe(TASTE_VIEW_WEIGHTS.taste);
  });
});

describe("diet-affinitet (spec §diet, mjuk rankning)", () => {
  it("utan matvana är affiniteten neutral (0.5)", () => {
    const scored = scoreCandidate(candidate(), ctx);
    expect(scored.parts.dietAffinity).toBe(0.5);
  });

  it("low_carb/LCHF premierar låg kolhydrat", () => {
    const low = scoreCandidate(candidate({ nutritionPerPortion: { ...nutrition, carbsG: 10 } }), {
      ...ctx,
      dietPattern: "low_carb",
    });
    const high = scoreCandidate(candidate({ nutritionPerPortion: { ...nutrition, carbsG: 60 } }), {
      ...ctx,
      dietPattern: "low_carb",
    });
    expect(low.parts.dietAffinity).toBe(1);
    expect(high.parts.dietAffinity).toBe(0.15);
  });

  it("explicit low_carb-tagg räcker oavsett makro", () => {
    const tagged = scoreCandidate(
      candidate({ tags: ["low_carb"], nutritionPerPortion: { ...nutrition, carbsG: 55 } }),
      { ...ctx, dietPattern: "keto" },
    );
    expect(tagged.parts.dietAffinity).toBe(1);
  });

  it("carnivore premierar högt protein + låg kolhydrat", () => {
    const meaty = scoreCandidate(
      candidate({ nutritionPerPortion: { ...nutrition, proteinG: 45, carbsG: 5 } }),
      { ...ctx, dietPattern: "carnivore" },
    );
    const carby = scoreCandidate(
      candidate({ nutritionPerPortion: { ...nutrition, proteinG: 12, carbsG: 70 } }),
      { ...ctx, dietPattern: "carnivore" },
    );
    expect(meaty.parts.dietAffinity).toBe(1);
    expect(meaty.parts.dietAffinity).toBeGreaterThan(carby.parts.dietAffinity ?? 0);
  });

  it("nordisk premierar svenskt/nordiskt kök", () => {
    const swedish = scoreCandidate(candidate({ cuisine: "swedish" }), {
      ...ctx,
      dietPattern: "nordic",
    });
    const italian = scoreCandidate(candidate({ cuisine: "italian" }), {
      ...ctx,
      dietPattern: "nordic",
    });
    expect(swedish.parts.dietAffinity).toBe(0.85);
    expect(swedish.parts.dietAffinity).toBeGreaterThan(italian.parts.dietAffinity ?? 0);
  });

  it("är mjuk rankning, inte hårt filter – ej matchande recept finns kvar", () => {
    const lowCarbRecipe = candidate({
      recipeId: "lowcarb",
      nutritionPerPortion: { ...nutrition, carbsG: 8 },
    });
    const pastaRecipe = candidate({
      recipeId: "pasta",
      nutritionPerPortion: { ...nutrition, carbsG: 80 },
    });
    const ranked = rankAll([pastaRecipe, lowCarbRecipe], { ...ctx, dietPattern: "low_carb" });
    // Båda recepten finns kvar (inget filtreras bort), men lågkolhydrat rankas högre.
    expect(ranked).toHaveLength(2);
    expect(ranked[0]?.recipeId).toBe("lowcarb");
  });
});
