import {
  calculate,
  qualityDistribution,
  resolveAutoModules,
} from "./calculator.ts";
import {
  MACHINE_BY_ID,
  PRODUCT_BY_KEY,
  QUALITIES,
  QUALITY_BY_ID,
  RECIPE_BY_ID,
  machinesForRecipe,
  preferredChainRecipeForProduct,
  preferredMachineForRecipe,
} from "./factorio-data.ts";
import { calculateLogistics, DEFAULT_LOGISTICS } from "./logistics.ts";
import {
  preferredUnlockedChainRoute,
  rankedMachinesForRecipe,
  unlockedMachineSet,
} from "./chain-machines.ts";
import {
  DEFAULT_MINING_DRILL_ID,
  miningSourceForBaseline,
} from "./mining.ts";
import type {
  CalculationResult,
  CalculatorInput,
  ChainMasterSettings,
  ChainOverride,
  ChainSource,
  LogisticsSettings,
  MaterialFlow,
  ProductionChainNode,
  ProductionChainResult,
  ProductOption,
  QualityAmount,
  QualityId,
} from "./types.ts";

type SourceSpec = {
  entityId: string;
  rate: number;
  electricKw: number;
  entityName: string;
  mined?: boolean;
};
type ResolvedSourceSpec = SourceSpec & {
  moduleSlots?: number;
  modules?: ChainSource["modules"];
  qualityChance?: number;
};
const SOURCES: Record<string, SourceSpec> = {
  "fluid:water": { entityId: "offshore-pump", rate: 1200, electricKw: 0, entityName: "Offshore pump" },
  "fluid:crude-oil": { entityId: "pumpjack", rate: 10, electricKw: 90, entityName: "Pumpjack" },
  "fluid:lava": { entityId: "offshore-pump", rate: 1200, electricKw: 0, entityName: "Offshore pump" },
  "fluid:ammoniacal-solution": { entityId: "offshore-pump", rate: 1200, electricKw: 0, entityName: "Offshore pump" },
  "fluid:fluorine": { entityId: "offshore-pump", rate: 1200, electricKw: 0, entityName: "Offshore pump" },
  "fluid:lithium-brine": { entityId: "offshore-pump", rate: 1200, electricKw: 0, entityName: "Offshore pump" },
  "item:iron-ore": { entityId: "big-mining-drill", rate: 2.5, electricKw: 300, entityName: "Big mining drill", mined: true },
  "item:copper-ore": { entityId: "big-mining-drill", rate: 2.5, electricKw: 300, entityName: "Big mining drill", mined: true },
  "item:coal": { entityId: "big-mining-drill", rate: 2.5, electricKw: 300, entityName: "Big mining drill", mined: true },
  "item:stone": { entityId: "big-mining-drill", rate: 2.5, electricKw: 300, entityName: "Big mining drill", mined: true },
  "item:uranium-ore": { entityId: "big-mining-drill", rate: 1.25, electricKw: 300, entityName: "Big mining drill", mined: true },
  "item:tungsten-ore": { entityId: "big-mining-drill", rate: 1.25, electricKw: 300, entityName: "Big mining drill", mined: true },
  "item:calcite": { entityId: "big-mining-drill", rate: 1.25, electricKw: 300, entityName: "Big mining drill", mined: true },
  "item:scrap": { entityId: "big-mining-drill", rate: 1.25, electricKw: 300, entityName: "Big mining drill", mined: true },
  "item:yumako": { entityId: "agricultural-tower", rate: 1, electricKw: 100, entityName: "Agricultural tower" },
  "item:jellynut": { entityId: "agricultural-tower", rate: 1, electricKw: 100, entityName: "Agricultural tower" },
  "item:metallic-asteroid-chunk": { entityId: "asteroid-collector", rate: 1, electricKw: 0, entityName: "Asteroid collector" },
  "item:carbonic-asteroid-chunk": { entityId: "asteroid-collector", rate: 1, electricKw: 0, entityName: "Asteroid collector" },
  "item:oxide-asteroid-chunk": { entityId: "asteroid-collector", rate: 1, electricKw: 0, entityName: "Asteroid collector" },
  "item:promethium-asteroid-chunk": { entityId: "asteroid-collector", rate: 1, electricKw: 0, entityName: "Asteroid collector" },
  "item:raw-fish": { entityId: "raw-fish", rate: 1, electricKw: 0, entityName: "Natural source" },
  "item:wood": { entityId: "tree", rate: 1, electricKw: 0, entityName: "Natural source" },
};

const EPSILON = 1e-12;

function resolvedSourceSpec(
  productKey: string,
  source: SourceSpec,
  master?: ChainMasterSettings,
): ResolvedSourceSpec {
  if (!source.mined) return source;
  const { drill, rate, electricKw, modules, qualityChance } = miningSourceForBaseline(
    productKey,
    source.rate,
    master?.miningDrillId ?? DEFAULT_MINING_DRILL_ID,
    {
      family: master?.miningModuleFamily ?? "none",
      tier: master?.miningModuleTier ?? 3,
      quality: master?.miningModuleQuality ?? "normal",
    },
  );
  return {
    ...source,
    entityId: drill.id,
    entityName: drill.id,
    rate,
    electricKw,
    moduleSlots: drill.moduleSlots,
    modules,
    qualityChance,
  };
}

function sourceOutputAmounts(
  type: ProductOption["type"],
  productionRate: number,
  spec: ResolvedSourceSpec,
  baseQuality: QualityId,
  maximumQuality: QualityId,
): QualityAmount[] | undefined {
  if (type === "fluid") return undefined;
  return qualityDistribution(
    baseQuality,
    spec.qualityChance ?? 0,
    maximumQuality,
  ).map((entry) => ({
    ...entry,
    amount: entry.amount * productionRate,
  }));
}

function copyQualityAmounts(amounts: QualityAmount[]): QualityAmount[] {
  return amounts
    .filter((entry) => entry.amount > EPSILON)
    .map((entry) => ({ ...entry }));
}

function soleQuality(amounts?: QualityAmount[]): QualityId | undefined {
  const qualities = (amounts ?? []).filter(
    (entry): entry is { quality: QualityId; amount: number } =>
      entry.quality !== null && entry.amount > EPSILON,
  );
  return qualities.length === 1 ? qualities[0].quality : undefined;
}

function normalizedItemAmounts(
  amounts: QualityAmount[],
  fallback: QualityId,
): QualityAmount[] {
  const totals = new Map<QualityId, number>();
  for (const entry of amounts) {
    if (entry.quality === null || entry.amount <= EPSILON) continue;
    totals.set(entry.quality, (totals.get(entry.quality) ?? 0) + entry.amount);
  }
  const total = [...totals.values()].reduce((sum, amount) => sum + amount, 0);
  if (total <= EPSILON) return [{ quality: fallback, amount: 1 }];
  return QUALITIES.map((quality) => ({
    quality: quality.id,
    amount: (totals.get(quality.id) ?? 0) / total,
  })).filter((entry) => entry.amount > EPSILON);
}

/**
 * A Factorio craft consumes one common item quality for all solid ingredients.
 * Every upstream route can be scaled independently, so the shared stream is
 * the normalized intersection of their expected quality distributions. Any
 * unmatched higher-quality coproduct remains visible on the producing node.
 */
function sharedInputDistribution(
  distributions: QualityAmount[][],
  fallback: QualityId,
  maximum: QualityId,
): QualityAmount[] {
  if (!distributions.length) return [{ quality: fallback, amount: 1 }];
  const baseIndex = Math.max(0, QUALITIES.findIndex((quality) => quality.id === fallback));
  const maximumIndex = QUALITIES.findIndex((quality) => quality.id === maximum);
  const capIndex = Math.max(baseIndex, maximumIndex < 0 ? QUALITIES.length - 1 : maximumIndex);
  const normalized = distributions.map((distribution) =>
    normalizedItemAmounts(distribution, fallback),
  );
  const intersection = QUALITIES.slice(baseIndex, capIndex + 1).map((quality) => ({
    quality: quality.id,
    amount: Math.min(
      ...normalized.map(
        (distribution) =>
          distribution.find((entry) => entry.quality === quality.id)?.amount ?? 0,
      ),
    ),
  }));
  const total = intersection.reduce((sum, entry) => sum + entry.amount, 0);
  if (total <= EPSILON) return [{ quality: fallback, amount: 1 }];
  return intersection
    .map((entry) => ({ ...entry, amount: entry.amount / total }))
    .filter((entry) => entry.amount > EPSILON);
}

function sourceFor(
  productKey: string,
  product: ProductOption,
  rate: number,
  depth: number,
  key: string,
  override: ChainOverride,
  requiredAmounts?: QualityAmount[],
  master?: ChainMasterSettings,
  baseQuality: QualityId = "normal",
  maximumQuality: QualityId = "legendary",
): ChainSource | undefined {
  const baseSpec = SOURCES[productKey];
  if (!baseSpec) return undefined;
  const spec = resolvedSourceSpec(productKey, baseSpec, master);
  const entities = rate / spec.rate;
  return {
    kind: "source",
    key,
    id: product.id,
    name: product.name,
    icon: product.icon,
    type: product.type,
    ratePerSecond: rate,
    requiredQuality: soleQuality(requiredAmounts),
    requiredAmounts: requiredAmounts ? copyQualityAmounts(requiredAmounts) : undefined,
    outputAmounts: sourceOutputAmounts(
      product.type,
      rate,
      spec,
      baseQuality,
      maximumQuality,
    ),
    entities,
    entityId: spec.entityId,
    entityName: spec.entityName,
    moduleSlots: spec.moduleSlots,
    modules: spec.modules,
    qualityChance: spec.qualityChance,
    electricKw: Math.ceil(entities - EPSILON) * spec.electricKw,
    includeEnergy: override.includeEnergy !== false,
    depth,
  };
}

export interface ChainBuildInput {
  rootInput: CalculatorInput;
  rootResult?: ReturnType<typeof calculate>;
  overrides?: Record<string, ChainOverride>;
  masterSettings?: ChainMasterSettings;
  logisticsSettings?: LogisticsSettings;
  maxDepth?: number;
}

export function buildProductionChain(input: ChainBuildInput): ProductionChainResult {
  const overrides = input.overrides ?? {};
  const master = input.masterSettings;
  const logisticsSettings = input.logisticsSettings ?? DEFAULT_LOGISTICS;
  const warnings: string[] = [];
  const initialRootResult = input.rootResult ?? calculate(input.rootInput);

  function resolveNode(
    product: ProductOption,
    rate: number,
    depth: number,
    path: string,
    ancestors: Set<string>,
    forcedInput?: CalculatorInput,
    forcedResult?: CalculationResult,
    requiredAmounts?: QualityAmount[],
  ): ProductionChainNode | ChainSource {
    const key = path;
    const override = overrides[key] ?? {};
    const raw = sourceFor(
      product.key,
      product,
      rate,
      depth,
      key,
      override,
      requiredAmounts,
      master,
      input.rootInput.inputQuality,
      input.rootInput.maxQuality ?? "legendary",
    );
    if (raw) return raw;
    if (depth > (input.maxDepth ?? 18) || ancestors.has(product.key)) {
      const fallback: ChainSource = {
        kind: "source",
        key,
        id: product.id,
        name: product.name,
        icon: product.icon,
        type: product.type,
        ratePerSecond: rate,
        requiredQuality: soleQuality(requiredAmounts),
        requiredAmounts: requiredAmounts ? copyQualityAmounts(requiredAmounts) : undefined,
        entities: 0,
        entityName: ancestors.has(product.key) ? "Cycle boundary" : "Depth boundary",
        electricKw: 0,
        includeEnergy: override.includeEnergy !== false,
        depth,
      };
      warnings.push(`${product.name}: recursive route stopped at ${fallback.entityName.toLowerCase()}.`);
      return fallback;
    }

    const processMachineChoice = master?.processMachineChoices?.[product.key];
    const unlockedMachines = unlockedMachineSet(master);
    const machineIsAvailable = (machineId: string | undefined) => {
      if (!machineId) return false;
      const candidate = MACHINE_BY_ID.get(machineId);
      return Boolean(
        candidate &&
          (candidate.energyKind === "passive" ||
            unlockedMachines.has(candidate.id)),
      );
    };
    const requestedMachineId =
      (machineIsAvailable(override.machineId) ? override.machineId : undefined) ??
      (machineIsAvailable(processMachineChoice?.machineId)
        ? processMachineChoice?.machineId
        : undefined) ??
      (master &&
      master.preferredMachineId !== "auto" &&
      machineIsAvailable(master.preferredMachineId)
        ? master.preferredMachineId
        : undefined);
    const machineCompatibleRecipe = requestedMachineId
      ? product.recipeIds
          .map((id) => RECIPE_BY_ID.get(id))
          .filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
          .find((entry) =>
            machinesForRecipe(entry).some((candidate) => candidate.id === requestedMachineId),
          )
      : undefined;
    const unlockedRoute = preferredUnlockedChainRoute(product, master);
    const requestedRecipe =
      forcedInput?.recipe ??
      (override.recipeId ? RECIPE_BY_ID.get(override.recipeId) : undefined) ??
      (processMachineChoice?.recipeId
        ? RECIPE_BY_ID.get(processMachineChoice.recipeId)
        : undefined) ??
      machineCompatibleRecipe ??
      unlockedRoute?.recipe ??
      preferredChainRecipeForProduct(product);
    const recipe =
      forcedInput || rankedMachinesForRecipe(requestedRecipe, master).length
        ? requestedRecipe
        : unlockedRoute?.recipe ?? requestedRecipe;
    const compatible = machinesForRecipe(recipe);
    const rankedCompatible = rankedMachinesForRecipe(recipe, master);
    const machine =
      forcedInput?.machine ??
      (machineIsAvailable(override.machineId)
        ? MACHINE_BY_ID.get(override.machineId!)
        : undefined) ??
      (machineIsAvailable(processMachineChoice?.machineId)
        ? MACHINE_BY_ID.get(processMachineChoice!.machineId)
        : undefined) ??
      (master &&
      master.preferredMachineId !== "auto" &&
      machineIsAvailable(master.preferredMachineId)
        ? MACHINE_BY_ID.get(master.preferredMachineId)
        : undefined) ??
      rankedCompatible[0] ??
      preferredMachineForRecipe(recipe);
    if (!compatible.some((entry) => entry.id === machine.id))
      warnings.push(`${product.name}: incompatible machine override was ignored.`);
    const selectedMachine = compatible.some((entry) => entry.id === machine.id)
      ? machine
      : rankedCompatible[0] ?? compatible[0];
    if (!rankedCompatible.length && !forcedInput)
      warnings.push(
        `${product.name}: no unlocked compatible machine; using ${selectedMachine.name} as a fallback.`,
      );
    const machineQuality =
      override.machineQuality ?? master?.machineQuality ?? forcedInput?.machineQuality ?? "normal";
    const machineFamilies =
      override.machineModuleFamilies ??
      (override.machineModuleFamily && override.machineModuleFamily !== "none"
        ? [override.machineModuleFamily]
        : override.machineModuleFamily === "none"
          ? []
          : master?.machineModuleFamilies ?? []);
    const machineTier = override.machineModuleTier ?? master?.machineModuleTier ?? 3;
    const machineModuleQuality =
      override.machineModuleQuality ?? master?.machineModuleQuality ?? machineQuality;
    const machineModules =
      forcedInput?.machineModules ??
      (!machineFamilies.length
        ? Array.from({ length: selectedMachine.moduleSlots }, () => null)
        : resolveAutoModules(
            machineFamilies,
            machineTier,
            machineModuleQuality,
            selectedMachine.moduleSlots,
            selectedMachine,
            recipe,
          ));
    const beaconEnabled =
      override.beaconEnabled ?? master?.beaconEnabled ?? Boolean(forcedInput?.beaconCount);
    const beaconMode = override.beaconMode ?? master?.beaconMode ?? "standard";
    const qualitySafeBeacon = beaconMode === "quality-safe";
    const beaconCount = beaconEnabled
      ? Math.min(
          override.beaconCount ?? master?.beaconCount ?? forcedInput?.beaconCount ?? 0,
          qualitySafeBeacon ? 1 : selectedMachine.maxBeacons,
        )
      : 0;
    const beaconFamily = override.beaconModuleFamily ?? master?.beaconModuleFamily ?? "speed";
    const beaconModuleTier = override.beaconModuleTier ?? master?.beaconModuleTier ?? 3;
    const beaconModuleQuality =
      override.beaconModuleQuality ?? master?.beaconModuleQuality ?? machineQuality;
    const beaconQuality =
      override.beaconQuality ?? master?.beaconQuality ?? forcedInput?.beaconQuality ?? machineQuality;
    const resolvedBeaconModules =
      beaconFamily === "none"
        ? [null, null]
        : resolveAutoModules(
            [beaconFamily],
            beaconModuleTier,
            beaconModuleQuality,
            qualitySafeBeacon ? 1 : 2,
            selectedMachine,
            recipe,
            true,
          );
    const beaconModules =
      forcedInput?.beaconModules ??
      (qualitySafeBeacon ? [resolvedBeaconModules[0] ?? null, null] : resolvedBeaconModules);
    const hasQualityModules = machineModules.some((selection) => selection?.family === "quality");
    const requiredQuality = soleQuality(requiredAmounts);
    const calculatorInput: CalculatorInput = forcedInput ?? {
      recipe,
      product,
      machine: selectedMachine,
      machineQuality,
      inputQuality: input.rootInput.inputQuality,
      outputQuality:
        product.type === "fluid" ? "total" : requiredQuality ?? input.rootInput.inputQuality,
      maxQuality: input.rootInput.maxQuality,
      calculationMode: hasQualityModules ? "quality" : input.rootInput.calculationMode,
      machineModules,
      beaconModules,
      beaconQuality,
      beaconCount,
      mode: "output",
      amount: rate,
      unit: "s",
      inputConstraints: [],
    };
    const result = forcedResult ?? calculate(calculatorInput);
    const nodeLogisticsSettings: LogisticsSettings = forcedInput
      ? logisticsSettings
      : {
          inserterId:
            override.inserterId ?? master?.inserterId ?? logisticsSettings.inserterId,
          inserterQuality:
            override.inserterQuality ??
            master?.inserterQuality ??
            logisticsSettings.inserterQuality,
          capacityBonus:
            override.inserterCapacityBonus ??
            master?.inserterCapacityBonus ??
            logisticsSettings.capacityBonus,
          handoff:
            override.inserterHandoff ??
            master?.inserterHandoff ??
            logisticsSettings.handoff,
        };
    const logistics = calculateLogistics(
      result,
      calculatorInput,
      nodeLogisticsSettings,
    );
    const includeEnergy = override.includeEnergy !== false;
    const node: ProductionChainNode = {
      kind: "recipe",
      key,
      productKey: product.key,
      product,
      ratePerSecond: rate,
      requiredQuality,
      requiredAmounts: requiredAmounts ? copyQualityAmounts(requiredAmounts) : undefined,
      calculatorInput,
      recipe,
      machine: selectedMachine,
      result,
      logistics,
      logisticsSettings: nodeLogisticsSettings,
      electricKw: result.electricTargetKw + logistics.electricKw,
      includeEnergy,
      depth,
      inputs: [],
    };
    const nextAncestors = new Set(ancestors).add(product.key);
    node.inputs = result.inputs.map((flow, index) => {
      const childKey = `${flow.type}:${flow.id}`;
      const childPath = `${path}/${index}:${childKey}`;
      const childProduct = PRODUCT_BY_KEY.get(childKey);
      if (!childProduct) {
        return {
          kind: "source",
          key: childPath,
          id: flow.id,
          name: flow.name,
          icon: flow.icon,
          type: flow.type,
          ratePerSecond: flow.total,
          requiredQuality: soleQuality(flow.amounts),
          requiredAmounts: copyQualityAmounts(flow.amounts),
          entities: 0,
          entityName: "External source",
          electricKw: 0,
          includeEnergy: overrides[childPath]?.includeEnergy !== false,
          depth: depth + 1,
        } satisfies ChainSource;
      }
      return resolveNode(
        childProduct,
        flow.total,
        depth + 1,
        childPath,
        nextAncestors,
        undefined,
        undefined,
        flow.amounts,
      );
    });
    return node;
  }

  const baseRoot = resolveNode(
    input.rootInput.product,
    initialRootResult.ratePerSecond,
    0,
    `root:${input.rootInput.product.key}`,
    new Set(),
    input.rootInput,
    initialRootResult,
  ) as ProductionChainNode;

  const qualityCoupled =
    input.rootInput.calculationMode === "quality" && input.rootInput.product.type === "item";
  let rootInput = input.rootInput;
  let rootResult = initialRootResult;
  let root = baseRoot;

  if (qualityCoupled) {
    const inputDistributions = new Map<string, QualityAmount[]>();
    const outputDistributions = new Map<string, QualityAmount[]>();
    const maximum = input.rootInput.maxQuality ?? "legendary";

    function profile(node: ProductionChainNode | ChainSource): QualityAmount[] {
      if (node.kind === "source") {
        if (node.type === "fluid") return [];
        return normalizedItemAmounts(
          node.outputAmounts ?? [],
          input.rootInput.inputQuality,
        );
      }
      const childProfiles = node.inputs.map((child) => ({ child, distribution: profile(child) }));
      const itemProfiles = childProfiles
        .filter(({ child }) =>
          child.kind === "source" ? child.type === "item" : child.product.type === "item",
        )
        .map(({ distribution }) => distribution);
      const mix = sharedInputDistribution(
        itemProfiles,
        input.rootInput.inputQuality,
        maximum,
      );
      inputDistributions.set(node.key, mix);
      const profileInput: CalculatorInput = {
        ...node.calculatorInput,
        inputQualityDistribution: mix,
        outputQuality: "total",
        mode: "output",
        amount: 1,
        unit: "s",
        inputConstraints: [],
      };
      const result = calculate(profileInput);
      const output = result.outputs.find(
        (flow) => flow.id === node.product.id && flow.type === node.product.type,
      );
      const distribution =
        node.product.type === "item" && output
          ? normalizedItemAmounts(output.amounts, input.rootInput.inputQuality)
          : [];
      outputDistributions.set(node.key, distribution);
      return distribution;
    }

    profile(baseRoot);
    const rootMix = inputDistributions.get(baseRoot.key) ?? [
      { quality: input.rootInput.inputQuality, amount: 1 },
    ];
    rootInput = { ...input.rootInput, inputQualityDistribution: rootMix };
    rootResult = calculate(rootInput);

    function requiredProductionRate(flow: MaterialFlow, distribution: QualityAmount[]): number {
      if (flow.type === "fluid") return flow.total;
      const shares = normalizedItemAmounts(distribution, input.rootInput.inputQuality);
      let rate = 0;
      for (const required of flow.amounts) {
        if (required.quality === null || required.amount <= EPSILON) continue;
        const share = shares.find((entry) => entry.quality === required.quality)?.amount ?? 0;
        if (share <= EPSILON) return flow.total;
        rate = Math.max(rate, required.amount / share);
      }
      return Math.max(flow.total, rate);
    }

    function resizeSource(template: ChainSource, flow: MaterialFlow): ChainSource {
      const productKey = `${flow.type}:${flow.id}`;
      const baseSpec = SOURCES[productKey];
      const spec = baseSpec
        ? resolvedSourceSpec(productKey, baseSpec, master)
        : undefined;
      const distribution = normalizedItemAmounts(
        template.outputAmounts ?? [],
        input.rootInput.inputQuality,
      );
      const productionRate =
        flow.type === "item"
          ? requiredProductionRate(flow, distribution)
          : flow.total;
      const entities = spec ? productionRate / spec.rate : 0;
      return {
        ...template,
        ratePerSecond: productionRate,
        requiredQuality: soleQuality(flow.amounts),
        requiredAmounts: copyQualityAmounts(flow.amounts),
        outputAmounts:
          flow.type === "item"
            ? distribution.map((entry) => ({
                ...entry,
                amount: entry.amount * productionRate,
              }))
            : undefined,
        entities,
        electricKw: spec ? Math.ceil(entities - EPSILON) * spec.electricKw : 0,
      };
    }

    function resizeRecipe(
      template: ProductionChainNode,
      flow?: MaterialFlow,
      forcedCalculatorInput?: CalculatorInput,
      forcedCalculationResult?: CalculationResult,
    ): ProductionChainNode {
      const mix = inputDistributions.get(template.key) ?? [
        { quality: input.rootInput.inputQuality, amount: 1 },
      ];
      const outputProfile = outputDistributions.get(template.key) ?? [
        { quality: input.rootInput.inputQuality, amount: 1 },
      ];
      const productionRate = flow
        ? requiredProductionRate(flow, outputProfile)
        : forcedCalculationResult?.ratePerSecond ?? template.ratePerSecond;
      const calculatorInput: CalculatorInput = forcedCalculatorInput ?? {
        ...template.calculatorInput,
        inputQualityDistribution: mix,
        outputQuality: "total",
        mode: "output",
        amount: productionRate,
        unit: "s",
        inputConstraints: [],
      };
      const result = forcedCalculationResult ?? calculate(calculatorInput);
      const logistics = calculateLogistics(
        result,
        calculatorInput,
        template.logisticsSettings,
      );
      const availableChildren = [...template.inputs];
      const inputs = result.inputs.map((childFlow) => {
        const childIndex = availableChildren.findIndex((child) =>
          child.kind === "source"
            ? child.id === childFlow.id && child.type === childFlow.type
            : child.product.id === childFlow.id && child.product.type === childFlow.type,
        );
        const childTemplate =
          childIndex >= 0 ? availableChildren.splice(childIndex, 1)[0] : undefined;
        if (!childTemplate) {
          return {
            kind: "source",
            key: `${template.key}/external:${childFlow.type}:${childFlow.id}`,
            id: childFlow.id,
            name: childFlow.name,
            icon: childFlow.icon,
            type: childFlow.type,
            ratePerSecond: childFlow.total,
            requiredQuality: soleQuality(childFlow.amounts),
            requiredAmounts: copyQualityAmounts(childFlow.amounts),
            entities: 0,
            entityName: "External source",
            electricKw: 0,
            includeEnergy: true,
            depth: template.depth + 1,
          } satisfies ChainSource;
        }
        return childTemplate.kind === "source"
          ? resizeSource(childTemplate, childFlow)
          : resizeRecipe(childTemplate, childFlow);
      });
      return {
        ...template,
        ratePerSecond: flow?.total ?? result.ratePerSecond,
        requiredQuality: flow ? soleQuality(flow.amounts) : undefined,
        requiredAmounts: flow ? copyQualityAmounts(flow.amounts) : undefined,
        calculatorInput,
        result,
        logistics,
        logisticsSettings: template.logisticsSettings,
        electricKw: result.electricTargetKw + logistics.electricKw,
        inputs,
      };
    }

    root = resizeRecipe(baseRoot, undefined, rootInput, rootResult);
  }

  const rawSources: ChainSource[] = [];
  let totalElectricKw = 0;
  let includedElectricKw = 0;
  function sum(node: ProductionChainNode | ChainSource) {
    totalElectricKw += node.electricKw;
    if (node.includeEnergy) includedElectricKw += node.electricKw;
    if (node.kind === "source") rawSources.push(node);
    else node.inputs.forEach(sum);
  }
  sum(root);
  return {
    root,
    rootInput,
    rootResult,
    qualityCoupled,
    totalElectricKw,
    includedElectricKw,
    rawSources,
    warnings,
  };
}

export function qualityMultiplier(id: QualityId): number {
  return QUALITY_BY_ID.get(id)?.multiplier ?? 1;
}
