// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0

//! `cmd_promote` migration. See `cli::store` for the design pattern.
//!
//! ## Two axes of promotion
//!
//! - **Horizontal (default):** bump the memory's tier to `long`. Sets
//!   `expires_at = ""` to clear the inherited tier-default TTL.
//! - **Vertical (`--to-namespace`):** clone the memory into an ancestor
//!   namespace; the original is untouched, the tier is preserved.

use crate::cli::CliOutput;
use crate::cli::governance::{GovernanceOutcome, enforce as enforce_governance};
use crate::cli::helpers::id_short;
use crate::{db, identity, models, validate};
use anyhow::Result;
use clap::Args;
use models::Tier;
use std::path::Path;

#[derive(Args)]
pub struct PromoteArgs {
    pub id: String,
    /// Task 1.7: clone this memory into a hierarchical-ancestor namespace
    /// (the original is untouched). Must be an ancestor of the memory's
    /// current namespace. Skips the tier bump — vertical promotion is a
    /// separate axis from tier promotion.
    #[arg(long)]
    pub to_namespace: Option<String>,
    /// #1623 (#831 parity): stop the tier bump at an intermediate tier
    /// ('mid' or 'long'). Omitting preserves the historical jump to
    /// long. 'short' is rejected (would be a downgrade). Mid landings
    /// keep the row's live TTL; long landings clear it.
    #[arg(long)]
    pub target_tier: Option<String>,
}

/// `promote` handler.
#[allow(clippy::too_many_lines)]
pub fn cmd_promote(
    db_path: &Path,
    args: &PromoteArgs,
    json_out: bool,
    cli_agent_id: Option<&str>,
    out: &mut CliOutput<'_>,
) -> Result<()> {
    validate::validate_id(&args.id)?;
    if let Some(ref to_ns) = args.to_namespace {
        validate::validate_namespace(to_ns)?;
    }
    let conn = db::open(db_path)?;
    let target = if let Some(m) = db::get(&conn, &args.id)? {
        m
    } else if let Some(m) = db::get_by_prefix(&conn, &args.id)? {
        m
    } else {
        writeln!(out.stderr, "{}", crate::errors::msg::not_found(&args.id))?;
        std::process::exit(1);
    };
    let resolved_id = target.id.clone();

    {
        use models::GovernedAction;
        let caller_agent_id = identity::resolve_agent_id(cli_agent_id, None)?;
        let mem_owner = target
            .metadata
            .get("agent_id")
            .and_then(|v| v.as_str())
            .map(str::to_string);
        let payload = serde_json::json!({
            "id": resolved_id,
            (crate::models::field_names::TO_NAMESPACE): args.to_namespace,
        });
        match enforce_governance(
            &conn,
            GovernedAction::Promote,
            &target.namespace,
            &caller_agent_id,
            Some(&resolved_id),
            mem_owner.as_deref(),
            &payload,
            json_out,
            out,
        )? {
            GovernanceOutcome::Allow => {}
            GovernanceOutcome::Deny => {
                std::process::exit(1);
            }
            GovernanceOutcome::Pending => {
                return Ok(());
            }
        }
    }

    if let Some(ref to_ns) = args.to_namespace {
        let clone_id = db::promote_to_namespace(&conn, &resolved_id, to_ns)?;
        if json_out {
            writeln!(
                out.stdout,
                "{}",
                serde_json::to_string(&serde_json::json!({
                    "promoted": true,
                    "mode": "vertical",
                    "source_id": resolved_id,
                    "clone_id": clone_id,
                    (crate::models::field_names::TO_NAMESPACE): to_ns,
                }))?
            )?;
        } else {
            writeln!(
                out.stdout,
                "promoted (vertical): {} → {} (clone: {})",
                id_short(&resolved_id),
                to_ns,
                id_short(&clone_id),
            )?;
        }
        return Ok(());
    }

    // #1623 — resolve the landing tier (mirrors the MCP handler's
    // validation wording; 'short' refused as a downgrade).
    let landing = match args.target_tier.as_deref() {
        None => Tier::Long,
        Some("short") => anyhow::bail!(
            "target_tier 'short' is not a valid promote target (would be a downgrade)"
        ),
        Some(other) => Tier::from_str(other).ok_or_else(|| {
            anyhow::anyhow!("target_tier must be one of 'mid' or 'long' (got '{other}')")
        })?,
    };
    // Long is permanent → clear expiry (Some("")); mid keeps the live
    // TTL (None preserves), matching MCP semantics.
    let expires_arg: Option<&str> = match landing {
        Tier::Long => Some(""),
        Tier::Mid | Tier::Short => None,
    };
    let (found, _) = db::update(
        &conn,
        &resolved_id,
        None,
        None,
        Some(&landing),
        None,
        None,
        None,
        None,
        expires_arg,
        None,
    )?;
    if !found {
        writeln!(out.stderr, "{}", crate::errors::msg::not_found(&args.id))?;
        std::process::exit(1);
    }
    if json_out {
        writeln!(
            out.stdout,
            "{}",
            serde_json::json!({"promoted": true, "id": resolved_id, "tier": landing.as_str()})
        )?;
    } else {
        writeln!(
            out.stdout,
            "promoted to {}: {resolved_id}",
            landing.as_str()
        )?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::test_utils::{TestEnv, seed_memory};

    /// v0.7.0 K3 — pin Enforce so promote-Pending / promote-Deny
    /// scenarios still hit the strict path (Advisory is the new
    /// process default and would Allow). Holds the central
    /// gate-mode Mutex; see `cli::governance::tests` for the full
    /// rationale.
    fn pin_governance_enforce_for_test() -> std::sync::MutexGuard<'static, ()> {
        let guard = crate::config::lock_permissions_mode_for_test();
        crate::config::override_active_permissions_mode_for_test(
            crate::config::PermissionsMode::Enforce,
        );
        guard
    }

    fn promote_args(id: &str) -> PromoteArgs {
        PromoteArgs {
            id: id.to_string(),
            to_namespace: None,
            target_tier: None,
        }
    }

    fn seed_governance_policy(
        db_path: &Path,
        namespace: &str,
        promote_level: models::GovernanceLevel,
        owner_agent_id: &str,
    ) {
        use models::{ApproverType, CorePolicy, GovernanceLevel, GovernancePolicy};
        let policy = GovernancePolicy {
            core: CorePolicy {
                write: GovernanceLevel::Any,
                promote: promote_level,
                delete: GovernanceLevel::Owner,
                approver: ApproverType::Human,
                inherit: true,
                max_reflection_depth: None,
                required_scope: None,
            },
            ..Default::default()
        };
        let conn = db::open(db_path).unwrap();
        let now = chrono::Utc::now().to_rfc3339();
        let mut metadata = models::default_metadata();
        if let Some(obj) = metadata.as_object_mut() {
            obj.insert(
                "agent_id".to_string(),
                serde_json::Value::String(owner_agent_id.to_string()),
            );
            obj.insert(
                "governance".to_string(),
                serde_json::to_value(&policy).unwrap(),
            );
        }
        let standard = models::Memory {
            id: uuid::Uuid::new_v4().to_string(),
            tier: Tier::Long,
            namespace: format!("_standards-{namespace}"),
            title: format!("standard for {namespace}"),
            content: "policy".to_string(),
            tags: vec![],
            priority: 9,
            confidence: 1.0,
            source: "test".to_string(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now,
            last_accessed_at: None,
            expires_at: None,
            metadata,
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        let standard_id = db::insert(&conn, &standard).unwrap();
        db::set_namespace_standard(&conn, namespace, &standard_id, None).unwrap();
    }

    #[test]
    fn test_promote_horizontal_to_long() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let args = promote_args(&id);
        {
            let mut out = env.output();
            cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
        }
        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
        assert_eq!(v["promoted"].as_bool().unwrap(), true);
        assert_eq!(v["tier"].as_str().unwrap(), Tier::Long.as_str());
        let conn = db::open(&db).unwrap();
        let mem = db::get(&conn, &id).unwrap().unwrap();
        assert_eq!(mem.tier, Tier::Long);
    }

    #[test]
    fn test_promote_by_prefix() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let prefix = id[..8].to_string();
        let args = promote_args(&prefix);
        {
            let mut out = env.output();
            cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
        }
        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
        assert_eq!(v["id"].as_str().unwrap(), id);
    }

    #[test]
    fn test_promote_vertical_with_to_namespace() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        // Hierarchical namespaces use `/`. The memory in `parent/child`
        // can be promoted to ancestor `parent`.
        let id = seed_memory(&db, "parent/child", "tt", "cc");
        let mut args = promote_args(&id);
        args.to_namespace = Some("parent".to_string());
        {
            let mut out = env.output();
            cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
        }
        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
        assert_eq!(v["mode"].as_str().unwrap(), "vertical");
        assert!(v["clone_id"].is_string());
        assert_eq!(v["to_namespace"].as_str().unwrap(), "parent");
    }

    #[test]
    fn test_promote_vertical_invalid_namespace_validation_error() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let mut args = promote_args(&id);
        args.to_namespace = Some("has spaces".to_string());
        let mut out = env.output();
        let res = cmd_promote(&db, &args, false, Some("test-agent"), &mut out);
        assert!(res.is_err());
    }

    #[test]
    fn test_promote_governance_pending() {
        let _gate = pin_governance_enforce_for_test();
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "gov-promote-ns", "tt", "cc");
        seed_governance_policy(
            &db,
            "gov-promote-ns",
            models::GovernanceLevel::Approve,
            "alice",
        );
        let args = promote_args(&id);
        {
            let mut out = env.output();
            cmd_promote(&db, &args, true, Some("bob"), &mut out).unwrap();
        }
        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
        assert_eq!(v["status"].as_str().unwrap(), "pending");
        assert_eq!(v["action"].as_str().unwrap(), "promote");
        // Memory must NOT be promoted on Pending — tier still mid.
        let conn = db::open(&db).unwrap();
        let mem = db::get(&conn, &id).unwrap().unwrap();
        assert_eq!(mem.tier, Tier::Mid);
    }

    #[test]
    fn test_promote_governance_deny() {
        let _gate = pin_governance_enforce_for_test();
        // The Deny branch in cmd_promote calls std::process::exit, which
        // tears down the test runner. The print-side of Deny is covered
        // by `cli::governance::tests::test_governance_deny_writes_reason_to_stderr`.
        // Here we exercise the helper directly with a Promote action against
        // an Owner-gated namespace and confirm the GovernanceOutcome::Deny
        // wiring + the literal stderr line cmd_promote would print.
        use crate::cli::governance::{GovernanceOutcome, enforce as enforce_governance};
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let conn = db::open(&db).unwrap();
        let now = chrono::Utc::now().to_rfc3339();
        let mut metadata = models::default_metadata();
        if let Some(obj) = metadata.as_object_mut() {
            obj.insert(
                "agent_id".to_string(),
                serde_json::Value::String("alice".to_string()),
            );
        }
        let mem = models::Memory {
            id: uuid::Uuid::new_v4().to_string(),
            tier: Tier::Mid,
            namespace: "deny-ns".to_string(),
            title: "tt".to_string(),
            content: "cc".to_string(),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "test".to_string(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now,
            last_accessed_at: None,
            expires_at: None,
            metadata,
            reflection_depth: 0,
            memory_kind: crate::models::MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
            lifecycle_state: crate::models::LifecycleState::Open,
        };
        let id = db::insert(&conn, &mem).unwrap();
        drop(conn);
        seed_governance_policy(&db, "deny-ns", models::GovernanceLevel::Owner, "alice");

        let conn = db::open(&db).unwrap();
        let payload = serde_json::json!({"id": id, "to_namespace": serde_json::Value::Null});
        let outcome = {
            let mut out = env.output();
            enforce_governance(
                &conn,
                models::GovernedAction::Promote,
                "deny-ns",
                "bob",
                Some(&id),
                Some("alice"),
                &payload,
                false,
                &mut out,
            )
            .unwrap()
        };
        assert_eq!(outcome, GovernanceOutcome::Deny);
        assert!(env.stderr_str().contains("promote denied by governance"));
    }

    // Nonexistent id triggers process::exit; covered by the integration
    // suite that spawns the binary. In-process the validate_id branch
    // proxies the not-found case for malformed inputs.
    #[test]
    fn test_promote_nonexistent_exits_nonzero() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        // Malformed id with a null byte hits validate_id before the
        // not-found exit branch — keeps the test in-process.
        let bad = "bad\0id".to_string();
        let args = promote_args(&bad);
        let mut out = env.output();
        let res = cmd_promote(&db, &args, false, Some("x"), &mut out);
        assert!(res.is_err());
    }
    #[test]
    fn promote_target_tier_mid_stops_at_mid_and_keeps_expiry_1623() {
        // #1623 — CLI parity with the MCP target_tier param (#831):
        // a mid landing stops at mid and PRESERVES the live TTL.
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt-1623", "cc");
        // seed_memory rows are tier=mid; downgrade to short first so the
        // mid landing is a genuine promotion.
        {
            let conn = crate::db::open(&db).unwrap();
            conn.execute(
                "UPDATE memories SET tier='short', expires_at='2099-01-01T00:00:00+00:00' WHERE id=?1",
                rusqlite::params![id],
            )
            .unwrap();
        }
        let mut args = promote_args(&id);
        args.target_tier = Some("mid".to_string());
        {
            let mut out = env.output();
            cmd_promote(&db, &args, true, Some("test-agent"), &mut out).unwrap();
        }
        let stdout = env.stdout_str();
        assert!(stdout.contains("\"tier\":\"mid\""), "got: {stdout}");
        let conn = crate::db::open(&db).unwrap();
        let (tier, exp): (String, Option<String>) = conn
            .query_row(
                "SELECT tier, expires_at FROM memories WHERE id=?1",
                rusqlite::params![id],
                |r| Ok((r.get(0)?, r.get(1)?)),
            )
            .unwrap();
        assert_eq!(tier, "mid", "#1623: must stop at mid");
        assert!(exp.is_some(), "#1623: mid landing must keep the live TTL");
    }

    #[test]
    fn promote_target_tier_short_rejected_1623() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt-1623b", "cc");
        let mut args = promote_args(&id);
        args.target_tier = Some("short".to_string());
        let mut out = env.output();
        let err = cmd_promote(&db, &args, false, Some("test-agent"), &mut out).unwrap_err();
        assert!(err.to_string().contains("downgrade"), "got: {err}");
    }
}
