//! The memory action each lifecycle event performs, and how it maps to an ICM
//! MCP tool call.

use serde_json::{Value, json};
use tracing::{debug, warn};

use crate::hook_run::mcp_client::McpHttpClient;
use crate::hook_run::{BundleRecallQuery, TagRecallQuery};

/// The keyword prefix under which tag-scoped memory is stored and recalled.
/// A memory written for tag `work-vpn` carries keyword `llmenv-tag:work-vpn`;
/// recalling that keyword (project-unfiltered) surfaces it from any project.
pub const TAG_KEYWORD_PREFIX: &str = "llmenv-tag:";

/// The `llmenv-tag:<tag>` keyword for a tag. The tag is assumed pre-validated
/// (see `hook_run::validate_tag`) so it contains no recall-query metacharacters.
#[must_use]
pub fn tag_keyword(tag: &str) -> String {
    format!("{TAG_KEYWORD_PREFIX}{tag}")
}

/// The keyword prefix under which bundle-scoped memory is stored and recalled.
/// A memory written for bundle `base` carries keyword `llmenv-bundle:base`;
/// recalling that keyword (project-unfiltered) surfaces it from any project.
pub const BUNDLE_KEYWORD_PREFIX: &str = "llmenv-bundle:";

/// The `llmenv-bundle:<bundle>` keyword for a bundle. The bundle name is
/// assumed pre-validated (see `hook_run::validate_bundle`) so it contains no
/// recall-query metacharacters.
#[must_use]
pub fn bundle_keyword(bundle: &str) -> String {
    format!("{BUNDLE_KEYWORD_PREFIX}{bundle}")
}

/// Internal: parse an `<!-- llmenv-<prefix>: X -->` HTML comment marker.
fn parse_marker<'a>(chunk: &'a str, prefix: &'a str) -> Option<&'a str> {
    chunk
        .split("<!--")
        .skip(1)
        .filter_map(|s| s.split("-->").next())
        .find_map(|s| s.trim().strip_prefix(prefix).map(str::trim))
        .filter(|v| !v.is_empty())
}

/// Parse `<!-- llmenv-type: X -->` marker from a context chunk (R1).
/// Returns the type value if found, `None` otherwise.
///
/// The marker is an HTML comment in the markdown context chunk:
/// ```markdown
/// <!-- llmenv-type: semantic -->
/// ```
/// Only the text between the comment delimiters is examined; nested `-->`
/// within value text is not supported (the first `-->` terminates the match).
#[must_use]
pub fn parse_type_marker(chunk: &str) -> Option<&str> {
    parse_marker(chunk, "llmenv-type:")
}

/// Parse `<!-- llmenv-importance: X -->` marker from a context chunk (R3).
/// Returns the importance value if found, `None` otherwise. Semantics and
/// performance are identical to [`parse_type_marker`].
#[must_use]
pub fn parse_importance_marker(chunk: &str) -> Option<&str> {
    parse_marker(chunk, "llmenv-importance:")
}

/// One memory action against the ICM MCP backend.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
    /// Inject the session wake-up pack (`icm_wake_up`).
    WakeUp,
    /// Inject recalled context for the active tags/project (`icm_memory_recall`).
    /// Project-scoped (cwd default) natural-language recall.
    Recall,
    /// Recall tag-scoped memory for one active tag (`icm_memory_recall`),
    /// **project-unfiltered** and keyed on `llmenv-tag:<tag>`. This is what
    /// makes memory stored under a tag in one project surface when the same tag
    /// activates in another (#197). One action is dispatched per active tag. The
    /// carried [`TagRecallQuery`] is the single source of the tag + keyword, so
    /// the keyword encoding never drifts between dispatch and the tool call.
    RecallTag(TagRecallQuery),
    /// Recall bundle-scoped memory for one active bundle (`icm_memory_recall`),
    /// **project-unfiltered** and keyed on `llmenv-bundle:<bundle>`. Mirrors
    /// `RecallTag` for bundles (#228): one action per active bundle, ensuring
    /// memory stored under a bundle in one project surfaces in another.
    RecallBundle(BundleRecallQuery),
    /// Best-effort store of the active scope context (`icm_memory_store`).
    Store,
}

impl Action {
    /// The ICM MCP tool this action calls.
    pub fn tool_name(&self) -> &'static str {
        match self {
            Action::WakeUp => "icm_wake_up",
            Action::Recall | Action::RecallTag(_) | Action::RecallBundle(_) => "icm_memory_recall",
            Action::Store => "icm_memory_store",
        }
    }

    /// Build the `arguments` object for this action's tool call. `query` is the
    /// recall query (active tags/project), `chunk` is the llmenv context chunk
    /// used as store content. Unused fields are ignored per action.
    ///
    /// `RecallTag` and `RecallBundle` pass `project: ""` to disable ICM's
    /// default cwd project filter (per the tool contract, an empty string
    /// searches all projects) and `keyword: llmenv-tag:<tag>` /
    /// `keyword: llmenv-bundle:<bundle>` to scope the recall.
    pub fn arguments(&self, query: &str, chunk: &str) -> Value {
        match self {
            Action::WakeUp => json!({}),
            Action::Recall => json!({ "query": query }),
            Action::RecallTag(q) => json!({
                "query": q.tag,
                "project": "",
                "keyword": q.keyword,
            }),
            Action::RecallBundle(q) => json!({
                "query": q.bundle,
                "project": "",
                "keyword": q.keyword,
            }),
            Action::Store => {
                let mut args = json!({ "content": chunk, "topic": "llmenv-scope-context" });
                if let Some(mtyp) = parse_type_marker(chunk) {
                    args["type"] = json!(mtyp);
                }
                if let Some(imp) = parse_importance_marker(chunk) {
                    args["importance"] = json!(imp);
                }
                args
            }
        }
    }

    /// Execute the action: call the tool and return its text result.
    ///
    /// # Errors
    /// Propagates any client/network error from `call_tool`.
    pub async fn run(
        &self,
        client: &McpHttpClient,
        query: &str,
        chunk: &str,
    ) -> anyhow::Result<String> {
        debug!(action = ?self, "dispatching MCP tool call");
        client
            .call_tool(self.tool_name(), self.arguments(query, chunk))
            .await
            .map_err(|e| {
                warn!(action = ?self, error = %e, "MCP tool call failed");
                e
            })
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
    use super::*;

    #[test]
    fn action_tool_name_mapping() {
        assert_eq!(Action::WakeUp.tool_name(), "icm_wake_up");
        assert_eq!(Action::Recall.tool_name(), "icm_memory_recall");
        assert_eq!(Action::Store.tool_name(), "icm_memory_store");
        assert_eq!(recall_bundle("base").tool_name(), "icm_memory_recall");
    }

    #[test]
    fn wakeup_arguments_are_empty_object() {
        let args = Action::WakeUp.arguments("query text", "chunk text");
        assert_eq!(args, serde_json::json!({}));
    }

    #[test]
    fn recall_arguments_carry_query() {
        let args = Action::Recall.arguments("rust, work", "chunk");
        assert_eq!(args["query"], serde_json::json!("rust, work"));
    }

    #[test]
    fn store_arguments_carry_content() {
        let args = Action::Store.arguments("query", "## llmenv context\n...");
        assert_eq!(args["content"], serde_json::json!("## llmenv context\n..."));
    }

    // ===== R1 / R3 marker parsing tests =====

    #[test]
    fn parse_type_marker_returns_type_when_present() {
        let chunk = "## context\n<!-- llmenv-type: semantic -->\nmore";
        assert_eq!(parse_type_marker(chunk), Some("semantic"));
    }

    #[test]
    fn parse_type_marker_returns_none_when_absent() {
        assert_eq!(parse_type_marker("## plain context"), None);
    }

    #[test]
    fn parse_type_marker_skips_empty_value() {
        let chunk = "<!-- llmenv-type: -->";
        assert_eq!(parse_type_marker(chunk), None);
    }

    #[test]
    fn parse_type_marker_handles_multiple_markers() {
        let chunk = "<!-- llmenv-type: episodic -->\n<!-- llmenv-importance: high -->";
        assert_eq!(parse_type_marker(chunk), Some("episodic"));
    }

    #[test]
    fn parse_importance_marker_returns_importance_when_present() {
        let chunk = "<!-- llmenv-importance: critical -->";
        assert_eq!(parse_importance_marker(chunk), Some("critical"));
    }

    #[test]
    fn parse_importance_marker_returns_none_when_absent() {
        assert_eq!(parse_importance_marker("no markers here"), None);
    }

    #[test]
    fn store_arguments_include_type_from_marker() {
        let chunk = "<!-- llmenv-type: procedural -->\ncontext";
        let args = Action::Store.arguments("query", chunk);
        assert_eq!(args["type"], serde_json::json!("procedural"));
        assert!(args.get("importance").is_none());
    }

    #[test]
    fn store_arguments_include_importance_from_marker() {
        let chunk = "<!-- llmenv-importance: critical -->\ncontext";
        let args = Action::Store.arguments("query", chunk);
        assert_eq!(args["importance"], serde_json::json!("critical"));
        assert!(args.get("type").is_none());
    }

    #[test]
    fn store_arguments_include_both_markers() {
        let chunk = "<!-- llmenv-type: semantic -->\n<!-- llmenv-importance: high -->";
        let args = Action::Store.arguments("query", chunk);
        assert_eq!(args["type"], serde_json::json!("semantic"));
        assert_eq!(args["importance"], serde_json::json!("high"));
    }

    #[test]
    fn store_arguments_no_markers_passthrough() {
        let chunk = "## context\nno markers";
        let args = Action::Store.arguments("query", chunk);
        assert!(args.get("type").is_none());
        assert!(args.get("importance").is_none());
        assert_eq!(args["content"], serde_json::json!(chunk));
    }

    #[test]
    fn store_arguments_carry_required_topic() {
        // icm_memory_store's schema requires "topic" alongside "content"; a
        // call missing it fails server-side with "missing required field: topic".
        let args = Action::Store.arguments("query", "chunk");
        assert_eq!(args["topic"], serde_json::json!("llmenv-scope-context"));
    }

    #[test]
    fn tag_keyword_prefixes_tag() {
        assert_eq!(tag_keyword("work-vpn"), "llmenv-tag:work-vpn");
        assert_eq!(tag_keyword("rust"), "llmenv-tag:rust");
    }

    fn recall_tag(tag: &str) -> Action {
        Action::RecallTag(TagRecallQuery {
            tag: tag.to_string(),
            keyword: tag_keyword(tag),
        })
    }

    fn recall_bundle(bundle: &str) -> Action {
        Action::RecallBundle(BundleRecallQuery {
            bundle: bundle.to_string(),
            keyword: bundle_keyword(bundle),
        })
    }

    #[test]
    fn recall_tag_tool_is_memory_recall() {
        assert_eq!(recall_tag("work-vpn").tool_name(), "icm_memory_recall");
    }

    #[test]
    fn bundle_keyword_prefixes_bundle() {
        assert_eq!(bundle_keyword("base"), "llmenv-bundle:base");
        assert_eq!(
            bundle_keyword("rust-defaults"),
            "llmenv-bundle:rust-defaults"
        );
    }

    #[test]
    fn recall_bundle_tool_is_memory_recall() {
        assert_eq!(recall_bundle("base").tool_name(), "icm_memory_recall");
    }

    #[test]
    fn recall_bundle_disables_project_filter() {
        let args = recall_bundle("base").arguments("ignored", "ignored");
        assert_eq!(
            args["project"],
            serde_json::json!(""),
            "project must be empty to search across all projects"
        );
    }

    #[test]
    fn recall_bundle_keys_on_llmenv_bundle_keyword() {
        let args = recall_bundle("base").arguments("ignored", "ignored");
        assert_eq!(
            args["keyword"],
            serde_json::json!("llmenv-bundle:base"),
            "recall must be keyed on the llmenv-bundle:<bundle> encoding"
        );
        assert_eq!(args["query"], serde_json::json!("base"));
    }

    #[test]
    fn recall_tag_disables_project_filter() {
        // The defining behavior of #197: tag-scoped recall must be
        // project-unfiltered so memory stored under the tag in one project
        // surfaces in another. An empty project string disables ICM's default
        // cwd filter.
        let args = recall_tag("work-vpn").arguments("ignored", "ignored");
        assert_eq!(
            args["project"],
            serde_json::json!(""),
            "project must be empty to search across all projects"
        );
    }

    #[test]
    fn recall_tag_keys_on_llmenv_tag_keyword() {
        let args = recall_tag("work-vpn").arguments("ignored", "ignored");
        assert_eq!(
            args["keyword"],
            serde_json::json!("llmenv-tag:work-vpn"),
            "recall must be keyed on the llmenv-tag:<tag> encoding"
        );
        assert_eq!(args["query"], serde_json::json!("work-vpn"));
    }

    // ===== Property tests for the tag keyword + RecallTag argument shape =====

    use proptest::prelude::*;

    /// A tag accepted by `hook_run::validate_tag`.
    fn valid_tag() -> impl Strategy<Value = String> {
        "[a-zA-Z0-9_-]{1,24}"
    }

    proptest! {
        // tag_keyword always prepends the prefix and preserves the tag exactly —
        // the keyword is `llmenv-tag:` + the unmodified tag for any valid input.
        #[test]
        fn prop_tag_keyword_is_prefix_plus_tag(tag in valid_tag()) {
            let kw = tag_keyword(&tag);
            prop_assert_eq!(&kw, &format!("{TAG_KEYWORD_PREFIX}{tag}"));
            prop_assert!(kw.starts_with(TAG_KEYWORD_PREFIX));
            prop_assert_eq!(&kw[TAG_KEYWORD_PREFIX.len()..], tag.as_str());
        }

        // RecallTag arguments are always exactly {query, project, keyword} with
        // query == tag, project == "" (cross-project), keyword == tag_keyword(tag).
        // The shape can't silently gain/lose a field or mis-bind a value.
        #[test]
        fn prop_recall_tag_arguments_shape(tag in valid_tag()) {
            let args = recall_tag(&tag).arguments("ignored", "ignored");
            let obj = args.as_object().expect("arguments must be a JSON object");
            prop_assert_eq!(obj.len(), 3, "exactly query/project/keyword");
            prop_assert_eq!(&obj["query"], &serde_json::json!(tag));
            prop_assert_eq!(&obj["project"], &serde_json::json!(""));
            prop_assert_eq!(&obj["keyword"], &serde_json::json!(tag_keyword(&tag)));
        }

        // bundle_keyword always prepends the bundle prefix and preserves the bundle
        // name exactly — the keyword is `llmenv-bundle:` + the unmodified bundle.
        #[test]
        fn prop_bundle_keyword_is_prefix_plus_bundle(bundle in valid_tag()) {
            let kw = bundle_keyword(&bundle);
            prop_assert_eq!(&kw, &format!("{BUNDLE_KEYWORD_PREFIX}{bundle}"));
            prop_assert!(kw.starts_with(BUNDLE_KEYWORD_PREFIX));
            prop_assert_eq!(&kw[BUNDLE_KEYWORD_PREFIX.len()..], bundle.as_str());
        }

        // RecallBundle arguments are always exactly {query, project, keyword} with
        // query == bundle, project == "" (cross-project), keyword == bundle_keyword(bundle).
        #[test]
        fn prop_recall_bundle_arguments_shape(bundle in valid_tag()) {
            let args = recall_bundle(&bundle).arguments("ignored", "ignored");
            let obj = args.as_object().expect("arguments must be a JSON object");
            prop_assert_eq!(obj.len(), 3, "exactly query/project/keyword");
            prop_assert_eq!(&obj["query"], &serde_json::json!(bundle));
            prop_assert_eq!(&obj["project"], &serde_json::json!(""));
            prop_assert_eq!(&obj["keyword"], &serde_json::json!(bundle_keyword(&bundle)));
        }
    }

    // ===== #593: PBT for marker parsers =====

    /// Arbitrary-looking string, heavy on angle brackets and dashes.
    fn arb_chunk() -> impl Strategy<Value = String> {
        prop_oneof![
            "[ -~]{0,60}",
            "<!-- llmenv-[a-z]+: [a-z]+ -->",
            "<!-- not-llmenv: value -->",
            "<!-- llmenv-type: -->",
            "<!-- llmenv-type:      -->",
        ]
    }

    proptest! {
        #[test]
        fn prop_parse_type_marker_never_panics(chunk in arb_chunk()) {
            let _ = parse_type_marker(&chunk);
        }

        #[test]
        fn prop_parse_importance_marker_never_panics(chunk in arb_chunk()) {
            let _ = parse_importance_marker(&chunk);
        }

        #[test]
        fn prop_parse_type_marker_idempotent(chunk in arb_chunk()) {
            let first = parse_type_marker(&chunk);
            let second = parse_type_marker(&chunk);
            prop_assert_eq!(first, second);
        }

        #[test]
        fn prop_parse_importance_marker_idempotent(chunk in arb_chunk()) {
            let first = parse_importance_marker(&chunk);
            let second = parse_importance_marker(&chunk);
            prop_assert_eq!(first, second);
        }
    }
}
