use std::fs;
use std::path::Path;
use std::process::ExitCode;

use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};

use crate::db;
use crate::output::CommandReport;
use crate::paths::state::StateLayout;
use crate::profile::{self, ProfileName};
use crate::repo::marker as repo_marker;
use crate::state::{
    protected_write::{self, AppendWriteAuthority, AppendWriteOutcome},
    session as session_state,
};

#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct EscalationStateFile {
    pub(crate) schema_version: u32,
    pub(crate) entries: Vec<EscalationEntry>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct EscalationEntry {
    pub(crate) id: String,
    pub(crate) kind: EscalationKind,
    pub(crate) reason: String,
    pub(crate) created_at_epoch_s: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) session_id: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum EscalationKind {
    Blocking,
    NonBlocking,
}

impl EscalationKind {
    fn as_str(self) -> &'static str {
        match self {
            Self::Blocking => "blocking",
            Self::NonBlocking => "non_blocking",
        }
    }
}

// --- View struct (for start/check/radar reports) ---

#[derive(Debug, Clone, Serialize)]
pub(crate) struct EscalationView {
    pub(crate) path: String,
    pub(crate) status: &'static str,
    pub(crate) blocking_count: usize,
    pub(crate) non_blocking_count: usize,
    pub(crate) entries: Vec<EscalationEntry>,
}

#[derive(Serialize)]
pub struct EscalationSetReport {
    command: &'static str,
    ok: bool,
    outcome: AppendWriteOutcome,
    profile: String,
    path: String,
    entry: EscalationEntry,
    view: EscalationView,
    #[serde(skip_serializing_if = "Option::is_none")]
    message: Option<String>,
}

#[derive(Serialize)]
pub struct EscalationClearReport {
    command: &'static str,
    ok: bool,
    outcome: AppendWriteOutcome,
    profile: String,
    path: String,
    cleared: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    cleared_id: Option<String>,
    view: EscalationView,
    #[serde(skip_serializing_if = "Option::is_none")]
    message: Option<String>,
}

#[derive(Serialize)]
pub struct EscalationListReport {
    command: &'static str,
    ok: bool,
    profile: String,
    path: String,
    view: EscalationView,
}

impl CommandReport for EscalationSetReport {
    fn exit_code(&self) -> ExitCode {
        if self.ok {
            ExitCode::SUCCESS
        } else {
            ExitCode::from(1)
        }
    }

    fn render_text(&self) {
        if !self.ok {
            println!(
                "{}",
                self.message
                    .as_deref()
                    .unwrap_or("Escalation write was rejected.")
            );
            return;
        }

        if self.outcome == AppendWriteOutcome::IdempotentNoop {
            println!(
                "Escalation {} already matches the recorded entry at {}; treated as idempotent noop.",
                self.entry.id, self.path
            );
            return;
        }

        println!(
            "Recorded {} escalation {} at {}.",
            self.entry.kind.as_str(),
            self.entry.id,
            self.path
        );
        if let Some(session_id) = &self.entry.session_id {
            println!("Bound to session: {session_id}");
        }
        println!(
            "Active escalations: {} blocking, {} non-blocking.",
            self.view.blocking_count, self.view.non_blocking_count
        );
        if self.view.blocking_count > 0 {
            println!("Blocking reasons:");
            for entry in blocking_entries(&self.view.entries) {
                println!("- {}: {}", entry.id, entry.reason);
            }
        }
    }
}

impl CommandReport for EscalationClearReport {
    fn exit_code(&self) -> ExitCode {
        if self.ok {
            ExitCode::SUCCESS
        } else {
            ExitCode::from(1)
        }
    }

    fn render_text(&self) {
        if !self.ok {
            println!(
                "{}",
                self.message
                    .as_deref()
                    .unwrap_or("Escalation clear was rejected.")
            );
            return;
        }

        if self.outcome == AppendWriteOutcome::IdempotentNoop && self.cleared_id.is_some() {
            println!(
                "Escalation entry {} is already absent at {}; treated as idempotent noop.",
                self.cleared_id.as_deref().unwrap_or("unknown"),
                self.path
            );
        } else if !self.cleared {
            println!("No active escalation state found at {}.", self.path);
        } else if self.cleared_id.is_some() {
            println!("Cleared escalation entry at {}.", self.path);
        } else {
            println!("Cleared all escalation entries at {}.", self.path);
        }
    }
}

impl CommandReport for EscalationListReport {
    fn exit_code(&self) -> ExitCode {
        ExitCode::SUCCESS
    }

    fn render_text(&self) {
        if self.view.entries.is_empty() {
            println!("No active escalations.");
            return;
        }

        println!(
            "Escalations at {} ({}: {} blocking, {} non-blocking):",
            self.path, self.view.status, self.view.blocking_count, self.view.non_blocking_count
        );
        for entry in &self.view.entries {
            let session = entry
                .session_id
                .as_deref()
                .map(|session_id| format!(" session {session_id}"))
                .unwrap_or_default();
            println!(
                "- [{}] {}{}: {}",
                entry.kind.as_str(),
                entry.id,
                session,
                entry.reason
            );
        }
    }
}

// --- DB helpers ---

/// Open the state DB and migrate any legacy `escalation.json`.
///
/// Uses `StateDb::open` with per-module migration rather than
/// `open_for_layout` to keep each module's migration self-contained.
///
/// Creates the DB if it does not exist. Use `try_open_escalation_db` for
/// read paths that should not create the DB when there is nothing to read.
fn open_escalation_db(layout: &StateLayout) -> Result<db::StateDb> {
    let pods_root = layout.pods_root();
    let db = db::StateDb::open(&layout.state_db_path(), Some(&pods_root))?;
    migrate_escalation_json(&db, layout)?;
    Ok(db)
}

/// Open the state DB only if it already exists or legacy JSON needs migration.
/// Returns `None` when neither is present, avoiding DB creation on read-only
/// clone-local trees that have no escalation data.
fn try_open_escalation_db(layout: &StateLayout) -> Result<Option<db::StateDb>> {
    db::StateDb::try_open_with_migration(
        layout,
        || layout.escalation_state_path().exists(),
        |db| migrate_escalation_json(db, layout),
    )
}

/// Import legacy `escalation.json` into the DB if it still exists.
/// Idempotent: uses INSERT OR IGNORE and renames the source file to
/// `.migrated` only after all inserts succeed.
fn migrate_escalation_json(db: &db::StateDb, layout: &StateLayout) -> Result<()> {
    let path = layout.escalation_state_path();
    let contents = match fs::read_to_string(&path) {
        Ok(c) => c,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
        Err(e) => return Err(e).with_context(|| format!("failed to read {}", path.display())),
    };

    let state: EscalationStateFile = serde_json::from_str(&contents)
        .with_context(|| format!("failed to parse {}", path.display()))?;

    for entry in &state.entries {
        db::escalation::insert_or_ignore(db.conn(), entry)?;
    }

    let mut migrated = path.as_os_str().to_owned();
    migrated.push(".migrated");
    fs::rename(&path, Path::new(&migrated))
        .with_context(|| format!("failed to rename {} to .migrated", path.display()))?;
    Ok(())
}

// --- Public query/command functions ---

pub(crate) fn load_for_layout(layout: &StateLayout) -> Result<Vec<EscalationEntry>> {
    load_from_db(try_open_escalation_db(layout)?.as_ref())
}

/// Variant of [`load_for_layout`] that reuses a caller-provided `StateDb`
/// handle. Pass `None` to get the same empty-list default without opening.
pub(crate) fn load_from_db(db: Option<&db::StateDb>) -> Result<Vec<EscalationEntry>> {
    match db {
        Some(db) => db::escalation::list(db.conn()),
        None => Ok(Vec::new()),
    }
}

pub fn set(
    repo_root: &Path,
    explicit_profile: Option<&str>,
    id: Option<&str>,
    write_options: protected_write::ExclusiveWriteOptions,
    kind: Option<EscalationKind>,
    reason: &str,
) -> Result<EscalationSetReport> {
    let profile = profile::resolve(explicit_profile)?;
    let layout = required_layout(repo_root, &profile)?;
    ensure_repo_linked(repo_root)?;
    let now = session_state::now_epoch_s()?;
    let tracked_session = session_state::load_for_layout(&layout)?;

    if let Some(conflict) = protected_write::authorize_append_surface_write(
        &layout,
        "escalation",
        &write_options,
        AppendWriteAuthority::OwnerOnly,
    )? {
        return Ok(EscalationSetReport {
            command: "escalation-state-set",
            ok: false,
            outcome: conflict.outcome,
            profile: profile.to_string(),
            path: layout.state_db_path().display().to_string(),
            entry: EscalationEntry {
                id: id.map(str::to_owned).unwrap_or_else(mint_escalation_id),
                kind: kind.unwrap_or(EscalationKind::Blocking),
                reason: reason.to_owned(),
                created_at_epoch_s: now,
                session_id: session_state::load_session_id(&layout)?,
            },
            view: build_view(&layout, &load_for_layout(&layout)?),
            message: Some(conflict.message),
        });
    }

    let autonomous_active = tracked_session.as_ref().is_some_and(|state| {
        state.lifecycle() == session_state::SessionLifecycle::Autonomous
            && !session_state::is_stale(state, now)
    });
    if autonomous_active && id.is_none() {
        return Ok(EscalationSetReport {
            command: "escalation-state-set",
            ok: false,
            outcome: AppendWriteOutcome::UnsupportedMultiwriter,
            profile: profile.to_string(),
            path: layout.state_db_path().display().to_string(),
            entry: EscalationEntry {
                id: mint_escalation_id(),
                kind: kind.unwrap_or(EscalationKind::Blocking),
                reason: reason.to_owned(),
                created_at_epoch_s: now,
                session_id: session_state::load_session_id(&layout)?,
            },
            view: build_view(&layout, &load_for_layout(&layout)?),
            message: Some(
                "`escalation` requires explicit `--id` for autonomous append semantics".to_owned(),
            ),
        });
    }

    let db = open_escalation_db(&layout)?;
    let session_id = session_state::load_session_id(&layout)?;
    let entry_id = id.map(str::to_owned).unwrap_or_else(mint_escalation_id);

    let entry = EscalationEntry {
        id: entry_id.clone(),
        kind: kind.unwrap_or(EscalationKind::Blocking),
        reason: reason.to_owned(),
        created_at_epoch_s: now,
        session_id,
    };
    let (entry, ok, outcome, message) = match db::escalation::get_by_id(db.conn(), &entry_id)? {
        Some(existing)
            if existing.kind == entry.kind
                && existing.reason == entry.reason
                && existing.session_id == entry.session_id =>
        {
            (existing, true, AppendWriteOutcome::IdempotentNoop, None)
        }
        Some(_) => (
            entry,
            false,
            AppendWriteOutcome::DuplicateIdConflict,
            Some(format!(
                "`escalation` id `{entry_id}` already exists with different content"
            )),
        ),
        None => {
            db::escalation::insert(db.conn(), &entry)?;
            (entry, true, AppendWriteOutcome::Applied, None)
        }
    };

    let entries = db::escalation::list(db.conn())?;
    let view = build_view(&layout, &entries);

    Ok(EscalationSetReport {
        command: "escalation-state-set",
        ok,
        outcome,
        profile: profile.to_string(),
        path: layout.state_db_path().display().to_string(),
        entry,
        view,
        message,
    })
}

pub fn clear(
    repo_root: &Path,
    explicit_profile: Option<&str>,
    id: Option<&str>,
    write_options: protected_write::ExclusiveWriteOptions,
) -> Result<EscalationClearReport> {
    let profile = profile::resolve(explicit_profile)?;
    let layout = required_layout(repo_root, &profile)?;
    ensure_repo_linked(repo_root)?;
    let db_path = layout.state_db_path().display().to_string();
    let tracked_session = session_state::load_for_layout(&layout)?;
    let autonomous_session = tracked_session
        .as_ref()
        .is_some_and(|state| state.lifecycle() == session_state::SessionLifecycle::Autonomous);

    if autonomous_session && id.is_none() {
        return Ok(EscalationClearReport {
            command: "escalation-state-clear",
            ok: false,
            outcome: AppendWriteOutcome::UnsupportedMultiwriter,
            profile: profile.to_string(),
            path: db_path,
            cleared: false,
            cleared_id: None,
            view: build_view(&layout, &load_for_layout(&layout)?),
            message: Some(
                "`escalation` clear-all is reserved for explicit session lifecycle transitions; clear by id or use `ccd session-state clear`"
                    .to_owned(),
            ),
        });
    }

    if autonomous_session {
        if let Some(conflict) = protected_write::authorize_append_surface_write(
            &layout,
            "escalation",
            &write_options,
            AppendWriteAuthority::OwnerOrSupervisor,
        )? {
            return Ok(EscalationClearReport {
                command: "escalation-state-clear",
                ok: false,
                outcome: conflict.outcome,
                profile: profile.to_string(),
                path: db_path,
                cleared: false,
                cleared_id: id.map(str::to_owned),
                view: build_view(&layout, &load_for_layout(&layout)?),
                message: Some(conflict.message),
            });
        }
    }

    let Some(db) = try_open_escalation_db(&layout)? else {
        return Ok(EscalationClearReport {
            command: "escalation-state-clear",
            ok: true,
            outcome: AppendWriteOutcome::IdempotentNoop,
            profile: profile.to_string(),
            path: db_path,
            cleared: false,
            cleared_id: id.map(str::to_owned),
            view: build_view(&layout, &[]),
            message: None,
        });
    };

    let (cleared, removed_id, outcome, message) = if let Some(id) = id {
        let found = db::escalation::delete_by_id(db.conn(), id)?;
        if !found {
            (
                false,
                Some(id.to_owned()),
                AppendWriteOutcome::IdempotentNoop,
                None,
            )
        } else {
            (true, Some(id.to_owned()), AppendWriteOutcome::Applied, None)
        }
    } else {
        let had_entries = !db::escalation::list(db.conn())?.is_empty();
        db::escalation::clear_all(db.conn())?;
        (
            had_entries,
            None,
            if had_entries {
                AppendWriteOutcome::Applied
            } else {
                AppendWriteOutcome::IdempotentNoop
            },
            None,
        )
    };

    let entries = db::escalation::list(db.conn())?;
    let view = build_view(&layout, &entries);

    Ok(EscalationClearReport {
        command: "escalation-state-clear",
        ok: true,
        outcome,
        profile: profile.to_string(),
        path: db_path,
        cleared,
        cleared_id: removed_id,
        view,
        message,
    })
}

pub fn list(repo_root: &Path, explicit_profile: Option<&str>) -> Result<EscalationListReport> {
    let profile = profile::resolve(explicit_profile)?;
    let layout = required_layout(repo_root, &profile)?;
    ensure_repo_linked(repo_root)?;

    let entries = match try_open_escalation_db(&layout)? {
        Some(db) => db::escalation::list(db.conn())?,
        None => Vec::new(),
    };
    let view = build_view(&layout, &entries);

    Ok(EscalationListReport {
        command: "escalation-state-list",
        ok: true,
        profile: profile.to_string(),
        path: layout.state_db_path().display().to_string(),
        view,
    })
}

// --- Query helpers ---

fn blocking_entries(entries: &[EscalationEntry]) -> Vec<&EscalationEntry> {
    entries
        .iter()
        .filter(|e| matches!(e.kind, EscalationKind::Blocking))
        .collect()
}

pub(crate) fn build_view(layout: &StateLayout, entries: &[EscalationEntry]) -> EscalationView {
    let path = layout.state_db_path();
    let blocking_count = entries
        .iter()
        .filter(|e| matches!(e.kind, EscalationKind::Blocking))
        .count();
    let non_blocking_count = entries.len() - blocking_count;
    let status = if entries.is_empty() {
        "empty"
    } else if blocking_count > 0 {
        "blocked"
    } else {
        "active"
    };
    EscalationView {
        path: path.display().to_string(),
        status,
        blocking_count,
        non_blocking_count,
        entries: entries.to_vec(),
    }
}

// --- Session lifecycle helpers ---

pub(crate) fn clear_all_for_session_boundary(layout: &StateLayout) -> Result<()> {
    if let Some(db) = try_open_escalation_db(layout)? {
        db::escalation::clear_all(db.conn())?;
    }
    Ok(())
}

/// Pure DB clear-all against a caller-provided shared `StateDb` handle.
/// Does NOT run legacy-JSON migration — callers that need it must invoke
/// [`migrate_legacy_on_shared_db`] first, OUTSIDE any enclosing SQL
/// transaction on the same handle.
///
/// This split exists because `migrate_escalation_json` renames
/// `escalation.json` to `.migrated` as a filesystem side effect. Inside a
/// `BEGIN`/`COMMIT` envelope, a later rollback would undo the row imports
/// while leaving the rename in place — a data-loss regression flagged by
/// the Phase B adversarial review. Keeping migration strictly before
/// `BEGIN` means the rename and the row imports are both outside the
/// transaction's rollback domain, and an idempotent re-run finds the DB
/// already populated.
pub(crate) fn clear_all_on_shared_db(shared_db: &db::StateDb) -> Result<()> {
    db::escalation::clear_all(shared_db.conn())?;
    Ok(())
}

/// Pure DB clear-non-blocking against a caller-provided shared `StateDb`
/// handle. Does NOT run migration — see [`clear_all_on_shared_db`] for the
/// migration-before-`BEGIN` contract.
pub(crate) fn clear_non_blocking_on_shared_db(shared_db: &db::StateDb) -> Result<()> {
    db::escalation::clear_non_blocking(shared_db.conn())?;
    Ok(())
}

/// Idempotent legacy-JSON migration against a caller-provided shared
/// handle. Must run BEFORE entering an enclosing SQL transaction on the
/// same handle so the `fs::rename(..., .migrated)` side effect stays
/// outside SQLite's rollback domain. Safe to call repeatedly: the existing
/// `INSERT OR IGNORE` guard in `migrate_escalation_json` and the
/// rename-only-after-success flow keep re-entry a no-op.
pub(crate) fn migrate_legacy_on_shared_db(
    shared_db: &db::StateDb,
    layout: &StateLayout,
) -> Result<()> {
    migrate_escalation_json(shared_db, layout)
}

// --- Internal helpers ---

fn mint_escalation_id() -> String {
    format!("esc_{}", ulid::Ulid::new())
}

fn required_layout(repo_root: &Path, profile: &ProfileName) -> Result<StateLayout> {
    let layout = StateLayout::resolve(repo_root, profile.clone())?;
    ensure_profile_exists(&layout)?;
    Ok(layout)
}

fn ensure_profile_exists(layout: &StateLayout) -> Result<()> {
    let profile_root = layout.profile_root();
    if profile_root.is_dir() {
        return Ok(());
    }

    bail!(
        "profile `{}` does not exist at {}; bootstrap it before using escalation-state",
        layout.profile(),
        profile_root.display()
    )
}

fn ensure_repo_linked(repo_root: &Path) -> Result<()> {
    let Some(_) = repo_marker::load(repo_root)? else {
        bail!(
            "repo is not linked: {} is missing; run `ccd attach --path {}` or `ccd link --path {}` first",
            repo_root.join(repo_marker::MARKER_FILE).display(),
            repo_root.display(),
            repo_root.display()
        );
    };

    Ok(())
}

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

    #[test]
    fn deserialize_escalation_state() {
        let json = r#"{
            "schema_version": 1,
            "entries": [
                {
                    "id": "esc_01ABC",
                    "kind": "blocking",
                    "reason": "need human review",
                    "created_at_epoch_s": 1000,
                    "session_id": "ses_01XYZ"
                }
            ]
        }"#;
        let state: EscalationStateFile = serde_json::from_str(json).unwrap();
        assert_eq!(state.schema_version, 1);
        assert_eq!(state.entries.len(), 1);
        assert_eq!(state.entries[0].kind, EscalationKind::Blocking);
        assert_eq!(state.entries[0].session_id.as_deref(), Some("ses_01XYZ"));
    }

    #[test]
    fn deserialize_non_blocking_kind() {
        let json = r#"{
            "schema_version": 1,
            "entries": [
                {
                    "id": "esc_02DEF",
                    "kind": "non_blocking",
                    "reason": "informational",
                    "created_at_epoch_s": 2000
                }
            ]
        }"#;
        let state: EscalationStateFile = serde_json::from_str(json).unwrap();
        assert_eq!(state.entries[0].kind, EscalationKind::NonBlocking);
        assert!(state.entries[0].session_id.is_none());
    }

    #[test]
    fn serialize_omits_none_session_id() {
        let entry = EscalationEntry {
            id: "esc_1".to_owned(),
            kind: EscalationKind::Blocking,
            reason: "test".to_owned(),
            created_at_epoch_s: 1000,
            session_id: None,
        };
        let json = serde_json::to_string(&entry).unwrap();
        assert!(!json.contains("session_id"));
    }

    #[test]
    fn build_view_empty_entries() {
        let layout = test_layout();
        let entries: Vec<EscalationEntry> = vec![];
        let view = build_view(&layout, &entries);
        assert_eq!(view.status, "empty");
        assert_eq!(view.blocking_count, 0);
        assert_eq!(view.non_blocking_count, 0);
    }

    #[test]
    fn build_view_with_blocking_entries() {
        let layout = test_layout();
        let entries = vec![
            EscalationEntry {
                id: "esc_1".to_owned(),
                kind: EscalationKind::Blocking,
                reason: "blocked".to_owned(),
                created_at_epoch_s: 1000,
                session_id: None,
            },
            EscalationEntry {
                id: "esc_2".to_owned(),
                kind: EscalationKind::NonBlocking,
                reason: "info".to_owned(),
                created_at_epoch_s: 2000,
                session_id: None,
            },
        ];
        let view = build_view(&layout, &entries);
        assert_eq!(view.status, "blocked");
        assert_eq!(view.blocking_count, 1);
        assert_eq!(view.non_blocking_count, 1);
    }

    #[test]
    fn build_view_active_non_blocking_only() {
        let layout = test_layout();
        let entries = vec![EscalationEntry {
            id: "esc_1".to_owned(),
            kind: EscalationKind::NonBlocking,
            reason: "info".to_owned(),
            created_at_epoch_s: 1000,
            session_id: None,
        }];
        let view = build_view(&layout, &entries);
        assert_eq!(view.status, "active");
        assert_eq!(view.blocking_count, 0);
        assert_eq!(view.non_blocking_count, 1);
    }

    #[test]
    fn load_returns_empty_without_creating_db() {
        let temp = tempfile::tempdir().unwrap();
        let layout = test_layout_at(temp.path());
        setup_dirs(&layout);

        // Neither state.db nor escalation.json exists
        let entries = load_for_layout(&layout).unwrap();
        assert!(entries.is_empty());

        // state.db must NOT have been created
        assert!(
            !layout.state_db_path().exists(),
            "read path should not create state.db"
        );
    }

    #[test]
    fn migrate_escalation_json_imports_and_renames() {
        let temp = tempfile::tempdir().unwrap();
        let layout = test_layout_at(temp.path());
        setup_dirs(&layout);

        fs::write(
            layout.escalation_state_path(),
            r#"{"schema_version":1,"entries":[{"id":"esc_MIG","kind":"blocking","reason":"test","created_at_epoch_s":1000}]}"#,
        ).unwrap();

        let db = db::StateDb::open(&layout.state_db_path(), None).unwrap();
        migrate_escalation_json(&db, &layout).unwrap();

        let entries = db::escalation::list(db.conn()).unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].id, "esc_MIG");

        // Source file renamed
        assert!(!layout.escalation_state_path().exists());
        let mut migrated = layout.escalation_state_path().as_os_str().to_owned();
        migrated.push(".migrated");
        assert!(Path::new(&migrated).exists());
    }

    #[test]
    fn migrate_escalation_json_is_idempotent() {
        let temp = tempfile::tempdir().unwrap();
        let layout = test_layout_at(temp.path());
        setup_dirs(&layout);

        fs::write(
            layout.escalation_state_path(),
            r#"{"schema_version":1,"entries":[{"id":"esc_IDEM","kind":"blocking","reason":"test","created_at_epoch_s":1000}]}"#,
        ).unwrap();

        let db = db::StateDb::open(&layout.state_db_path(), None).unwrap();
        migrate_escalation_json(&db, &layout).unwrap();

        // Restore the source file (simulates partial rename failure on retry)
        fs::write(
            layout.escalation_state_path(),
            r#"{"schema_version":1,"entries":[{"id":"esc_IDEM","kind":"blocking","reason":"test","created_at_epoch_s":1000}]}"#,
        ).unwrap();

        migrate_escalation_json(&db, &layout).unwrap();

        // Should not duplicate
        let entries = db::escalation::list(db.conn()).unwrap();
        assert_eq!(entries.len(), 1);
    }

    #[test]
    fn migrate_noop_when_no_legacy_file() {
        let temp = tempfile::tempdir().unwrap();
        let layout = test_layout_at(temp.path());
        setup_dirs(&layout);

        let db = db::StateDb::open(&layout.state_db_path(), None).unwrap();
        migrate_escalation_json(&db, &layout).unwrap();

        assert!(db::escalation::list(db.conn()).unwrap().is_empty());
    }

    #[test]
    fn session_boundary_clears_db() {
        let temp = tempfile::tempdir().unwrap();
        let layout = test_layout_at(temp.path());
        setup_dirs(&layout);

        let db = db::StateDb::open(&layout.state_db_path(), None).unwrap();
        db::escalation::insert(
            db.conn(),
            &EscalationEntry {
                id: "esc_SB".to_owned(),
                kind: EscalationKind::Blocking,
                reason: "test".to_owned(),
                created_at_epoch_s: 1000,
                session_id: None,
            },
        )
        .unwrap();
        drop(db);

        clear_all_for_session_boundary(&layout).unwrap();

        let db = db::StateDb::open(&layout.state_db_path(), None).unwrap();
        assert!(db::escalation::list(db.conn()).unwrap().is_empty());
    }

    #[test]
    fn stale_reset_keeps_blocking() {
        let temp = tempfile::tempdir().unwrap();
        let layout = test_layout_at(temp.path());
        setup_dirs(&layout);

        let db = db::StateDb::open(&layout.state_db_path(), None).unwrap();
        db::escalation::insert(
            db.conn(),
            &EscalationEntry {
                id: "esc_B".to_owned(),
                kind: EscalationKind::Blocking,
                reason: "critical".to_owned(),
                created_at_epoch_s: 1000,
                session_id: None,
            },
        )
        .unwrap();
        db::escalation::insert(
            db.conn(),
            &EscalationEntry {
                id: "esc_NB".to_owned(),
                kind: EscalationKind::NonBlocking,
                reason: "info".to_owned(),
                created_at_epoch_s: 2000,
                session_id: None,
            },
        )
        .unwrap();
        drop(db);

        // Open a shared handle the same way `session::start` does, then
        // apply the shared-db clear. Migration would already have run
        // outside BEGIN in the real flow; here no legacy JSON is involved
        // so that step is a no-op.
        let shared = db::StateDb::open(&layout.state_db_path(), None).unwrap();
        clear_non_blocking_on_shared_db(&shared).unwrap();
        drop(shared);

        let db = db::StateDb::open(&layout.state_db_path(), None).unwrap();
        let entries = db::escalation::list(db.conn()).unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].id, "esc_B");
    }

    // --- Test helpers ---

    use crate::profile::ProfileName;

    fn test_layout() -> StateLayout {
        let temp = tempfile::tempdir().unwrap();
        let layout = test_layout_at(temp.path());
        setup_dirs(&layout);
        layout
    }

    fn test_layout_at(temp: &Path) -> StateLayout {
        StateLayout::new(
            temp.join(".ccd"),
            temp.join("repo/.git/ccd"),
            ProfileName::new("main").expect("profile"),
        )
    }

    fn setup_dirs(layout: &StateLayout) {
        fs::create_dir_all(layout.clone_profile_root()).expect("clone profile root");
        fs::create_dir_all(layout.clone_runtime_state_root()).expect("clone runtime root");
    }
}
