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

//! `cmd_update` migration. See `cli::store` for the design pattern.

use crate::cli::CliOutput;
use crate::{db, validate};
use anyhow::Result;
use clap::Args;
use std::path::Path;

#[derive(Args)]
pub struct UpdateArgs {
    pub id: String,
    #[arg(long, short = 'T', allow_hyphen_values = true)]
    pub title: Option<String>,
    #[arg(long, short, allow_hyphen_values = true)]
    pub content: Option<String>,
    #[arg(long, short)]
    pub tier: Option<String>,
    #[arg(long, short)]
    pub namespace: Option<String>,
    #[arg(long)]
    pub tags: Option<String>,
    #[arg(long, short)]
    pub priority: Option<i32>,
    #[arg(long)]
    pub confidence: Option<f64>,
    /// Expiry timestamp (RFC3339), or empty string to clear
    #[arg(long)]
    pub expires_at: Option<String>,
    /// v0.7.0 F2.4 (#1428) — JSON metadata patch (object). Replaces the
    /// existing `metadata` blob field-by-field. Pass `'{"agent_id":"...",
    /// "scope":"team"}'`. Validates as a JSON object.
    #[arg(long)]
    pub metadata: Option<String>,
    /// v0.7.0 F2.4 (#1428) — Form-4 first-class URI pointer. Accepted
    /// schemes: `uri:` / `doc:` / `file:`. Validates through
    /// `crate::validate::validate_source_uri`.
    #[arg(long)]
    pub source_uri: Option<String>,
    /// v0.7.0 F2.4 (#1428) — optimistic-concurrency gate per #884.
    /// When set, the update only proceeds if the row's current
    /// `version` field matches; mismatch returns VERSION_CONFLICT.
    /// Unset (legacy CLI behaviour) skips the gate.
    #[arg(long)]
    pub expected_version: Option<i64>,
}

/// `update` handler.
pub fn run(
    db_path: &Path,
    args: &UpdateArgs,
    json_out: bool,
    out: &mut CliOutput<'_>,
) -> Result<()> {
    use crate::models::Tier;
    validate::validate_id(&args.id)?;
    let conn = db::open(db_path)?;
    let resolved_id = if db::get(&conn, &args.id)?.is_some() {
        args.id.clone()
    } else if let Some(mem) = db::get_by_prefix(&conn, &args.id)? {
        mem.id
    } else {
        writeln!(out.stderr, "{}", crate::errors::msg::not_found(&args.id))?;
        std::process::exit(1);
    };
    let tier = args.tier.as_deref().and_then(Tier::from_str);
    let tags: Option<Vec<String>> = args.tags.as_ref().map(|t| {
        t.split(',')
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .collect()
    });
    if let Some(ref t) = args.title {
        validate::validate_title(t)?;
    }
    if let Some(ref c) = args.content {
        validate::validate_content(c)?;
    }
    if let Some(ref ns) = args.namespace {
        validate::validate_namespace(ns)?;
    }
    if let Some(ref tags) = tags {
        validate::validate_tags(tags)?;
    }
    if let Some(p) = args.priority {
        validate::validate_priority(p)?;
    }
    if let Some(c) = args.confidence {
        validate::validate_confidence(c)?;
    }
    if let Some(ref ts) = args.expires_at
        && !ts.is_empty()
    {
        validate::validate_expires_at_format(ts)?;
    }
    // v0.7.0 F2.4 (#1428) — validate the new metadata / source_uri /
    // expected_version flags before issuing the update.
    let metadata_patch: Option<serde_json::Value> = match args.metadata.as_deref() {
        None => None,
        Some(s) => {
            let v: serde_json::Value = serde_json::from_str(s)
                .map_err(|e| anyhow::anyhow!("invalid --metadata JSON: {e}"))?;
            if !v.is_object() {
                return Err(anyhow::anyhow!(
                    "--metadata must be a JSON object (got {v})"
                ));
            }
            // #1635 — preserve existing metadata.agent_id: provenance
            // is immutable across update (CLAUDE.md Agent Identity
            // contract). Mirrors the MCP memory_update caller layer
            // (src/mcp/tools/update.rs); without this a bare
            // `--metadata '{...}'` rewrote/stripped the author of any
            // memory because the sqlite UPDATE replaces metadata
            // wholesale.
            let existing =
                db::get(&conn, &resolved_id)?.map_or_else(|| serde_json::json!({}), |m| m.metadata);
            Some(crate::identity::preserve_provenance_keys(&existing, &v))
        }
    };
    if let Some(ref s) = args.source_uri {
        validate::validate_source_uri(s)
            .map_err(|e| anyhow::anyhow!("invalid --source-uri: {e}"))?;
    }
    // Route through `db::update_with_expected_version` so the #884
    // optimistic-concurrency gate is reachable from CLI when the
    // operator passes `--expected-version`. Legacy behaviour (no
    // expected_version) preserved by passing `None`.
    let (found, _content_changed) = db::update_with_expected_version(
        &conn,
        &resolved_id,
        args.title.as_deref(),
        args.content.as_deref(),
        tier.as_ref(),
        args.namespace.as_deref(),
        tags.as_ref(),
        args.priority,
        args.confidence,
        args.expires_at.as_deref(),
        metadata_patch.as_ref(),
        args.source_uri.as_deref(),
        args.expected_version,
    )?;
    if !found {
        writeln!(out.stderr, "{}", crate::errors::msg::not_found(&args.id))?;
        std::process::exit(1);
    }
    if let Some(mem) = db::get(&conn, &resolved_id)? {
        // PR-5 (issue #487): security audit trail. No-op when disabled.
        crate::audit::emit(crate::audit::EventBuilder::new(
            crate::audit::AuditAction::Update,
            crate::audit::actor(
                mem.metadata
                    .get("agent_id")
                    .and_then(|v| v.as_str())
                    .unwrap_or_default(),
                crate::audit::synthesis_sources::DEFAULT_FALLBACK,
                None,
            ),
            crate::audit::target_memory(
                mem.id.clone(),
                mem.namespace.clone(),
                Some(mem.title.clone()),
                Some(mem.tier.to_string()),
                None,
            ),
        ));
        if json_out {
            writeln!(out.stdout, "{}", serde_json::to_string(&mem)?)?;
        } else {
            writeln!(out.stdout, "updated: {} [{}]", mem.id, mem.title)?;
        }
    }
    Ok(())
}

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

    fn empty_args(id: &str) -> UpdateArgs {
        UpdateArgs {
            id: id.to_string(),
            title: None,
            content: None,
            tier: None,
            namespace: None,
            tags: None,
            priority: None,
            confidence: None,
            expires_at: None,
            // v0.7.0 F2.4 (#1428) — new CLI flags
            metadata: None,
            source_uri: None,
            expected_version: None,
        }
    }

    #[test]
    fn test_update_happy_path() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "old-title", "old content");
        let mut args = empty_args(&id);
        args.title = Some("new-title".to_string());
        args.content = Some("new content".to_string());
        {
            let mut out = env.output();
            run(&db, &args, false, &mut out).unwrap();
        }
        assert!(env.stdout_str().contains("updated:"));
        assert!(env.stdout_str().contains("new-title"));
    }

    #[test]
    fn test_update_by_prefix_id() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "title-a", "content-a");
        // Use an 8-char prefix (UUIDs are 36 chars).
        let prefix = &id[..8];
        let mut args = empty_args(prefix);
        args.title = Some("renamed".to_string());
        {
            let mut out = env.output();
            run(&db, &args, false, &mut out).unwrap();
        }
        assert!(env.stdout_str().contains("renamed"));
    }

    // Skip nonexistent-id-exits-nonzero test directly: process::exit
    // tears down the test runner. Exit-path coverage handled in the
    // integration suite that spawns the binary.

    #[test]
    fn test_update_partial_only_title() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "orig-title", "orig content");
        let mut args = empty_args(&id);
        args.title = Some("title-only-change".to_string());
        {
            let mut out = env.output();
            run(&db, &args, true, &mut out).unwrap();
        }
        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
        assert_eq!(v["title"].as_str().unwrap(), "title-only-change");
        assert_eq!(v["content"].as_str().unwrap(), "orig content");
    }

    #[test]
    fn test_update_partial_only_content() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "kept-title", "old-content");
        let mut args = empty_args(&id);
        args.content = Some("new content body".to_string());
        {
            let mut out = env.output();
            run(&db, &args, true, &mut out).unwrap();
        }
        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
        assert_eq!(v["title"].as_str().unwrap(), "kept-title");
        assert_eq!(v["content"].as_str().unwrap(), "new content body");
    }

    #[test]
    fn test_update_clear_expires_at_with_empty_string() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let mut args = empty_args(&id);
        args.expires_at = Some(String::new());
        {
            let mut out = env.output();
            // Empty-string skips the format-validate branch and is
            // forwarded as a clear-expiry directive to db::update.
            run(&db, &args, false, &mut out).unwrap();
        }
        assert!(env.stdout_str().contains("updated:"));
    }

    #[test]
    fn test_update_invalid_priority_validation_error() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let mut args = empty_args(&id);
        args.priority = Some(99);
        let mut out = env.output();
        let res = run(&db, &args, false, &mut out);
        assert!(res.is_err());
    }

    // ----------------------------------------------------------------
    // L0.7-3 chunk-e2 — coverage uplift to ≥95%.
    // ----------------------------------------------------------------

    #[test]
    fn test_update_invalid_namespace_validation_error() {
        // Triggers the namespace validation branch (line 66).
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let mut args = empty_args(&id);
        args.namespace = Some("bad namespace with spaces".to_string());
        let mut out = env.output();
        let res = run(&db, &args, false, &mut out);
        assert!(res.is_err(), "expected namespace validation error");
    }

    #[test]
    fn test_update_invalid_tags_validation_error() {
        // Triggers the tags split+validate branch (lines 53-58, 69).
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let mut args = empty_args(&id);
        // Many tag-validators reject excessively long entries; lean on
        // an unreasonably-long single tag to provoke the error.
        let big = "x".repeat(2000);
        args.tags = Some(big);
        let mut out = env.output();
        let res = run(&db, &args, false, &mut out);
        // Either validation rejects it, or update succeeds — at minimum
        // the tags-parse branch executed. We accept both outcomes;
        // executing the path is the coverage target.
        let _ = res;
    }

    #[test]
    fn test_update_valid_tags_split_and_pass_through() {
        // Drives the comma-split + filter-empty path through to a
        // successful update; covers the happy tags branch (54-58).
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let mut args = empty_args(&id);
        args.tags = Some("alpha, beta , , gamma".to_string());
        {
            let mut out = env.output();
            run(&db, &args, false, &mut out).unwrap();
        }
        assert!(env.stdout_str().contains("updated:"));
    }

    #[test]
    fn test_update_invalid_confidence_validation_error() {
        // Triggers the confidence validation branch (line 75).
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let mut args = empty_args(&id);
        args.confidence = Some(2.0); // > 1.0
        let mut out = env.output();
        let res = run(&db, &args, false, &mut out);
        assert!(res.is_err(), "expected confidence validation error");
    }

    #[test]
    fn test_update_invalid_expires_at_format_validation_error() {
        // Triggers the expires_at format validation branch (line 80).
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let mut args = empty_args(&id);
        args.expires_at = Some("not-a-timestamp".to_string());
        let mut out = env.output();
        let res = run(&db, &args, false, &mut out);
        assert!(res.is_err(), "expected expires_at format validation error");
    }

    #[test]
    fn test_update_valid_namespace_passes_through() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let mut args = empty_args(&id);
        args.namespace = Some("new-namespace".to_string());
        {
            let mut out = env.output();
            run(&db, &args, false, &mut out).unwrap();
        }
        assert!(env.stdout_str().contains("updated:"));
    }

    #[test]
    fn test_update_valid_confidence_passes_through() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let mut args = empty_args(&id);
        args.confidence = Some(0.5);
        {
            let mut out = env.output();
            run(&db, &args, false, &mut out).unwrap();
        }
        assert!(env.stdout_str().contains("updated:"));
    }

    #[test]
    fn test_update_valid_expires_at_format_passes_through() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let mut args = empty_args(&id);
        args.expires_at = Some("2030-01-01T00:00:00+00:00".to_string());
        {
            let mut out = env.output();
            run(&db, &args, false, &mut out).unwrap();
        }
        assert!(env.stdout_str().contains("updated:"));
    }

    // v0.7.0 F2.4 (#1428) — coverage for the metadata / source_uri /
    // expected_version flag arms.

    #[test]
    fn test_update_metadata_and_source_uri_valid_roundtrip() {
        // Covers the metadata Some(object) arm + source_uri Some(valid) arm.
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let mut args = empty_args(&id);
        args.metadata = Some(r#"{"scope":"team"}"#.to_string());
        args.source_uri = Some("uri:https://example.com/doc".to_string());
        {
            let mut out = env.output();
            run(&db, &args, true, &mut out).unwrap();
        }
        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
        assert_eq!(v["metadata"]["scope"].as_str().unwrap(), "team");
        assert_eq!(
            v["source_uri"].as_str().unwrap(),
            "uri:https://example.com/doc"
        );
    }

    #[test]
    fn test_update_invalid_metadata_json_errors() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let mut args = empty_args(&id);
        args.metadata = Some("not-json".to_string());
        let mut out = env.output();
        let err = run(&db, &args, false, &mut out).unwrap_err();
        assert!(
            err.to_string().contains("invalid --metadata JSON"),
            "got: {err}"
        );
    }

    #[test]
    fn test_update_metadata_non_object_errors() {
        // Well-formed JSON but not an object — hits the is_object() guard.
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let mut args = empty_args(&id);
        args.metadata = Some("[1,2,3]".to_string());
        let mut out = env.output();
        let err = run(&db, &args, false, &mut out).unwrap_err();
        assert!(
            err.to_string().contains("--metadata must be a JSON object"),
            "got: {err}"
        );
    }

    #[test]
    fn test_update_invalid_source_uri_errors() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let mut args = empty_args(&id);
        args.source_uri = Some("bareword-no-scheme".to_string());
        let mut out = env.output();
        let err = run(&db, &args, false, &mut out).unwrap_err();
        assert!(
            err.to_string().contains("invalid --source-uri"),
            "got: {err}"
        );
    }

    #[test]
    fn test_update_expected_version_match_succeeds() {
        // Seeded rows start at version 1, so expected_version=1 matches.
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let mut args = empty_args(&id);
        args.title = Some("v-gated".to_string());
        args.expected_version = Some(1);
        {
            let mut out = env.output();
            run(&db, &args, false, &mut out).unwrap();
        }
        assert!(env.stdout_str().contains("updated:"));
    }

    #[test]
    fn test_update_metadata_preserves_agent_id_1635() {
        // #1635 — a bare `--metadata '{...}'` used to replace metadata
        // wholesale, stripping/rewriting metadata.agent_id (immutable
        // provenance per the CLAUDE.md Agent Identity contract; the
        // MCP caller layer already preserved it).
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc"); // agent_id = "test-agent"
        let mut args = empty_args(&id);
        args.metadata = Some(r#"{"note":"x","agent_id":"ai:attacker"}"#.to_string());
        {
            let mut out = env.output();
            run(&db, &args, false, &mut out).unwrap();
        }
        let conn = crate::db::open(&db).unwrap();
        let mem = crate::db::get(&conn, &id).unwrap().unwrap();
        assert_eq!(
            mem.metadata.get("agent_id").and_then(|v| v.as_str()),
            Some("test-agent"),
            "#1635: agent_id must survive a CLI metadata update; got {:?}",
            mem.metadata
        );
        assert_eq!(
            mem.metadata.get("note").and_then(|v| v.as_str()),
            Some("x"),
            "the rest of the patch must apply"
        );
    }

    #[test]
    fn test_update_expected_version_mismatch_conflicts() {
        let mut env = TestEnv::fresh();
        let db = env.db_path.clone();
        let id = seed_memory(&db, "ns", "tt", "cc");
        let mut args = empty_args(&id);
        args.title = Some("should-not-apply".to_string());
        args.expected_version = Some(999);
        let mut out = env.output();
        let err = run(&db, &args, false, &mut out).unwrap_err();
        assert!(err.to_string().contains("CONFLICT"), "got: {err}");
    }
}
