import type {
  CookingBackgroundTaskView,
  CookingPhaseKind,
  CookingPhaseView,
  CookingSessionRecipeRef,
  CookingTaskIngredientView,
  CookingTaskView,
  TechStepView,
  UtensilView,
} from "@batch-cooking/shared";

/**
 * The pure core of the "Calcul batch-cooking" module (`specs/batch-cooking-architecture.md`):
 * takes the week's planned recipes — already resolved to reference views by
 * `cooking-session.service.ts` — and reorganizes their steps into an ordered
 * sequence of {@link CookingPhaseView}s that pools shared preparation and
 * interleaves the recipes so passive cooks (simmer, braise, bake…) run in
 * the background while the cook does active work from another recipe.
 *
 * Pure and synchronous, no database access — same `matchXxx()` pure /
 * `loadXxx()` DB-backed split as `ingredient-matcher.ts` /
 * `tech-step-matcher.ts` / `shopping-list.service.ts`'s
 * `aggregateShoppingList`, so the whole optimization is unit-testable
 * without a Postgres round-trip.
 *
 * v1 scope (see the plan / spec): preparation is the only thing *merged*
 * across recipes — a `chop`/`peel`/… technique applied to the same
 * ingredient by two or more recipes, in a step that does nothing but prep,
 * collapses into a single {@link CookingTaskView} of `kind: "merged-prep"`.
 * Cooking steps themselves are never merged (no "same oven, same
 * temperature" reasoning yet); they're only *reordered* for parallelism.
 */

/**
 * Technique keys (`TechStep.key`, see `reference-seed-data.ts`'s
 * `TECH_STEPS`) that are pure knife/prep work on an ingredient — the only
 * techniques v1 pools across recipes. A *step* counts as prep only when
 * **every** technique it mentions is in here (see {@link isPurePrepStep}):
 * "émincer les oignons" merges, "faire revenir les oignons émincés" does
 * not (its `panFry` keeps it a cooking step).
 */
const PREP_TECHNIQUES: ReadonlySet<string> = new Set([
  "chop",
  "peel",
  "mince",
  "julienne",
  "brunoise",
  "concasse",
  "paysanne",
  "mirepoix",
  "zest",
  "score",
  "pod",
  "shellEgg",
  "hollowOut",
  "filet",
  "disgorge",
  "sift",
  "dustWithFlour",
  "peelBlanch",
]);

/**
 * How much of the cook's attention a technique needs once it's under way —
 * the axis that makes parallelism possible.
 *
 * - `"SETUP"` — a short active trigger, then it looks after itself: preheat
 *   the oven, bring a pot of water to the boil. Pooled into the first
 *   ("mise en place") phase so it's running before it's needed.
 * - `"PASSIVE"` — unattended once started (simmer, braise, bake, marinate,
 *   rest…). Scheduled, then floated into every following phase's
 *   `background` until the step that consumes it comes up.
 * - anything not listed here, or a step with no detected technique at all,
 *   is treated as `"ACTIVE"` — hands-on, occupies the cook.
 */
const SETUP_TECHNIQUES: ReadonlySet<string> = new Set(["preheat", "boil", "bainMarie"]);

/** See {@link SETUP_TECHNIQUES}. */
const PASSIVE_TECHNIQUES: ReadonlySet<string> = new Set([
  "simmer",
  "bake",
  "roast",
  "braise",
  "marinate",
  "rest",
  "proof",
  "confit",
  "reduce",
  "blindBake",
  "compote",
  "smother",
  "setGel",
  "pasteurize",
  "appertize",
  "poach",
  "sweat",
  "glaze",
]);

/** Attention class of a single step — see {@link SETUP_TECHNIQUES}. */
type Attention = "SETUP" | "PASSIVE" | "ACTIVE";

/** One technique occurrence within a step, already scaled to the planned portions. */
interface OptimizerTechStepInput {
  techStep: TechStepView;
  order: number;
  ingredients: CookingTaskIngredientView[];
  utensils: UtensilView[];
}

/** One recipe step, as handed to {@link optimizeCookingPlan}. */
interface OptimizerStepInput {
  stepId: number;
  order: number;
  description: string;
  techSteps: OptimizerTechStepInput[];
}

/**
 * One planned recipe, as handed to {@link optimizeCookingPlan}. `portions`
 * is the planning slot's own count and `recipePortions` the recipe's
 * as-written yield — quantities are scaled by `portions / recipePortions`
 * (see {@link scaleOf}). The same recipe planned twice at different portion
 * counts arrives as two entries with the same `recipeId`; that's
 * intentional (two real cooking jobs), and merged-prep still pools their
 * knife work back together.
 */
interface OptimizerRecipeInput {
  recipeId: number;
  name: string;
  portions: number;
  recipePortions: number;
  steps: OptimizerStepInput[];
}

/** {@link optimizeCookingPlan}'s result — the date-range/legend wrapper is added by the service. */
interface OptimizeCookingPlanResult {
  recipes: CookingSessionRecipeRef[];
  phases: CookingPhaseView[];
}

export type {
  OptimizeCookingPlanResult,
  OptimizerRecipeInput,
  OptimizerStepInput,
  OptimizerTechStepInput,
};

/** Portion scale factor for a recipe — guards a missing/zero as-written yield (bad data) by falling back to 1× rather than dividing by zero. */
function scaleOf(recipe: OptimizerRecipeInput): number {
  if (!recipe.recipePortions || recipe.recipePortions <= 0) return 1;
  return recipe.portions / recipe.recipePortions;
}

/** A step normalized for scheduling — techniques scaled, attention resolved, ingredients/utensils unioned across its technique clauses. */
interface NormalizedStep {
  /** Stable within one response: `step:<recipeIndex>:<stepId>` (the index disambiguates the same recipe planned twice). */
  taskId: string;
  recipeIndex: number;
  recipe: CookingSessionRecipeRef;
  stepId: number;
  order: number;
  description: string;
  techSteps: OptimizerTechStepInput[];
  attention: Attention;
  isPurePrep: boolean;
  dominantTechnique: TechStepView | null;
  ingredients: CookingTaskIngredientView[];
  utensils: UtensilView[];
  /** Set once merged-prep extraction absorbs this step wholesale (all its prep pooled elsewhere) — it then produces no standalone task. */
  absorbed: boolean;
}

/** Sums two ingredient lines only when it's unambiguous — same unit id and both quantities known; otherwise the pooled line carries no number (see `ShoppingListItemView`'s "don't guess a conversion" rule). */
function poolIngredient(lines: CookingTaskIngredientView[]): {
  quantity: number | null;
  unit: CookingTaskIngredientView["unit"];
} {
  const first = lines[0];
  if (!first) return { quantity: null, unit: null };
  const unitId = first.unit?.id ?? null;
  let total = 0;
  for (const line of lines) {
    if (line.quantity === null || (line.unit?.id ?? null) !== unitId) {
      return { quantity: null, unit: null };
    }
    total += line.quantity;
  }
  return { quantity: total, unit: first.unit };
}

/** Unions ingredient lines by `(ingredientId, unitId)`, summing quantities within a group the same careful way as {@link poolIngredient}. */
function unionIngredients(lines: CookingTaskIngredientView[]): CookingTaskIngredientView[] {
  const groups = new Map<string, CookingTaskIngredientView[]>();
  for (const line of lines) {
    const key = `${line.ingredient.id}:${line.unit?.id ?? "x"}`;
    const group = groups.get(key);
    if (group) group.push(line);
    else groups.set(key, [line]);
  }
  const out: CookingTaskIngredientView[] = [];
  for (const group of groups.values()) {
    const head = group[0];
    if (!head) continue;
    const pooled = poolIngredient(group);
    out.push({ ingredient: head.ingredient, quantity: pooled.quantity, unit: pooled.unit });
  }
  return out.sort((a, b) => a.ingredient.key.localeCompare(b.ingredient.key));
}

/** Unions utensils by id, keeping a stable order by key. */
function unionUtensils(utensils: UtensilView[]): UtensilView[] {
  const byId = new Map<number, UtensilView>();
  for (const utensil of utensils) byId.set(utensil.id, utensil);
  return [...byId.values()].sort((a, b) => a.key.localeCompare(b.key));
}

/** A step is pure prep only if it has techniques and every one of them is in {@link PREP_TECHNIQUES}. */
function isPurePrepStep(techSteps: OptimizerTechStepInput[]): boolean {
  return techSteps.length > 0 && techSteps.every((ts) => PREP_TECHNIQUES.has(ts.techStep.key));
}

/** Resolves a step's {@link Attention} — SETUP wins, then a *trailing* passive technique, else ACTIVE (see {@link SETUP_TECHNIQUES}). */
function attentionOf(techSteps: OptimizerTechStepInput[]): Attention {
  if (techSteps.some((ts) => SETUP_TECHNIQUES.has(ts.techStep.key))) return "SETUP";
  const last = techSteps[techSteps.length - 1];
  if (last && PASSIVE_TECHNIQUES.has(last.techStep.key)) return "PASSIVE";
  return "ACTIVE";
}

/** Turns one recipe's raw steps into {@link NormalizedStep}s — scales quantities, resolves attention, unions per-clause ingredients/utensils up to the step. */
function normalizeRecipe(recipe: OptimizerRecipeInput, recipeIndex: number): NormalizedStep[] {
  const scale = scaleOf(recipe);
  const recipeRef: CookingSessionRecipeRef = {
    recipeId: recipe.recipeId,
    name: recipe.name,
    portions: recipe.portions,
  };

  return [...recipe.steps]
    .sort((a, b) => a.order - b.order)
    .map((step) => {
      const techSteps: OptimizerTechStepInput[] = [...step.techSteps]
        .sort((a, b) => a.order - b.order)
        .map((ts) => ({
          techStep: ts.techStep,
          order: ts.order,
          ingredients: ts.ingredients.map((line) => ({
            ingredient: line.ingredient,
            quantity: line.quantity === null ? null : line.quantity * scale,
            unit: line.unit,
          })),
          utensils: ts.utensils,
        }));

      const lastTech = techSteps[techSteps.length - 1];
      return {
        taskId: `step:${recipeIndex}:${step.stepId}`,
        recipeIndex,
        recipe: recipeRef,
        stepId: step.stepId,
        order: step.order,
        description: step.description,
        techSteps,
        attention: attentionOf(techSteps),
        isPurePrep: isPurePrepStep(techSteps),
        dominantTechnique: lastTech ? lastTech.techStep : null,
        ingredients: unionIngredients(techSteps.flatMap((ts) => ts.ingredients)),
        utensils: unionUtensils(techSteps.flatMap((ts) => ts.utensils)),
        absorbed: false,
      } satisfies NormalizedStep;
    });
}

/** The prep signature of a pure-prep step — sorted `<techniqueKey>:<ingredientId>` pairs; two steps with the same signature do identical knife work and can be pooled. */
function prepSignature(step: NormalizedStep): string {
  const pairs: string[] = [];
  for (const ts of step.techSteps) {
    for (const line of ts.ingredients) {
      pairs.push(`${ts.techStep.key}:${line.ingredient.id}`);
    }
  }
  return [...new Set(pairs)].sort().join("+");
}

/** Builds one {@link CookingTaskView} from a normalized step run as written. */
function stepToTask(step: NormalizedStep): CookingTaskView {
  return {
    id: step.taskId,
    kind: "step",
    technique: step.dominantTechnique,
    description: step.description,
    ingredients: step.ingredients,
    utensils: step.utensils,
    sourceRecipes: [step.recipe],
    originalSteps: [
      {
        recipeId: step.recipe.recipeId,
        recipeName: step.recipe.name,
        description: step.description,
      },
    ],
  };
}

/** Builds the running-in-the-background status line for a passive step already scheduled in an earlier phase. */
function stepToBackground(step: NormalizedStep): CookingBackgroundTaskView {
  return {
    id: `bg:${step.taskId}`,
    technique: step.dominantTechnique,
    description: step.description,
    recipeId: step.recipe.recipeId,
    recipeName: step.recipe.name,
  };
}

/**
 * Pools pure-prep steps that do the *exact same* knife work (same
 * {@link prepSignature}) in two or more distinct recipes into one
 * `merged-prep` {@link CookingTaskView}, and marks every contributing step
 * `absorbed` so it produces no standalone task. A pure-prep step whose
 * signature is unique (only one recipe needs it) is left untouched — it
 * still lands in the mise-en-place phase, just as its own step task.
 *
 * Returns the merged tasks in a stable order (by id).
 */
function extractMergedPrep(steps: NormalizedStep[]): CookingTaskView[] {
  const bySignature = new Map<string, NormalizedStep[]>();
  for (const step of steps) {
    if (!step.isPurePrep) continue;
    const signature = prepSignature(step);
    if (signature === "") continue;
    const group = bySignature.get(signature);
    if (group) group.push(step);
    else bySignature.set(signature, [step]);
  }

  const merged: CookingTaskView[] = [];
  for (const [signature, group] of bySignature) {
    const recipeIndexes = new Set(group.map((s) => s.recipeIndex));
    if (recipeIndexes.size < 2) continue;

    for (const step of group) step.absorbed = true;

    // Every contributing clause's ingredient lines, pooled per ingredient.
    const allLines = group.flatMap((s) => s.techSteps.flatMap((ts) => ts.ingredients));
    const ingredients = unionIngredients(allLines);
    const utensils = unionUtensils(group.flatMap((s) => s.utensils));

    // Dominant technique of the pool = the first pair's technique (v1
    // signatures are almost always a single `<technique>:<ingredient>`
    // pair; a multi-pair signature just takes the earliest).
    const firstTech = group[0]?.techSteps[0]?.techStep ?? null;
    const firstIngredientKey = ingredients[0]?.ingredient.key ?? signature;

    // Distinct source recipes / original step texts, in input order.
    const sourceRecipes: CookingSessionRecipeRef[] = [];
    const seenRecipe = new Set<number>();
    const originalSteps: CookingTaskView["originalSteps"] = [];
    for (const step of [...group].sort((a, b) => a.recipeIndex - b.recipeIndex)) {
      if (!seenRecipe.has(step.recipeIndex)) {
        seenRecipe.add(step.recipeIndex);
        sourceRecipes.push(step.recipe);
      }
      originalSteps.push({
        recipeId: step.recipe.recipeId,
        recipeName: step.recipe.name,
        description: step.description,
      });
    }

    merged.push({
      id: `prep:${firstTech ? firstTech.key : "prep"}:${firstIngredientKey}`,
      kind: "merged-prep",
      technique: firstTech,
      description: null,
      ingredients,
      utensils,
      sourceRecipes,
      originalSteps,
    });
  }

  return merged.sort((a, b) => a.id.localeCompare(b.id));
}

/** Maps the input recipe list to its display legend, de-duplicating an exact `(recipeId, portions)` repeat. */
function toRecipeLegend(recipes: OptimizerRecipeInput[]): CookingSessionRecipeRef[] {
  const seen = new Set<string>();
  const out: CookingSessionRecipeRef[] = [];
  for (const recipe of recipes) {
    const key = `${recipe.recipeId}:${recipe.portions}`;
    if (seen.has(key)) continue;
    seen.add(key);
    out.push({ recipeId: recipe.recipeId, name: recipe.name, portions: recipe.portions });
  }
  return out;
}

/** A phase is `"finishing"` when everything left in it is plating; otherwise it's a normal `"cooking"` phase. */
function cookingPhaseKind(tasks: CookingTaskView[]): CookingPhaseKind {
  return tasks.every((task) => task.technique?.key === "plate") ? "finishing" : "cooking";
}

/**
 * See the file header. Given the week's planned recipes (already resolved
 * to reference views), returns the display legend plus the ordered phases:
 *
 * 1. **Mise en place** (`"mise-en-place"`) — every `merged-prep` task, then
 *    every leftover pure-prep step, then every `SETUP` step. Omitted
 *    entirely if it would be empty.
 * 2. **Cooking** (`"cooking"` / `"finishing"`) — the recipes interleaved:
 *    each phase pops the next remaining step of every recipe that still has
 *    one (passive-cook steps first, so long cooks start early). A passive
 *    step scheduled in one phase is echoed in every later phase's
 *    `background` until that recipe's next step is popped.
 */
export function optimizeCookingPlan(recipes: OptimizerRecipeInput[]): OptimizeCookingPlanResult {
  const legend = toRecipeLegend(recipes);
  const normalized = recipes.map((recipe, index) => normalizeRecipe(recipe, index));
  const allSteps = normalized.flat();

  const mergedPrep = extractMergedPrep(allSteps);

  const phases: CookingPhaseView[] = [];

  // Phase 0 — mise en place.
  const miseTasks: CookingTaskView[] = [...mergedPrep];
  for (const step of allSteps) {
    if (step.absorbed) continue;
    if (step.isPurePrep || step.attention === "SETUP") {
      miseTasks.push(stepToTask(step));
      step.absorbed = true; // consumed here, not again in the cooking loop
    }
  }
  if (miseTasks.length > 0) {
    phases.push({ index: 0, kind: "mise-en-place", tasks: miseTasks, background: [] });
  }

  // Cooking phases — one "next step of each recipe" per phase. `hold` keeps
  // a recipe out of the *next* phase right after it starts a passive cook,
  // so another recipe's active work fills that phase and the passive cook
  // shows up as `background` there instead of being immediately followed by
  // its own next step.
  const queues = normalized.map((steps) => ({
    remaining: steps.filter((s) => !s.absorbed),
    cursor: 0,
    hold: 0,
  }));
  /** Passive steps started in an earlier phase, keyed by recipe index, still "cooking". */
  const runningPassive = new Map<number, NormalizedStep>();

  while (queues.some((queue) => queue.cursor < queue.remaining.length)) {
    const phaseSteps: NormalizedStep[] = [];
    queues.forEach((queue, recipeIndex) => {
      const next = queue.remaining[queue.cursor];
      if (!next) return;
      if (queue.hold > 0) {
        // Still tending its passive cook this phase — leave it in
        // `runningPassive` so it renders as background, don't advance.
        queue.hold--;
        return;
      }
      // This recipe is advancing — whatever passive cook it had going is
      // now being tended to, so it stops showing as background.
      runningPassive.delete(recipeIndex);
      phaseSteps.push(next);
      queue.cursor++;
    });

    // Every recipe with steps left is holding on a passive cook — break the
    // stall by releasing all holds and letting the next iteration advance.
    if (phaseSteps.length === 0) {
      for (const queue of queues) queue.hold = 0;
      continue;
    }

    // Background = passive cooks from earlier phases not yet resolved above.
    const background = [...runningPassive.values()].map(stepToBackground);

    // Start the long cooks first within the phase.
    phaseSteps.sort((a, b) => {
      const rank = (s: NormalizedStep) => (s.attention === "PASSIVE" ? 0 : 1);
      return rank(a) - rank(b) || a.recipeIndex - b.recipeIndex;
    });

    const tasks = phaseSteps.map(stepToTask);
    phases.push({
      index: phases.length,
      kind: cookingPhaseKind(tasks),
      tasks,
      background,
    });

    for (const step of phaseSteps) {
      if (step.attention === "PASSIVE") {
        runningPassive.set(step.recipeIndex, step);
        const queue = queues[step.recipeIndex];
        if (queue) queue.hold = 1;
      }
    }
  }

  // `index` was set from `phases.length` as we went; re-stamp so it always
  // matches the final array position even if phase 0 was skipped.
  phases.forEach((phase, index) => {
    phase.index = index;
  });

  return { recipes: legend, phases };
}
