// SPDX-License-Identifier: Apache-2.0
/**
 * # Component Upgrade Migration Rules
 *
 * ## Architecture Overview
 *
 * This module implements a **boundary-based upgrade migration planner** for Solo-managed Kubernetes
 * components (e.g., block-node). It determines the safest strategy for upgrading a component from
 * one version to another by analyzing which "boundary" versions are crossed during the upgrade.
 *
 * ### The Problem
 *
 * Some Kubernetes resources (like StatefulSets) have **immutable fields** that cannot be changed via
 * a simple `helm upgrade`. When a component's Helm chart introduces breaking changes to such fields
 * at a certain version, the existing StatefulSet must be deleted and recreated rather than upgraded
 * in-place. This module encodes knowledge of those breaking version boundaries and produces a
 * multi-step migration plan that the caller (e.g., `BlockNodeCommand`) can execute.
 *
 * ### Key Concepts
 *
 * - **Strategy**: Either `'in-place'` (Helm upgrade) or `'recreate'` (delete + reinstall).
 *   The `'in-place'` consumer also has a fallback: if `helm upgrade` fails with an immutable-field
 *   error, it automatically retries as `'recreate'`.
 *
 * - **Boundary**: A specific semver version at which the upgrade strategy changes. For example,
 *   if a future block-node release changes an immutable StatefulSet field, a boundary at that
 *   version with strategy `'recreate'` means: "any upgrade crossing this version requires recreation."
 *
 * - **Migration Plan**: An ordered list of `ComponentUpgradeMigrationStep` objects, each describing
 *   a segment of the upgrade path with its own strategy. For simple upgrades (no boundaries crossed),
 *   the plan is a single step. For complex upgrades that cross multiple boundaries with different
 *   strategies, the plan may have multiple steps.
 *
 * ### How the Planner Works (Algorithm)
 *
 * Given `currentVersion` → `targetVersion` for a component:
 *
 * 1. Load the component's migration config (boundaries + default strategy).
 * 2. Find all boundaries where `current < boundary.version <= target` (i.e., boundaries crossed
 *    during a forward upgrade).
 * 3. Sort crossed boundaries by version ascending.
 * 4. **Merge consecutive boundaries with the same strategy** — if boundaries at 1.0.0 and 2.0.0
 *    both require `'recreate'`, they collapse into one step that jumps straight to 2.0.0.
 * 5. Generate steps:
 *    - For each (reduced) boundary, create a step from the cursor to the boundary's version (or
 *      to the target version if it's the last boundary).
 *    - If the cursor hasn't reached the target after all boundaries, add a final step using
 *      the default strategy.
 *
 * ### Example
 *
 * **Current config** — `block-node` has a `'recreate'` boundary at `0.28.1`:
 *
 * - Upgrade 0.28.0 → 0.28.1: Boundary at 0.28.1 crossed → 1 step, `'recreate'` (delete + reinstall).
 * - Upgrade 0.28.0 → 0.35.0: Boundary at 0.28.1 crossed → 1 step, `'recreate'` (target used directly).
 * - Upgrade 0.28.1 → 0.35.0: Already past the boundary → 1 step, `'in-place'`.
 *
 * **Adding a future boundary** — if a future block-node release changes an immutable StatefulSet
 * field at v0.29.0, add an entry for it to the external override file at
 * `constants.UPGRADE_MIGRATIONS_FILE`:
 *
 * ```json
 * {
 *   "components": {
 *     "block-node": {
 *       "defaultStrategy": "in-place",
 *       "boundaries": [
 *         {
 *           "version": "0.28.1",
 *           "strategy": "recreate",
 *           "reason": "The 0.28.1 chart introduced blockNode.persistence.plugins; --reuse-values from 0.28.0 fails"
 *         },
 *         {
 *           "version": "0.29.0",
 *           "strategy": "recreate",
 *           "reason": "StatefulSet volumeClaimTemplates changed at 0.29.0; requires full recreate"
 *         }
 *       ]
 *     }
 *   }
 * }
 * ```
 *
 * With that second boundary added:
 *
 * - Upgrade 0.28.0 → 0.29.0: Both boundaries have `'recreate'` → merged into 1 step targeting 0.29.0.
 * - Upgrade 0.28.1 → 0.29.0: Only 0.29.0 boundary crossed → 1 step, `'recreate'`.
 * - Upgrade 0.29.0 → 0.30.5: No boundary crossed → 1 step, `'in-place'`.
 *
 * ### Configuration Loading
 *
 * The planner first tries to load an external JSON file at `constants.UPGRADE_MIGRATIONS_FILE`.
 * If it exists and parses correctly, it overrides the embedded defaults. If not found or on parse
 * error, a safe empty config is used (unknown components fall back to `'in-place'` with no
 * boundaries). Configuration is cached after the first load for performance.
 *
 * ### Consumer
 *
 * Currently consumed by `BlockNodeCommand` in `block-node.ts`:
 * - The "Plan block node upgrade migration" task calls `planUpgradeMigrationPath()`.
 * - The "Update block node chart" task iterates over the returned steps and executes each one:
 *   - `'recreate'` → uninstall chart, wait for pod termination, reinstall.
 *   - `'in-place'` → `helm upgrade`, with automatic fallback to `'recreate'` on failure.
 *
 * The architecture is generic (keyed by component name) so new components can be added
 * by extending the config without code changes.
 */
import fs from 'node:fs';
import * as constants from '../../core/constants.js';
import { SemanticVersion } from '../../business/utils/semantic-version.js';
/**
 * Static utility for loading component migration rules and planning upgrade paths.
 */
export class ComponentUpgradeMigrationRules {
    /**
     * Module-level cache for the loaded migration config. Once loaded (from file or embedded
     * defaults), the config is stored here to avoid redundant file I/O on subsequent calls.
     * Reset via `resetUpgradeMigrationConfigCache()` in tests.
     */
    static cachedConfig;
    /**
     * Resets the cached migration config. Intended for use in tests only.
     *
     * This allows tests to:
     * - Switch between different config files between test cases.
     * - Force re-loading to verify file parsing behavior.
     * - Ensure test isolation (no state leaks between test cases).
     */
    static resetCache() {
        ComponentUpgradeMigrationRules.cachedConfig = undefined;
    }
    /**
     * Plans the complete upgrade migration path for a component.
     *
     * This is the main entry point of the module. Given a component name, current version, and
     * target version, it returns an ordered list of migration steps that the caller should
     * execute sequentially to safely upgrade the component.
     *
     * ## Behavior by scenario
     *
     * ### Downgrade or same-version (current >= target)
     * Returns a single step using the component's default strategy. No boundary analysis is
     * performed since boundaries only apply to forward upgrades.
     *
     * ### Forward upgrade with no boundaries crossed
     * Returns a single step using the default strategy (typically `'in-place'`).
     *
     * ### Forward upgrade crossing one or more boundaries
     * Returns multiple steps, one per boundary group (after merging consecutive same-strategy
     * boundaries). The last boundary's step targets the final `targetVersion` (not the boundary
     * version itself). If there's remaining distance after all boundaries, a final default-
     * strategy step covers the gap.
     *
     * ## Example migration plans
     *
     * **Current config** — default `'in-place'`, `'recreate'` boundary at `0.28.1`:
     *
     * ```
     * planUpgradeMigrationPath('block-node', '0.28.0', '0.28.1')
     * → [{ from: '0.28.0', to: '0.28.1', strategy: 'recreate' }]
     * // Boundary at 0.28.1 crossed; step targets the final version directly.
     *
     * planUpgradeMigrationPath('block-node', '0.28.0', '0.35.0')
     * → [{ from: '0.28.0', to: '0.35.0', strategy: 'recreate' }]
     * // Boundary at 0.28.1 crossed; last boundary so step jumps to target (0.35.0).
     *
     * planUpgradeMigrationPath('block-node', '0.28.1', '0.35.0')
     * → [{ from: '0.28.1', to: '0.35.0', strategy: 'in-place' }]
     * // Already at/past 0.28.1; no boundary crossed → default in-place.
     * ```
     *
     * **Hypothetical addition** — adding a second `'recreate'` boundary at 0.29.0:
     *
     * ```
     * // Config: boundaries: [{ version: '0.28.1', recreate }, { version: '0.29.0', recreate }]
     *
     * planUpgradeMigrationPath('block-node', '0.28.0', '0.29.0')
     * → [{ from: '0.28.0', to: '0.29.0', strategy: 'recreate' }]
     * // Both boundaries have 'recreate' → merged into 1 step; last boundary targets final version.
     *
     * planUpgradeMigrationPath('block-node', '0.28.1', '0.29.0')
     * → [{ from: '0.28.1', to: '0.29.0', strategy: 'recreate' }]
     * // Only the 0.29.0 boundary crossed → 1 recreate step.
     * ```
     *
     * @param component - Component name (e.g., 'block-node'). Must match a key in the config.
     * @param currentVersion - The currently installed version (semver string).
     * @param targetVersion - The desired target version (semver string).
     * @returns Ordered array of migration steps to execute.
     */
    static planUpgradeMigrationPath(component, currentVersion, targetVersion) {
        // Normalize version strings to canonical semver (strips 'v' prefix, etc.)
        const normalizedCurrentVersion = new SemanticVersion(currentVersion).toString();
        const normalizedTargetVersion = new SemanticVersion(targetVersion).toString();
        const current = new SemanticVersion(normalizedCurrentVersion);
        const target = new SemanticVersion(normalizedTargetVersion);
        const componentConfig = ComponentUpgradeMigrationRules.getComponentConfig(component);
        const defaultExtraCommandArguments = componentConfig.defaultExtraCommandArgs || [];
        // Case 1: Downgrade or same-version — no boundary analysis needed.
        // Just return the default strategy. The caller may still perform the operation
        // (e.g., for re-applying config), but no special migration handling is required.
        if (!current.lessThan(target)) {
            return [
                {
                    fromVersion: normalizedCurrentVersion,
                    toVersion: normalizedTargetVersion,
                    strategy: componentConfig.defaultStrategy,
                    reason: 'No forward upgrade boundary crossing detected',
                    extraCommandArgs: defaultExtraCommandArguments,
                },
            ];
        }
        // Case 2: Forward upgrade — find which boundaries are crossed.
        const boundaries = ComponentUpgradeMigrationRules.findCrossedBoundaries(componentConfig, current, target);
        // Case 2a: No boundaries crossed — simple upgrade using the default strategy.
        if (boundaries.length === 0) {
            return [
                {
                    fromVersion: normalizedCurrentVersion,
                    toVersion: normalizedTargetVersion,
                    strategy: componentConfig.defaultStrategy,
                    reason: 'Default in-place upgrade path',
                    extraCommandArgs: defaultExtraCommandArguments,
                },
            ];
        }
        // Case 2b: One or more boundaries are crossed — build a multi-step migration plan.
        // Walk through the reduced boundaries in order, creating a step for each one.
        // The `cursor` tracks where we are in the version progression.
        const steps = [];
        let cursor = current;
        for (const [index, boundary] of boundaries.entries()) {
            const isLast = index === boundaries.length - 1;
            // For the last boundary, the step targets the final desired version (not the boundary
            // version itself). This avoids an unnecessary extra step from the last boundary to
            // the target version. For non-last boundaries, we step up to the boundary version.
            const stepTarget = isLast ? target : new SemanticVersion(boundary.version);
            // Skip if the cursor has already reached or passed this step's target.
            // This can happen when boundaries are close together or when the current version
            // is already at a boundary version.
            if (!cursor.lessThan(stepTarget)) {
                continue;
            }
            steps.push({
                fromVersion: cursor.toString(),
                toVersion: stepTarget.toString(),
                strategy: boundary.strategy,
                reason: boundary.reason,
                extraCommandArgs: boundary.extraCommandArgs || [],
            });
            cursor = stepTarget;
        }
        // If after processing all boundaries we still haven't reached the target version,
        // append a final step using the default strategy to cover the remaining distance.
        // This happens when the last boundary's version is before the target, AND the last
        // boundary wasn't the "isLast" boundary (which would have used the target directly).
        if (cursor.lessThan(target)) {
            steps.push({
                fromVersion: cursor.toString(),
                toVersion: target.toString(),
                strategy: componentConfig.defaultStrategy,
                reason: 'Default in-place upgrade path',
                extraCommandArgs: defaultExtraCommandArguments,
            });
        }
        return steps;
    }
    /**
     * Loads the component upgrade migration configuration.
     *
     * Loading priority:
     * 1. Return cached config if already loaded (performance optimization).
     * 2. Try to read and parse the external JSON override file at `constants.UPGRADE_MIGRATIONS_FILE`.
     *    This allows operators to customize migration rules without modifying source code.
     * 3. If the file doesn't exist or fails to parse, silently fall back to a safe empty config
     *    (`{components: {}}`). Unknown components then receive an `'in-place'` default with no boundaries.
     *
     * The fallback behavior is intentional: we never want a missing or malformed config file to
     * block upgrades entirely. The resource-backed defaults always provide a safe baseline.
     *
     * Historical note: before this refactor, the default config lived in the deleted
     * `DEFAULT_COMPONENT_UPGRADE_MIGRATION_CONFIG` constant. The contents now live in
     * `resources/component-upgrade-migrations.json` and are loaded dynamically at runtime.
     */
    static loadConfig() {
        if (ComponentUpgradeMigrationRules.cachedConfig) {
            return ComponentUpgradeMigrationRules.cachedConfig;
        }
        try {
            if (fs.existsSync(constants.UPGRADE_MIGRATIONS_FILE)) {
                const fileContent = fs.readFileSync(constants.UPGRADE_MIGRATIONS_FILE, 'utf8');
                const parsed = JSON.parse(fileContent);
                if (!parsed.components || typeof parsed.components !== 'object') {
                    throw new Error('Missing required migration field: components');
                }
                ComponentUpgradeMigrationRules.cachedConfig = parsed;
                return parsed;
            }
        }
        catch {
            // ignore and fallback to safe defaults
        }
        ComponentUpgradeMigrationRules.cachedConfig = { components: {} };
        return ComponentUpgradeMigrationRules.cachedConfig;
    }
    /**
     * Retrieves the migration config for a specific component by name.
     *
     * If the component has no entry in the config (e.g., a newly added component that hasn't
     * had any migration rules defined yet), returns a safe default: `'in-place'` strategy with
     * no boundaries. This means unknown components always get a simple Helm upgrade.
     *
     * @param component - The component name (e.g., 'block-node').
     * @returns The component's migration config, or a default no-op config.
     */
    static getComponentConfig(component) {
        const config = ComponentUpgradeMigrationRules.loadConfig();
        const componentConfig = config.components[component];
        if (componentConfig) {
            return componentConfig;
        }
        return {
            defaultStrategy: 'in-place',
            boundaries: [],
        };
    }
    /**
     * Finds all boundary rules that are "crossed" during a forward upgrade from `current` to `target`.
     *
     * A boundary is "crossed" when:
     *   current < boundary.version <= target
     *
     * This means:
     * - If you're already AT or ABOVE the boundary version, it's not crossed (you've already
     *   passed it in a previous upgrade).
     * - If the target is BELOW the boundary version, it's not crossed (you haven't reached it yet).
     *
     * After finding crossed boundaries, they are:
     * 1. Sorted ascending by version (so the earliest boundary is processed first).
     * 2. **Reduced (merged)**: consecutive boundaries with the SAME strategy are collapsed into
     *    one entry (keeping the later version). This optimization avoids unnecessary intermediate
     *    steps. For example, if boundaries at 0.28.0 and 0.30.0 both require `'recreate'`, there's
     *    no point recreating at 0.28.0 and then recreating again at 0.30.0 — we can skip straight
     *    to 0.30.0 with a single recreate.
     *
     * @param componentConfig - The component's migration config containing boundary rules.
     * @param current - The current installed version (parsed SemanticVersion<string>).
     * @param target - The desired target version (parsed SemanticVersion<string>).
     * @returns Sorted and reduced array of crossed boundary rules.
     */
    static findCrossedBoundaries(componentConfig, current, target) {
        // Step 1: Normalize all boundary versions and filter to those crossed during this upgrade.
        // A boundary is crossed when: currentVersion < boundaryVersion AND targetVersion >= boundaryVersion.
        const crossed = componentConfig.boundaries
            .map((boundary) => ({
            ...boundary,
            version: new SemanticVersion(boundary.version).toString(),
        }))
            .filter((boundary) => current.lessThan(new SemanticVersion(boundary.version)) &&
            target.greaterThanOrEqual(new SemanticVersion(boundary.version)))
            // Step 2: Sort crossed boundaries by version ascending so we process them in order.
            // eslint-disable-next-line unicorn/no-array-sort
            .sort((a, b) => new SemanticVersion(a.version).compare(new SemanticVersion(b.version)));
        // Step 3: Reduce (merge) consecutive boundaries with the same strategy.
        // This avoids redundant intermediate steps. For example, if we have:
        //   [{ version: '1.0.0', strategy: 'recreate' }, { version: '2.0.0', strategy: 'recreate' }]
        // We collapse them into just [{ version: '2.0.0', strategy: 'recreate' }] because there's
        // no value in recreating at 1.0.0 only to recreate again at 2.0.0.
        // However, if strategies differ (e.g., 'recreate' then 'in-place'), both are kept since
        // they represent genuinely different upgrade behavior.
        const reduced = [];
        for (const boundary of crossed) {
            const last = reduced.at(-1);
            if (last && last.strategy === boundary.strategy) {
                reduced[reduced.length - 1] = boundary;
            }
            else {
                reduced.push(boundary);
            }
        }
        return reduced;
    }
}
//# sourceMappingURL=component-upgrade-rules.js.map