"""Unit tests for `run_recipe_ci.resolve_upgrade_base` — the DYNAMIC upgrade-base decision (phase prevb).

Resolution order: upgrade∉stages / EXPECTED_NA[upgrade] (declared skip) → last-green (warm canonical,
with same-version step-back) → target-branch (`main`) tip → skip (no predecessor). The result is a
`BasePlan(kind, version, ref, reason)`: kind ∈ {"version", "ref", "skip"}; `.runs` is True for
version/ref (the upgrade tier runs). Replaces the old static `recipe_versions[-2]` default; the old
explicit `UPGRADE_BASE_VERSION` override knob was removed in phase canon (§2.G).
"""

from __future__ import annotations

import os
import sys
from types import SimpleNamespace

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
import run_recipe_ci  # noqa: E402
import warm_reconcile  # noqa: E402
from harness import canonical, lifecycle  # noqa: E402

ALL = {"install", "upgrade", "backup", "restore", "custom"}
HEAD = "aaaa1111head"
MAIN = "bbbb2222main"


@pytest.fixture(autouse=True)
def _default_flag_false(monkeypatch):
    # Hermetic: SKIP_CANONICALS_FOR_UPGRADE defaults to false regardless of any host settings.toml.
    # The few flag-true tests re-patch this within the test body.
    monkeypatch.setattr(
        run_recipe_ci.settings_mod,
        "get",
        lambda: SimpleNamespace(skip_canonicals_for_upgrade=False),
    )


def _set_flag(monkeypatch, value: bool):
    monkeypatch.setattr(
        run_recipe_ci.settings_mod,
        "get",
        lambda: SimpleNamespace(skip_canonicals_for_upgrade=value),
    )


def _meta(expected_na=None):
    return SimpleNamespace(EXPECTED_NA=expected_na)


def _no_canonical(monkeypatch):
    monkeypatch.setattr(canonical, "read_registry", lambda r: None)


def _no_main(monkeypatch):
    monkeypatch.setattr(lifecycle, "recipe_branch_commit", lambda r, b="main": None)


def test_upgrade_not_in_stages_skip(monkeypatch):
    # never consults canonical/main when upgrade isn't requested
    monkeypatch.setattr(
        canonical, "read_registry", lambda r: (_ for _ in ()).throw(AssertionError("not consulted"))
    )
    plan = run_recipe_ci.resolve_upgrade_base(ALL - {"upgrade"}, _meta(), "somerecipe")
    assert plan.kind == "skip" and not plan.runs


def test_expected_na_upgrade_skip_even_with_canonical(monkeypatch):
    # EXPECTED_NA[upgrade] is the strongest, declared fact — short-circuits before canonical.
    monkeypatch.setattr(
        canonical, "read_registry", lambda r: {"version": "9.9.9", "status": "warm"}
    )
    declared = {"upgrade": "no deployable upgrade base (moving tag republished)"}
    plan = run_recipe_ci.resolve_upgrade_base(ALL, _meta(expected_na=declared), "bluesky-pds")
    assert plan.kind == "skip" and "EXPECTED_NA" in plan.reason


def test_last_green_warm_canonical_is_primary(monkeypatch):
    # no override → last-green (warm canonical version) is the PRIMARY base; main is not consulted.
    monkeypatch.setattr(
        canonical, "read_registry", lambda r: {"version": "0.6.0+3.1.1", "status": "idle"}
    )
    monkeypatch.setattr(
        lifecycle,
        "recipe_branch_commit",
        lambda r, b="main": (_ for _ in ()).throw(AssertionError("not consulted")),
    )
    plan = run_recipe_ci.resolve_upgrade_base(ALL, _meta(), "keycloak", head_ref=HEAD)
    assert plan.kind == "version" and plan.version == "0.6.0+3.1.1" and "last-green" in plan.reason


def test_main_tip_fallback_when_no_last_green(monkeypatch):
    _no_canonical(monkeypatch)
    monkeypatch.setattr(lifecycle, "recipe_branch_commit", lambda r, b="main": MAIN)
    plan = run_recipe_ci.resolve_upgrade_base(ALL, _meta(), "discourse", head_ref=HEAD)
    assert plan.kind == "ref" and plan.ref == MAIN and plan.version is None and plan.runs


def test_head_equals_main_tip_skip(monkeypatch):
    # the PR head IS the main tip (empty PR / LATEST run) → no real predecessor → declared skip.
    _no_canonical(monkeypatch)
    monkeypatch.setattr(lifecycle, "recipe_branch_commit", lambda r, b="main": HEAD)
    plan = run_recipe_ci.resolve_upgrade_base(ALL, _meta(), "discourse", head_ref=HEAD)
    assert plan.kind == "skip" and "main tip" in plan.reason and not plan.runs


def test_no_canonical_no_main_skip(monkeypatch):
    _no_canonical(monkeypatch)
    _no_main(monkeypatch)
    plan = run_recipe_ci.resolve_upgrade_base(ALL, _meta(), "brandnew", head_ref=HEAD)
    assert plan.kind == "skip" and "no predecessor" in plan.reason


# --- phase samever: step back to an older base when canonical == head version ---

# A realistic keycloak tag list (coop-cloud "<recipe-semver>+<app-version>"), out of order on purpose
# so the test exercises the ordering, not list position.
KC_TAGS = ["10.6.0+26.5.0", "10.8.0+26.6.3", "10.7.1+26.6.2", "10.7.0+26.6.0", "not-a-version"]


def test_canonical_equals_head_steps_back_to_newest_older(monkeypatch):
    # nightly steady state: a green cold-on-latest run promoted canonical→latest, so the next night
    # finds canonical == head version. The resolver must NOT deploy the same version — it steps back
    # to the newest published version STRICTLY OLDER than the head.
    head_v = "10.8.0+26.6.3"  # == canonical == latest tag
    monkeypatch.setattr(canonical, "read_registry", lambda r: {"version": head_v, "status": "warm"})
    monkeypatch.setattr(warm_reconcile, "recipe_tags", lambda r: KC_TAGS)
    # main must never be consulted on the step-back (canonical path owns the decision)
    monkeypatch.setattr(
        lifecycle,
        "recipe_branch_commit",
        lambda r, b="main": (_ for _ in ()).throw(AssertionError("not consulted")),
    )
    plan = run_recipe_ci.resolve_upgrade_base(
        ALL, _meta(), "keycloak", head_ref=HEAD, head_version=head_v
    )
    assert plan.kind == "version" and plan.runs
    assert plan.version == "10.7.1+26.6.2"  # newest tag strictly older than 10.8.0+26.6.3
    # strictly older than head (the load-bearing invariant)
    assert warm_reconcile.version_key(plan.version) < warm_reconcile.version_key(head_v)
    assert "step-back" in plan.reason


def test_canonical_differs_from_head_uses_canonical_unchanged(monkeypatch):
    # the common version-bump case: canonical (older) ≠ head version → primary base unchanged; the
    # step-back never triggers, recipe_tags never consulted.
    monkeypatch.setattr(
        canonical, "read_registry", lambda r: {"version": "10.7.1+26.6.2", "status": "warm"}
    )
    monkeypatch.setattr(
        warm_reconcile,
        "recipe_tags",
        lambda r: (_ for _ in ()).throw(AssertionError("step-back not taken")),
    )
    plan = run_recipe_ci.resolve_upgrade_base(
        ALL, _meta(), "keycloak", head_ref=HEAD, head_version="10.8.0+26.6.3"
    )
    assert plan.kind == "version" and plan.version == "10.7.1+26.6.2"
    assert "last-green" in plan.reason


def test_canonical_equals_head_no_older_published_skips(monkeypatch):
    # canonical == head and it is the ONLY (oldest) published version → genuinely no predecessor →
    # declared skip with the samever reason (never a same-version no-op).
    head_v = "1.0.0+3.5.3"
    monkeypatch.setattr(canonical, "read_registry", lambda r: {"version": head_v, "status": "warm"})
    monkeypatch.setattr(warm_reconcile, "recipe_tags", lambda r: [head_v])  # only the head version
    plan = run_recipe_ci.resolve_upgrade_base(
        ALL, _meta(), "discourse", head_ref=HEAD, head_version=head_v
    )
    assert plan.kind == "skip" and not plan.runs
    assert "no older published predecessor" in plan.reason


def test_no_head_version_preserves_canonical_primary(monkeypatch):
    # head_version unreadable (None) → cannot compare → preserve prevb behavior: canonical is primary,
    # never a step-back (no regression for callers that don't pass head_version).
    monkeypatch.setattr(
        canonical, "read_registry", lambda r: {"version": "10.8.0+26.6.3", "status": "warm"}
    )
    monkeypatch.setattr(
        warm_reconcile,
        "recipe_tags",
        lambda r: (_ for _ in ()).throw(AssertionError("step-back not taken")),
    )
    plan = run_recipe_ci.resolve_upgrade_base(ALL, _meta(), "keycloak", head_ref=HEAD)
    assert plan.kind == "version" and plan.version == "10.8.0+26.6.3"


def test_newest_older_version_ordering():
    # the ordering helper picks the correct strictly-older tag and excludes the equal one.
    assert warm_reconcile.newest_older_version(KC_TAGS, "10.8.0+26.6.3") == "10.7.1+26.6.2"
    assert warm_reconcile.newest_older_version(KC_TAGS, "10.7.0+26.6.0") == "10.6.0+26.5.0"
    assert warm_reconcile.newest_older_version(KC_TAGS, "10.6.0+26.5.0") is None  # oldest → none
    assert warm_reconcile.newest_older_version(KC_TAGS, None) is None


def test_expected_na_other_rung_does_not_suppress_upgrade(monkeypatch):
    # an EXPECTED_NA for a DIFFERENT rung (backup_restore) must NOT short-circuit the upgrade base —
    # resolution proceeds to last-green/main-tip (custom-html-tiny shape).
    _no_canonical(monkeypatch)
    monkeypatch.setattr(lifecycle, "recipe_branch_commit", lambda r, b="main": MAIN)
    meta = _meta(expected_na={"backup_restore": "stateless"})
    plan = run_recipe_ci.resolve_upgrade_base(ALL, meta, "custom-html-tiny", head_ref=HEAD)
    assert plan.kind == "ref" and plan.ref == MAIN


# --- phase settings: improved no-canonical fallback (release tag before main-tip) + the flag ---


def test_no_canonical_prefers_release_tag_over_main_tip(monkeypatch):
    # flag false + NO canonical → the base must be the newest release TAG strictly older than head
    # (a real published predecessor), NOT the raw main-tip. main must not even be consulted.
    _no_canonical(monkeypatch)
    monkeypatch.setattr(warm_reconcile, "recipe_tags", lambda r: KC_TAGS)
    monkeypatch.setattr(
        lifecycle,
        "recipe_branch_commit",
        lambda r, b="main": (_ for _ in ()).throw(AssertionError("main consulted before tag")),
    )
    plan = run_recipe_ci.resolve_upgrade_base(
        ALL, _meta(), "keycloak", head_ref=HEAD, head_version="10.8.0+26.6.3"
    )
    assert plan.kind == "version" and plan.runs
    assert plan.version == "10.7.1+26.6.2"  # newest tag strictly older than head
    assert warm_reconcile.version_key(plan.version) < warm_reconcile.version_key("10.8.0+26.6.3")
    assert "no-canonical fallback" in plan.reason and "release tag" in plan.reason


def test_no_canonical_no_older_tag_falls_back_to_main_tip(monkeypatch):
    # flag false + no canonical + no release tag strictly older than head → raw main-tip is the
    # FURTHER fallback (a recipe whose only tag IS the head version, e.g. brand-new single release).
    _no_canonical(monkeypatch)
    monkeypatch.setattr(warm_reconcile, "recipe_tags", lambda r: ["10.8.0+26.6.3"])  # == head only
    monkeypatch.setattr(lifecycle, "recipe_branch_commit", lambda r, b="main": MAIN)
    plan = run_recipe_ci.resolve_upgrade_base(
        ALL, _meta(), "keycloak", head_ref=HEAD, head_version="10.8.0+26.6.3"
    )
    assert plan.kind == "ref" and plan.ref == MAIN and plan.version is None


def test_no_canonical_no_tag_no_main_skips(monkeypatch):
    # no canonical, no release tag, no main → declared skip (new recipe / no predecessor).
    _no_canonical(monkeypatch)
    monkeypatch.setattr(warm_reconcile, "recipe_tags", lambda r: [])
    _no_main(monkeypatch)
    plan = run_recipe_ci.resolve_upgrade_base(
        ALL, _meta(), "brandnew", head_ref=HEAD, head_version="1.0.0"
    )
    assert plan.kind == "skip" and not plan.runs and "no predecessor" in plan.reason


def test_no_head_version_skips_tag_lookup_uses_main(monkeypatch):
    # no canonical AND no head_version (unreadable) → cannot compare versions, so recipe_tags is NOT
    # consulted; fall straight through to main-tip (preserves prevb behavior for that caller shape).
    _no_canonical(monkeypatch)
    monkeypatch.setattr(
        warm_reconcile,
        "recipe_tags",
        lambda r: (_ for _ in ()).throw(AssertionError("tags consulted without head_version")),
    )
    monkeypatch.setattr(lifecycle, "recipe_branch_commit", lambda r, b="main": MAIN)
    plan = run_recipe_ci.resolve_upgrade_base(ALL, _meta(), "discourse", head_ref=HEAD)
    assert plan.kind == "ref" and plan.ref == MAIN


def test_flag_true_bypasses_canonical_into_release_tag_fallback(monkeypatch):
    # SKIP_CANONICALS_FOR_UPGRADE=true: a canonical-bearing recipe (canonical ≠ head, would normally
    # resolve to the canonical) instead bypasses the canonical entirely and takes the release-tag
    # fallback → newest release tag < head.
    _set_flag(monkeypatch, True)
    monkeypatch.setattr(
        canonical, "read_registry", lambda r: {"version": "10.6.0+26.5.0", "status": "warm"}
    )
    monkeypatch.setattr(warm_reconcile, "recipe_tags", lambda r: KC_TAGS)
    plan = run_recipe_ci.resolve_upgrade_base(
        ALL, _meta(), "keycloak", head_ref=HEAD, head_version="10.8.0+26.6.3"
    )
    assert plan.kind == "version" and plan.runs
    assert plan.version == "10.7.1+26.6.2"  # release tag, NOT the canonical 10.6.0+26.5.0
    assert "no-canonical fallback" in plan.reason


def test_flag_true_canonical_present_no_older_tag_uses_main(monkeypatch):
    # flag true bypasses the canonical; with no older release tag, the further fallback is main-tip
    # (proves the flag genuinely routes through the full no-canonical chain, not just step 1).
    _set_flag(monkeypatch, True)
    monkeypatch.setattr(
        canonical, "read_registry", lambda r: {"version": "10.8.0+26.6.3", "status": "warm"}
    )
    monkeypatch.setattr(warm_reconcile, "recipe_tags", lambda r: ["10.8.0+26.6.3"])  # == head only
    monkeypatch.setattr(lifecycle, "recipe_branch_commit", lambda r, b="main": MAIN)
    plan = run_recipe_ci.resolve_upgrade_base(
        ALL, _meta(), "keycloak", head_ref=HEAD, head_version="10.8.0+26.6.3"
    )
    assert plan.kind == "ref" and plan.ref == MAIN


def test_flag_false_canonical_present_unchanged(monkeypatch):
    # explicit guardrail check: flag false + canonical present (≠ head) → canonical, byte-for-byte the
    # prevb behavior; recipe_tags / main never consulted.
    _set_flag(monkeypatch, False)
    monkeypatch.setattr(
        canonical, "read_registry", lambda r: {"version": "10.7.1+26.6.2", "status": "warm"}
    )
    monkeypatch.setattr(
        warm_reconcile,
        "recipe_tags",
        lambda r: (_ for _ in ()).throw(AssertionError("fallback taken with canonical present")),
    )
    monkeypatch.setattr(
        lifecycle,
        "recipe_branch_commit",
        lambda r, b="main": (_ for _ in ()).throw(AssertionError("main consulted")),
    )
    plan = run_recipe_ci.resolve_upgrade_base(
        ALL, _meta(), "keycloak", head_ref=HEAD, head_version="10.8.0+26.6.3"
    )
    assert (
        plan.kind == "version" and plan.version == "10.7.1+26.6.2" and "last-green" in plan.reason
    )
