//! Tier transition executor (ADR-033).
//!
//! Single Rust entry-point that materialises a tier change. Both the CLI
//! surface (`ontoref tier set <T>`) and the future tier-2 catalogued
//! operation route through [`execute`], satisfying the
//! `transition-tier-single-rust-function` Hard constraint.
//!
//! This module implements the **declarative effects** of the transition:
//! enforce the guard from [`crate::tier_guard`], atomically rewrite the
//! `ops.tier` value inside `.ontoref/config.ncl`, and leave the original
//! file untouched when any pre-write validation fails.
//!
//! Substrate effects (oplog bootstrap, Ed25519 key generation, witness
//! emission for tier-2 descent) are NOT part of this module yet — they
//! land in a follow-up integration with the substrate crates. The
//! per-effect catalog declared by ADR-033 expects each effect to register
//! its own compensating rollback action; this file establishes the
//! transactional skeleton (atomic file replace via tempfile + rename)
//! that those effects will plug into.
//!
//! Hard constraints satisfied here:
//!
//! - `transition-tier-uses-existing-guard` — [`execute`] invokes
//!   [`crate::tier_guard::enforce_tier_transition_guard`] as its first
//!   validator before any effect runs.
//! - `transition-tier-single-rust-function` — this is the single entry that
//!   both the CLI surface and the future catalogued op must call.
//! - `transition-tier-transactional-or-noop` — file replace happens via
//!   tempfile + atomic rename. On any pre-rewrite failure the original
//!   `.ontoref/config.ncl` is untouched. The atomicity tests in this module pin
//!   the property.
//!
//! Hard constraints DEFERRED to substrate integration:
//!
//! - `downgrade-preserves-substrate-immutable` — no substrate to preserve yet
//!   in this module. Will be verified when oplog/keys effects land.
//! - `tier-2-descent-emits-final-witness` — witness emission requires the
//!   keypair surface and the oplog writer; deferred.

use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

#[cfg(feature = "substrate")]
use ed25519_dalek::SigningKey;
use tempfile::NamedTempFile;

use crate::tier::{OpsConfig, Phase, Tier};
use crate::tier_guard::{enforce_tier_transition_guard, TierGuardError};

/// Inputs needed to execute a tier transition.
#[derive(Debug, Clone)]
pub struct TransitionContext {
    /// Consumer project root (the directory that contains `.ontoref/`).
    pub project_root: PathBuf,
    /// Ops state declared by the project's current `.ontoref/config.ncl`.
    pub current_ops: OpsConfig,
    /// Migration ids currently pending for this project. The guard
    /// enforces this list be empty when `target_tier != current_ops.tier`.
    pub pending_migrations: Vec<String>,
    /// Actor signing key used to emit the tier-descent witness when
    /// leaving Tier2 (ADR-036 `tier-descent-witness-on-tier2-leave`). The
    /// caller supplies it — the catalogued op forwards `OpContext.signing_key`,
    /// the daemon/CLI loads it via the keypair store — so this module stays
    /// free of key-file handling and the ontoref-ops → daemon layering holds.
    /// `None` is rejected on a Tier2 descent (the witness is mandatory).
    #[cfg(feature = "substrate")]
    pub descent_signing_key: Option<SigningKey>,
}

/// Outcome of a successful transition.
#[derive(Debug, Clone)]
pub struct TransitionOutcome {
    /// Tier value the project was on before the transition.
    pub from_tier: Tier,
    /// Tier value the project is on after the transition.
    pub to_tier: Tier,
    /// Phase value carried through (unchanged by this implementation —
    /// phase transitions are governed by a separate concern).
    pub phase: Phase,
    /// Whether the on-disk config file was actually rewritten. `false`
    /// when `from_tier == to_tier` (no-op case).
    pub config_rewritten: bool,
}

/// Failure modes emitted by [`execute`].
#[derive(Debug, thiserror::Error)]
pub enum TransitionError {
    /// The guard refused the transition.
    #[error("transition blocked by guard: {0}")]
    GuardRejected(#[from] TierGuardError),

    /// `.ontoref/config.ncl` does not exist or could not be read.
    #[error("config.ncl absent or unreadable at {path}: {source}")]
    ConfigMissing {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },

    /// The on-disk config does not contain a recognisable `ops.tier`
    /// declaration. ADR-029 `no-forced-tier-migration` requires the
    /// declaration to be a deliberate authored act; this implementation
    /// refuses to insert one synthetically.
    #[error("config.ncl at {path} lacks an `ops.tier` declaration — author it first")]
    OpsBlockAbsent { path: PathBuf },

    /// File IO failed mid-transition. The original file is preserved
    /// (the atomic rename did not occur) — caller may safely retry.
    #[error("IO failed during transition: {0}")]
    Io(#[from] std::io::Error),

    /// A substrate bootstrap effect (oplog creation, ADR-036) failed
    /// before the config was rewritten. The config file is untouched and
    /// any partially-created substrate has been rolled back, so the
    /// project remains cleanly on its original tier.
    #[cfg(feature = "substrate")]
    #[error("substrate bootstrap failed: {reason}")]
    SubstrateBootstrap {
        /// Human-readable reason the bootstrap aborted.
        reason: String,
    },

    /// Leaving Tier2 with substrate enabled requires emitting a
    /// `tier_descent` witness, and that step failed — no signing key was
    /// supplied, the oplog was absent, or the append failed. The config is
    /// untouched; the project stays on Tier2.
    #[cfg(feature = "substrate")]
    #[error("tier-descent witness failed: {reason}")]
    TierDescentWitness {
        /// Human-readable reason emission aborted.
        reason: String,
    },

    /// Ascending into Tier2 makes the Rust operation catalog authoritative,
    /// so the catalog is verified first; this is the incoherence that
    /// blocked the ascent (a duplicate op id or an op referencing an
    /// unregistered validator). The config is untouched.
    #[cfg(feature = "substrate")]
    #[error("catalog verification failed: {reason}")]
    CatalogVerification {
        /// The specific incoherence found.
        reason: String,
    },
}

/// Atomic NCL-config rewriter for the `ops.tier` field.
fn rewrite_config_tier(config_path: &Path, target: Tier) -> Result<(), TransitionError> {
    let original =
        fs::read_to_string(config_path).map_err(|source| TransitionError::ConfigMissing {
            path: config_path.to_path_buf(),
            source,
        })?;

    let target_atom = format!("'{target:?}");
    let rewritten = replace_tier_value(&original, &target_atom).ok_or_else(|| {
        TransitionError::OpsBlockAbsent {
            path: config_path.to_path_buf(),
        }
    })?;

    let parent = config_path
        .parent()
        .ok_or_else(|| TransitionError::Io(std::io::Error::other("config path has no parent")))?;
    let mut tmp = NamedTempFile::new_in(parent)?;
    tmp.write_all(rewritten.as_bytes())?;
    tmp.flush()?;
    tmp.persist(config_path)
        .map_err(|e| TransitionError::Io(e.error))?;
    Ok(())
}

/// Replace the first `tier = 'TierN` (or `tier  = 'TierN`) occurrence
/// with the target tier atom. Returns `None` when the pattern is absent.
fn replace_tier_value(source: &str, target_atom: &str) -> Option<String> {
    let bytes = source.as_bytes();
    (0..bytes.len()).find_map(|idx| try_replace_tier_at(source, bytes, idx, target_atom))
}

/// Attempt the `tier = 'Atom` replacement anchored at `idx`. Returns the
/// rewritten string when `idx` begins a well-formed `tier` binding,
/// `None` otherwise — guard clauses keep the scanner flat (one early
/// return per malformed shape) instead of deep nesting.
fn try_replace_tier_at(
    source: &str,
    bytes: &[u8],
    idx: usize,
    target_atom: &str,
) -> Option<String> {
    if !bytes[idx..].starts_with(b"tier") || idx + 4 >= bytes.len() {
        return None;
    }
    let prev_ok = idx == 0 || matches!(bytes[idx - 1], b' ' | b'\t' | b'\n' | b'{' | b',');
    let next_ok = matches!(bytes[idx + 4], b' ' | b'\t' | b'=');
    if !prev_ok || !next_ok {
        return None;
    }

    // Skip whitespace, require `=`, skip whitespace, require the `'` atom.
    let mut cursor = skip_ws(bytes, idx + 4);
    if cursor >= bytes.len() || bytes[cursor] != b'=' {
        return None;
    }
    cursor = skip_ws(bytes, cursor + 1);
    if cursor >= bytes.len() || bytes[cursor] != b'\'' {
        return None;
    }

    // Consume the atom (`'` + alnum/underscore run) and splice the target in.
    let atom_start = cursor;
    cursor += 1;
    while cursor < bytes.len() && (bytes[cursor].is_ascii_alphanumeric() || bytes[cursor] == b'_') {
        cursor += 1;
    }
    let mut out = String::with_capacity(source.len() + 8);
    out.push_str(&source[..atom_start]);
    out.push_str(target_atom);
    out.push_str(&source[cursor..]);
    Some(out)
}

/// Index of the first non-space/tab byte at or after `from`.
fn skip_ws(bytes: &[u8], from: usize) -> usize {
    let mut cursor = from;
    while cursor < bytes.len() && matches!(bytes[cursor], b' ' | b'\t') {
        cursor += 1;
    }
    cursor
}

/// Current epoch milliseconds. Used for transition-log filename + content.
/// Returns 0 if the system clock predates UNIX_EPOCH (won't happen on any
/// normal host; the fallback avoids unwrap panics).
fn now_unix_ms() -> u128 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis())
        .unwrap_or(0)
}

/// Write a record of a failed transition into
/// `.ontoref/artifacts/transition-log/<unix_ms>-failed.ncl` per ADR-033
/// `transition-failure-logged-not-silenced` (Soft).
///
/// Best-effort: if the artifacts directory cannot be created or the file
/// cannot be written, this function silently returns. The caller's
/// failure is already being reported via `Err(TransitionError)` — log
/// failure must not eclipse the original error.
///
/// The record MUST NOT contain sensitive substrate state (no private
/// keys, no secret values). Only the protocol-level fields named by the
/// constraint: requested target tier, original tier, failure source,
/// timestamp.
fn log_failed_transition(project_root: &Path, requested: Tier, original: Tier, source: &str) {
    let log_dir = project_root
        .join(".ontoref")
        .join("artifacts")
        .join("transition-log");
    if fs::create_dir_all(&log_dir).is_err() {
        return;
    }
    let ts = now_unix_ms();
    let log_path = log_dir.join(format!("{ts}-failed.ncl"));
    let body = format!(
        "{{\n  timestamp = {ts},\n  requested_tier = \"{requested:?}\",\n  original_tier = \
         \"{original:?}\",\n  source = \"{source}\",\n}}\n"
    );
    let _ = fs::write(log_path, body);
}

/// A compensating action recorded by a substrate effect, replayed in
/// reverse order when a later effect (or the final config rewrite) fails.
/// Best-effort by design: a compensation that itself fails must not mask
/// the original `TransitionError`.
#[cfg(feature = "substrate")]
#[derive(Debug)]
enum Compensation {
    /// Remove an oplog file this transition created, and its parent
    /// `substrate/` directory when this transition created that too. Only
    /// scheduled for substrate we created — pre-existing substrate is
    /// immutable on rollback (ADR-033
    /// `downgrade-preserves-substrate-immutable`).
    RemoveOplog {
        /// Path of the oplog file to remove.
        oplog_path: PathBuf,
        /// `substrate/` dir to remove if this transition created it.
        created_dir: Option<PathBuf>,
    },
}

#[cfg(feature = "substrate")]
impl Compensation {
    fn run(&self) {
        match self {
            Compensation::RemoveOplog {
                oplog_path,
                created_dir,
            } => {
                let _ = fs::remove_file(oplog_path);
                if let Some(dir) = created_dir {
                    // Only succeeds when empty — never clobbers a dir that
                    // gained other substrate artefacts in the meantime.
                    let _ = fs::remove_dir(dir);
                }
            }
        }
    }
}

/// Bootstrap the substrate for an upgrade that newly enters the Tier1+
/// regime (ADR-036 first effect). Creates `.ontoref/substrate/oplog.redb`,
/// initialising the redb tables via [`ontoref_oplog::OpLog::open`].
///
/// Idempotent: an oplog that already exists is reused and no compensation
/// is recorded for it — we never schedule deletion of substrate we did
/// not create (`downgrade-preserves-substrate-immutable`). Returns the
/// compensations to replay if a later effect fails; an empty vec when the
/// transition does not cross into the substrate regime.
///
/// # Errors
///
/// [`TransitionError::SubstrateBootstrap`] when the substrate directory
/// or the oplog file cannot be created.
#[cfg(feature = "substrate")]
fn bootstrap_substrate(
    project_root: &Path,
    from: Tier,
    to: Tier,
) -> Result<Vec<Compensation>, TransitionError> {
    // Only on an upgrade that newly enters the substrate regime. A
    // downgrade (from >= Tier1) preserves the substrate untouched.
    if from >= Tier::Tier1 || to < Tier::Tier1 {
        return Ok(Vec::new());
    }

    let substrate_dir = project_root.join(".ontoref").join("substrate");
    let created_dir = (!substrate_dir.exists()).then(|| substrate_dir.clone());
    fs::create_dir_all(&substrate_dir).map_err(|e| TransitionError::SubstrateBootstrap {
        reason: format!("creating substrate dir: {e}"),
    })?;

    let oplog_path = substrate_dir.join("oplog.redb");
    let already_present = oplog_path.exists();
    ontoref_oplog::OpLog::open(&oplog_path).map_err(|e| TransitionError::SubstrateBootstrap {
        reason: format!("opening oplog at {}: {e}", oplog_path.display()),
    })?;

    let mut compensations = Vec::new();
    if !already_present {
        compensations.push(Compensation::RemoveOplog {
            oplog_path,
            created_dir,
        });
    }
    Ok(compensations)
}

/// Verify the in-process operation catalog is coherent before Tier2 makes
/// the Rust substrate authoritative (ADR-036 catalog-verification effect):
/// every operation id is unique and every validator an operation declares
/// resolves to a registered validator. A pure check over the linked
/// inventory — no file IO, no NCL parse — so it stays in `ontoref-ops`.
///
/// # Errors
///
/// [`TransitionError::CatalogVerification`] on a duplicate op id or a
/// dangling validator reference.
#[cfg(feature = "substrate")]
fn verify_catalog_coherence() -> Result<(), TransitionError> {
    use std::collections::HashSet;

    let validators: HashSet<&str> = crate::validation::dispatch::registered_ids()
        .into_iter()
        .collect();
    let mut seen_ops: HashSet<&str> = HashSet::new();
    for entry in inventory::iter::<crate::registry::OperationEntry>() {
        if !seen_ops.insert(entry.id) {
            return Err(TransitionError::CatalogVerification {
                reason: format!("duplicate operation id '{}'", entry.id),
            });
        }
        for vid in entry
            .pre_validators
            .iter()
            .chain(entry.post_validators.iter())
        {
            if !vid.is_empty() && !validators.contains(vid) {
                return Err(TransitionError::CatalogVerification {
                    reason: format!(
                        "operation '{}' references unregistered validator '{vid}'",
                        entry.id
                    ),
                });
            }
        }
    }
    Ok(())
}

/// Emit the `tier_descent` witness to the project oplog as the final
/// signed entry before a Tier2 descent (ADR-036
/// `tier-descent-witness-on-tier2-leave`). The op's parents are the
/// current heads and its HLC ticks strictly past them, satisfying the
/// oplog's monotonicity invariant.
///
/// Append-only: this witness is never rolled back if the subsequent
/// config rewrite fails. It records the actor's signed intent to descend;
/// a failed rewrite leaves the project on Tier2 with that intent on
/// record (the witness chain accumulates monotonically per ADR-036).
///
/// # Errors
///
/// [`TransitionError::TierDescentWitness`] when the oplog is absent, heads
/// cannot be read, or the append fails.
#[cfg(feature = "substrate")]
fn emit_tier_descent_witness(
    project_root: &Path,
    from: Tier,
    to: Tier,
    signing_key: &SigningKey,
) -> Result<(), TransitionError> {
    use ontoref_types::operation::{OpBody, OpPayload, Operation};
    use ontoref_types::{Hlc, PublicKey};

    let descent_err = |reason: String| TransitionError::TierDescentWitness { reason };

    let oplog_path = project_root
        .join(".ontoref")
        .join("substrate")
        .join("oplog.redb");
    if !oplog_path.exists() {
        return Err(descent_err(format!(
            "oplog absent at {} — a Tier2 project must have a bootstrapped substrate",
            oplog_path.display()
        )));
    }
    let oplog = ontoref_oplog::OpLog::open(&oplog_path)
        .map_err(|e| descent_err(format!("opening oplog: {e}")))?;

    let parents = oplog
        .heads()
        .map_err(|e| descent_err(format!("reading oplog heads: {e}")))?;
    let mut max_parent_ts = Hlc::genesis();
    for parent in &parents {
        let parent_op = oplog
            .get(parent)
            .map_err(|e| descent_err(format!("loading parent {parent:?}: {e}")))?
            .ok_or_else(|| descent_err(format!("head {parent:?} absent from oplog")))?;
        if parent_op.body.timestamp > max_parent_ts {
            max_parent_ts = parent_op.body.timestamp;
        }
    }
    let now_physical = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
        .unwrap_or(0);

    let body = OpBody {
        actor: PublicKey::from_signing_key(signing_key),
        parents,
        payload: OpPayload::TierDescent {
            from_tier: format!("{from:?}"),
            to_tier: format!("{to:?}"),
        },
        timestamp: Hlc::tick(max_parent_ts, now_physical),
    };
    oplog
        .append(&Operation::sign(body, signing_key))
        .map_err(|e| descent_err(format!("oplog append: {e}")))?;
    Ok(())
}

/// Execute a tier transition. Returns `Ok(TransitionOutcome)` on success
/// (including the no-op same-tier case), `Err(TransitionError)` on
/// rejection or effect failure. The function is transactional with
/// respect to `.ontoref/config.ncl`: either the file reflects the new
/// tier OR it reflects the original tier; never an intermediate value.
/// Every error path also writes a record to
/// `.ontoref/artifacts/transition-log/<ts>-failed.ncl`.
pub fn execute(
    target_tier: Tier,
    ctx: TransitionContext,
) -> Result<TransitionOutcome, TransitionError> {
    let from_tier = ctx.current_ops.tier;

    let target_ops = OpsConfig {
        tier: target_tier,
        phase: ctx.current_ops.phase,
    };
    if let Err(e) =
        enforce_tier_transition_guard(&ctx.current_ops, &target_ops, &ctx.pending_migrations)
    {
        log_failed_transition(
            &ctx.project_root,
            target_tier,
            from_tier,
            &format!("guard_rejected: {e}").replace('"', "'"),
        );
        return Err(e.into());
    }

    if from_tier == target_tier {
        return Ok(TransitionOutcome {
            from_tier,
            to_tier: target_tier,
            phase: ctx.current_ops.phase,
            config_rewritten: false,
        });
    }

    // Substrate effects run before the config rewrite (ADR-036: the
    // atomic config replace is always the last effect). Each effect
    // records its compensation; if the rewrite fails we replay them in
    // reverse so the project stays cleanly on its original tier.
    #[cfg(feature = "substrate")]
    let compensations = match bootstrap_substrate(&ctx.project_root, from_tier, target_tier) {
        Ok(comps) => comps,
        Err(e) => {
            log_failed_transition(
                &ctx.project_root,
                target_tier,
                from_tier,
                &format!("substrate_bootstrap_failed: {e}").replace('"', "'"),
            );
            return Err(e);
        }
    };

    // Tier-descent witness (ADR-036): when leaving Tier2 with substrate
    // enabled, emit the final signed oplog entry BEFORE the config rewrite.
    // Append-only — not part of the rollback set below.
    #[cfg(feature = "substrate")]
    if from_tier == Tier::Tier2 && target_tier != Tier::Tier2 {
        let Some(signing_key) = ctx.descent_signing_key.as_ref() else {
            for comp in compensations.iter().rev() {
                comp.run();
            }
            let e = TransitionError::TierDescentWitness {
                reason: "Tier2 descent requires the actor signing key (supply it via the ops \
                         surface or the keypair store)"
                    .to_owned(),
            };
            log_failed_transition(
                &ctx.project_root,
                target_tier,
                from_tier,
                &format!("tier_descent_witness_failed: {e}").replace('"', "'"),
            );
            return Err(e);
        };
        if let Err(e) =
            emit_tier_descent_witness(&ctx.project_root, from_tier, target_tier, signing_key)
        {
            for comp in compensations.iter().rev() {
                comp.run();
            }
            log_failed_transition(
                &ctx.project_root,
                target_tier,
                from_tier,
                &format!("tier_descent_witness_failed: {e}").replace('"', "'"),
            );
            return Err(e);
        }
    }

    // Catalog verification (ADR-036): ascending into Tier2 makes the Rust
    // op catalog authoritative, so verify its coherence before the rewrite.
    // On failure, roll back any substrate this transition bootstrapped.
    #[cfg(feature = "substrate")]
    if target_tier == Tier::Tier2 && from_tier != Tier::Tier2 {
        if let Err(e) = verify_catalog_coherence() {
            for comp in compensations.iter().rev() {
                comp.run();
            }
            log_failed_transition(
                &ctx.project_root,
                target_tier,
                from_tier,
                &format!("catalog_verification_failed: {e}").replace('"', "'"),
            );
            return Err(e);
        }
    }

    let config_path = ctx.project_root.join(".ontoref").join("config.ncl");
    if let Err(e) = rewrite_config_tier(&config_path, target_tier) {
        #[cfg(feature = "substrate")]
        for comp in compensations.iter().rev() {
            comp.run();
        }
        log_failed_transition(
            &ctx.project_root,
            target_tier,
            from_tier,
            &format!("config_rewrite_failed: {e}").replace('"', "'"),
        );
        return Err(e);
    }

    Ok(TransitionOutcome {
        from_tier,
        to_tier: target_tier,
        phase: ctx.current_ops.phase,
        config_rewritten: true,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn write_config(dir: &Path, body: &str) -> PathBuf {
        let dot_ontoref = dir.join(".ontoref");
        fs::create_dir_all(&dot_ontoref).unwrap();
        let path = dot_ontoref.join("config.ncl");
        fs::write(&path, body).unwrap();
        path
    }

    fn ctx(project_root: PathBuf, tier: Tier, pending: Vec<String>) -> TransitionContext {
        TransitionContext {
            project_root,
            current_ops: OpsConfig {
                tier,
                phase: Phase::Progressive,
            },
            pending_migrations: pending,
            #[cfg(feature = "substrate")]
            descent_signing_key: None,
        }
    }

    #[test]
    fn same_tier_is_noop_no_file_touched() {
        let tmp = tempfile::tempdir().unwrap();
        let path = write_config(
            tmp.path(),
            "{ ops = { tier = 'Tier1, phase = 'Progressive }, }",
        );
        let before = fs::read_to_string(&path).unwrap();

        let outcome = execute(
            Tier::Tier1,
            ctx(tmp.path().to_path_buf(), Tier::Tier1, vec![]),
        )
        .unwrap();

        assert_eq!(outcome.from_tier, Tier::Tier1);
        assert_eq!(outcome.to_tier, Tier::Tier1);
        assert!(!outcome.config_rewritten);
        assert_eq!(fs::read_to_string(&path).unwrap(), before);
    }

    #[test]
    fn guard_blocks_when_migrations_pending() {
        let tmp = tempfile::tempdir().unwrap();
        let original = "{ ops = { tier = 'Tier0, phase = 'Progressive }, }";
        let path = write_config(tmp.path(), original);

        let err = execute(
            Tier::Tier1,
            ctx(
                tmp.path().to_path_buf(),
                Tier::Tier0,
                vec!["0023".to_string()],
            ),
        )
        .unwrap_err();

        assert!(matches!(err, TransitionError::GuardRejected(_)));
        // File untouched on guard rejection.
        assert_eq!(fs::read_to_string(&path).unwrap(), original);
    }

    #[test]
    fn successful_upgrade_atomically_rewrites_tier() {
        let tmp = tempfile::tempdir().unwrap();
        let original = "{\n  ops = { tier = 'Tier0, phase = 'Progressive },\n}\n";
        let path = write_config(tmp.path(), original);

        let outcome = execute(
            Tier::Tier1,
            ctx(tmp.path().to_path_buf(), Tier::Tier0, vec![]),
        )
        .unwrap();

        assert_eq!(outcome.from_tier, Tier::Tier0);
        assert_eq!(outcome.to_tier, Tier::Tier1);
        assert!(outcome.config_rewritten);

        let contents = fs::read_to_string(&path).unwrap();
        assert!(contents.contains("tier = 'Tier1"));
        assert!(!contents.contains("tier = 'Tier0"));
        assert!(contents.contains("phase = 'Progressive"));
    }

    #[test]
    fn config_missing_returns_explicit_error() {
        let tmp = tempfile::tempdir().unwrap();
        let err = execute(
            Tier::Tier1,
            ctx(tmp.path().to_path_buf(), Tier::Tier0, vec![]),
        )
        .unwrap_err();
        assert!(matches!(err, TransitionError::ConfigMissing { .. }));
    }

    #[test]
    fn ops_block_absent_returns_explicit_error() {
        let tmp = tempfile::tempdir().unwrap();
        let path = write_config(tmp.path(), "{ log = { level = \"info\" }, }");
        let original = fs::read_to_string(&path).unwrap();

        let err = execute(
            Tier::Tier1,
            ctx(tmp.path().to_path_buf(), Tier::Tier0, vec![]),
        )
        .unwrap_err();
        assert!(matches!(err, TransitionError::OpsBlockAbsent { .. }));
        assert_eq!(fs::read_to_string(&path).unwrap(), original);
    }

    #[test]
    fn downgrade_with_clean_migrations_succeeds() {
        let tmp = tempfile::tempdir().unwrap();
        let path = write_config(
            tmp.path(),
            "{ ops = { tier = 'Tier2, phase = 'Progressive }, }",
        );
        // Under substrate a Tier2 descent emits the mandatory witness, so
        // it needs a real oplog + an actor key; without the feature the
        // config rewrite is the whole effect.
        #[cfg(feature = "substrate")]
        bootstrap_oplog(tmp.path());
        #[cfg(feature = "substrate")]
        let transition = ctx_with_key(
            tmp.path().to_path_buf(),
            Tier::Tier2,
            ed25519_dalek::SigningKey::from_bytes(&[1u8; 32]),
        );
        #[cfg(not(feature = "substrate"))]
        let transition = ctx(tmp.path().to_path_buf(), Tier::Tier2, vec![]);

        let outcome = execute(Tier::Tier1, transition).unwrap();
        assert_eq!(outcome.from_tier, Tier::Tier2);
        assert_eq!(outcome.to_tier, Tier::Tier1);
        assert!(fs::read_to_string(&path).unwrap().contains("tier = 'Tier1"));
    }

    #[test]
    fn replace_tier_value_finds_pattern_with_extra_whitespace() {
        let input = "{ ops = { tier  =  'Tier0, phase = 'Progressive } }";
        let out = replace_tier_value(input, "'Tier2").unwrap();
        assert!(out.contains("'Tier2"));
        assert!(!out.contains("'Tier0"));
    }

    #[test]
    fn replace_tier_value_returns_none_when_absent() {
        let input = "{ log = { level = \"info\" } }";
        assert!(replace_tier_value(input, "'Tier1").is_none());
    }

    fn transition_log_entries(project_root: &Path) -> Vec<PathBuf> {
        let dir = project_root
            .join(".ontoref")
            .join("artifacts")
            .join("transition-log");
        if !dir.exists() {
            return Vec::new();
        }
        fs::read_dir(&dir)
            .map(|rd| {
                rd.filter_map(|e| e.ok().map(|e| e.path()))
                    .filter(|p| {
                        p.file_name()
                            .and_then(|n| n.to_str())
                            .is_some_and(|n| n.ends_with("-failed.ncl"))
                    })
                    .collect()
            })
            .unwrap_or_default()
    }

    #[test]
    fn guard_rejection_writes_transition_log() {
        let tmp = tempfile::tempdir().unwrap();
        write_config(
            tmp.path(),
            "{ ops = { tier = 'Tier0, phase = 'Progressive }, }",
        );

        let _err = execute(
            Tier::Tier1,
            ctx(
                tmp.path().to_path_buf(),
                Tier::Tier0,
                vec!["0023".to_string()],
            ),
        )
        .unwrap_err();

        let entries = transition_log_entries(tmp.path());
        assert_eq!(entries.len(), 1, "expected exactly one log entry");
        let body = fs::read_to_string(&entries[0]).unwrap();
        assert!(body.contains("requested_tier = \"Tier1\""));
        assert!(body.contains("original_tier = \"Tier0\""));
        assert!(body.contains("source = \"guard_rejected:"));
        assert!(body.contains("timestamp ="));
    }

    #[test]
    fn ops_block_absent_writes_transition_log() {
        let tmp = tempfile::tempdir().unwrap();
        write_config(tmp.path(), "{ log = { level = \"info\" }, }");

        let _err = execute(
            Tier::Tier1,
            ctx(tmp.path().to_path_buf(), Tier::Tier0, vec![]),
        )
        .unwrap_err();

        let entries = transition_log_entries(tmp.path());
        assert_eq!(entries.len(), 1, "expected log entry on ops-absent failure");
        let body = fs::read_to_string(&entries[0]).unwrap();
        assert!(body.contains("config_rewrite_failed:"));
    }

    #[test]
    fn successful_transition_writes_no_log() {
        let tmp = tempfile::tempdir().unwrap();
        write_config(
            tmp.path(),
            "{ ops = { tier = 'Tier0, phase = 'Progressive }, }",
        );

        execute(
            Tier::Tier1,
            ctx(tmp.path().to_path_buf(), Tier::Tier0, vec![]),
        )
        .unwrap();

        assert!(
            transition_log_entries(tmp.path()).is_empty(),
            "no log entries should exist on success"
        );
    }

    #[test]
    fn same_tier_noop_writes_no_log() {
        let tmp = tempfile::tempdir().unwrap();
        write_config(
            tmp.path(),
            "{ ops = { tier = 'Tier1, phase = 'Progressive }, }",
        );

        execute(
            Tier::Tier1,
            ctx(tmp.path().to_path_buf(), Tier::Tier1, vec![]),
        )
        .unwrap();

        assert!(
            transition_log_entries(tmp.path()).is_empty(),
            "no log entries should exist for same-tier no-op"
        );
    }

    #[test]
    fn transition_log_body_excludes_quotes_in_source() {
        // Pre-existing concern: the failure source string is wrapped in
        // NCL string quotes — embedded quote characters would break the
        // NCL parse. `log_failed_transition` replaces them with single
        // quotes to keep the NCL valid.
        let tmp = tempfile::tempdir().unwrap();
        write_config(
            tmp.path(),
            "{ ops = { tier = 'Tier0, phase = 'Progressive }, }",
        );
        let _ = execute(
            Tier::Tier2,
            ctx(
                tmp.path().to_path_buf(),
                Tier::Tier0,
                vec!["0023".to_string()],
            ),
        );
        let entries = transition_log_entries(tmp.path());
        let body = fs::read_to_string(&entries[0]).unwrap();
        // Should NOT contain an unescaped double-quote inside the source
        // field (only the surrounding quotes wrapping the value itself).
        let between_quotes = body
            .split("source = \"")
            .nth(1)
            .and_then(|s| s.split("\"").next())
            .unwrap_or("");
        assert!(
            !between_quotes.contains('"'),
            "source value must not contain bare double-quotes"
        );
    }

    #[cfg(feature = "substrate")]
    fn oplog_path(project_root: &Path) -> PathBuf {
        project_root
            .join(".ontoref")
            .join("substrate")
            .join("oplog.redb")
    }

    #[cfg(feature = "substrate")]
    #[test]
    fn upgrade_into_tier1_bootstraps_oplog() {
        let tmp = tempfile::tempdir().unwrap();
        let path = write_config(
            tmp.path(),
            "{ ops = { tier = 'Tier0, phase = 'Progressive }, }",
        );

        let outcome = execute(
            Tier::Tier1,
            ctx(tmp.path().to_path_buf(), Tier::Tier0, vec![]),
        )
        .unwrap();

        assert!(outcome.config_rewritten);
        assert!(
            oplog_path(tmp.path()).exists(),
            "crossing into Tier1 must create the oplog"
        );
        assert!(fs::read_to_string(&path).unwrap().contains("tier = 'Tier1"));
    }

    #[cfg(feature = "substrate")]
    #[test]
    fn failed_rewrite_rolls_back_bootstrapped_oplog() {
        let tmp = tempfile::tempdir().unwrap();
        // No `ops.tier` binding → rewrite fails AFTER bootstrap created
        // the oplog, exercising the compensating rollback.
        write_config(tmp.path(), "{ log = { level = \"info\" }, }");

        let err = execute(
            Tier::Tier1,
            ctx(tmp.path().to_path_buf(), Tier::Tier0, vec![]),
        )
        .unwrap_err();

        assert!(matches!(err, TransitionError::OpsBlockAbsent { .. }));
        assert!(
            !oplog_path(tmp.path()).exists(),
            "a failed rewrite must roll back the bootstrapped oplog"
        );
        assert!(
            !tmp.path().join(".ontoref").join("substrate").exists(),
            "the substrate dir this transition created must also be removed"
        );
    }

    #[cfg(feature = "substrate")]
    #[test]
    fn downgrade_preserves_existing_substrate() {
        let tmp = tempfile::tempdir().unwrap();
        write_config(
            tmp.path(),
            "{ ops = { tier = 'Tier2, phase = 'Progressive }, }",
        );
        // A real, pre-existing oplog. The downgrade appends a descent
        // witness but must NEVER delete the oplog (immutability) — bootstrap
        // is a no-op on descent and records no removal compensation.
        bootstrap_oplog(tmp.path());

        execute(
            Tier::Tier1,
            ctx_with_key(
                tmp.path().to_path_buf(),
                Tier::Tier2,
                ed25519_dalek::SigningKey::from_bytes(&[2u8; 32]),
            ),
        )
        .unwrap();

        assert!(
            oplog_path(tmp.path()).exists(),
            "downgrade-preserves-substrate-immutable — the oplog is never removed on descent"
        );
    }

    #[cfg(feature = "substrate")]
    #[test]
    fn bootstrap_is_idempotent_when_oplog_present() {
        let tmp = tempfile::tempdir().unwrap();
        write_config(
            tmp.path(),
            "{ ops = { tier = 'Tier0, phase = 'Progressive }, }",
        );
        // A real oplog already exists (e.g. from a prior upgrade attempt).
        let substrate_dir = tmp.path().join(".ontoref").join("substrate");
        fs::create_dir_all(&substrate_dir).unwrap();
        ontoref_oplog::OpLog::open(oplog_path(tmp.path())).unwrap();

        let outcome = execute(
            Tier::Tier1,
            ctx(tmp.path().to_path_buf(), Tier::Tier0, vec![]),
        )
        .unwrap();

        assert!(outcome.config_rewritten);
        assert!(
            oplog_path(tmp.path()).exists(),
            "an existing oplog is reused, never recreated or scheduled for deletion"
        );
    }

    #[cfg(feature = "substrate")]
    fn bootstrap_oplog(project_root: &Path) {
        let dir = project_root.join(".ontoref").join("substrate");
        fs::create_dir_all(&dir).unwrap();
        let _ = ontoref_oplog::OpLog::open(dir.join("oplog.redb")).unwrap();
    }

    #[cfg(feature = "substrate")]
    fn ctx_with_key(
        project_root: PathBuf,
        tier: Tier,
        key: ed25519_dalek::SigningKey,
    ) -> TransitionContext {
        TransitionContext {
            project_root,
            current_ops: OpsConfig {
                tier,
                phase: Phase::Progressive,
            },
            pending_migrations: vec![],
            descent_signing_key: Some(key),
        }
    }

    #[cfg(feature = "substrate")]
    #[test]
    fn tier2_descent_emits_tier_descent_witness() {
        use ontoref_types::operation::OpPayload;

        let tmp = tempfile::tempdir().unwrap();
        write_config(
            tmp.path(),
            "{ ops = { tier = 'Tier2, phase = 'Progressive }, }",
        );
        bootstrap_oplog(tmp.path());

        let key = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]);
        let outcome = execute(
            Tier::Tier1,
            ctx_with_key(tmp.path().to_path_buf(), Tier::Tier2, key),
        )
        .unwrap();
        assert!(outcome.config_rewritten);

        // Fresh handle: the descent witness is durable and verifiable.
        let reopened = ontoref_oplog::OpLog::open(oplog_path(tmp.path())).unwrap();
        assert_eq!(reopened.len().unwrap(), 1, "exactly one descent witness");
        let ops = reopened.all_ops_in_hlc_order().unwrap();
        ops[0].verify_signature().expect("descent witness verifies");
        assert!(
            matches!(ops[0].body.payload, OpPayload::TierDescent { .. }),
            "the entry must be a tier_descent payload"
        );
        if let OpPayload::TierDescent { from_tier, to_tier } = &ops[0].body.payload {
            assert_eq!(from_tier, "Tier2");
            assert_eq!(to_tier, "Tier1");
        }
    }

    #[cfg(feature = "substrate")]
    #[test]
    fn tier2_descent_without_key_fails_and_preserves_config() {
        let tmp = tempfile::tempdir().unwrap();
        let path = write_config(
            tmp.path(),
            "{ ops = { tier = 'Tier2, phase = 'Progressive }, }",
        );
        bootstrap_oplog(tmp.path());

        // ctx() supplies descent_signing_key = None.
        let err = execute(
            Tier::Tier1,
            ctx(tmp.path().to_path_buf(), Tier::Tier2, vec![]),
        )
        .unwrap_err();

        assert!(matches!(err, TransitionError::TierDescentWitness { .. }));
        assert!(
            fs::read_to_string(&path).unwrap().contains("tier = 'Tier2"),
            "config must stay on Tier2 when the mandatory witness cannot be emitted"
        );
    }

    #[cfg(feature = "substrate")]
    #[test]
    fn shipped_catalog_is_coherent() {
        // The real linked op catalog must pass verification — otherwise no
        // project could ever ascend to Tier2.
        verify_catalog_coherence().expect("shipped catalog must be coherent");
    }

    #[cfg(feature = "substrate")]
    #[test]
    fn tier1_to_tier2_ascent_verifies_catalog_and_succeeds() {
        let tmp = tempfile::tempdir().unwrap();
        let path = write_config(
            tmp.path(),
            "{ ops = { tier = 'Tier1, phase = 'Progressive }, }",
        );
        bootstrap_oplog(tmp.path());

        // Ascent into Tier2 (no descent witness, no key needed) — the
        // catalog-verification effect runs and the coherent catalog passes.
        let outcome = execute(
            Tier::Tier2,
            ctx(tmp.path().to_path_buf(), Tier::Tier1, vec![]),
        )
        .unwrap();
        assert_eq!(outcome.to_tier, Tier::Tier2);
        assert!(fs::read_to_string(&path).unwrap().contains("tier = 'Tier2"));
    }

    #[cfg(feature = "substrate")]
    #[test]
    fn non_tier2_leave_emits_no_descent_witness() {
        let tmp = tempfile::tempdir().unwrap();
        write_config(
            tmp.path(),
            "{ ops = { tier = 'Tier1, phase = 'Progressive }, }",
        );
        bootstrap_oplog(tmp.path());

        // Tier1 → Tier0: not a Tier2 descent, so no witness is required.
        execute(
            Tier::Tier0,
            ctx(tmp.path().to_path_buf(), Tier::Tier1, vec![]),
        )
        .unwrap();

        let reopened = ontoref_oplog::OpLog::open(oplog_path(tmp.path())).unwrap();
        assert_eq!(
            reopened.len().unwrap(),
            0,
            "leaving a non-Tier2 tier must not emit a descent witness"
        );
    }
}
