//! Event canonicalization and search/identity document extraction: maps raw
//! payloads to canonical event types, builds the FTS `SearchDocument`, derives
//! embedding units, and extracts file paths.

use crate::{
    sha256_hex, string_pointer, CanonicalType, EmbeddingUnit, EmbeddingUnitKind, Error, Result,
    Tool,
};
use serde_json::Value;
use std::collections::BTreeSet;

pub(crate) fn file_paths_for_payload(payload: &Value) -> Vec<String> {
    let mut paths = BTreeSet::new();
    collect_file_paths(payload, None, &mut paths);
    paths.into_iter().collect()
}

fn collect_file_paths(value: &Value, key: Option<&str>, output: &mut BTreeSet<String>) {
    match value {
        Value::String(text) => {
            if key.is_some_and(is_file_path_key) || looks_like_file_path(text) {
                let text = text.trim();
                if !text.is_empty() {
                    output.insert(text.to_string());
                }
            }
        }
        Value::Array(values) => {
            for value in values {
                collect_file_paths(value, key, output);
            }
        }
        Value::Object(map) => {
            for (key, value) in map {
                collect_file_paths(value, Some(key), output);
            }
        }
        _ => {}
    }
}

fn is_file_path_key(key: &str) -> bool {
    matches!(
        key,
        "file" | "file_path" | "filepath" | "path" | "source_path" | "transcript_path"
    ) || key.ends_with("_file")
        || key.ends_with("_path")
}

fn looks_like_file_path(value: &str) -> bool {
    let value = value.trim();
    value.starts_with('/')
        || value.starts_with("./")
        || value.starts_with("../")
        || value.starts_with("~/")
}

pub(crate) fn hook_event_name(payload: &Value) -> Result<&str> {
    payload
        .get("hook_event_name")
        .or_else(|| payload.get("event"))
        .or_else(|| payload.get("type"))
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| {
            Error::Validation(
                "payload.hook_event_name, payload.event, or payload.type is required".to_string(),
            )
        })
}

pub(crate) fn canonical_type_for_payload(
    tool: Tool,
    source_event_type: &str,
    payload: &Value,
) -> CanonicalType {
    if source_event_type == "MessageDisplay" {
        return if payload
            .get("final")
            .and_then(Value::as_bool)
            .unwrap_or(false)
        {
            CanonicalType::AssistantMessage
        } else {
            CanonicalType::AssistantDelta
        };
    }

    if tool == Tool::Codex {
        match source_event_type {
            "session_meta" => return CanonicalType::SessionStarted,
            "turn_context" => return CanonicalType::SessionResumed,
            "response_item" => {
                return match string_pointer(payload, "/payload/type").as_deref() {
                    Some("message") => match string_pointer(payload, "/payload/role").as_deref() {
                        Some("user") => CanonicalType::UserMessage,
                        Some("assistant") => CanonicalType::AssistantMessage,
                        _ => CanonicalType::Error,
                    },
                    Some("function_call") | Some("custom_tool_call") => CanonicalType::ToolCall,
                    Some("function_call_output") | Some("custom_tool_call_output") => {
                        CanonicalType::ToolResult
                    }
                    Some("reasoning") => CanonicalType::AssistantDelta,
                    _ => CanonicalType::Error,
                };
            }
            "event_msg" => {
                return match string_pointer(payload, "/payload/type").as_deref() {
                    Some("user_message") => CanonicalType::UserMessage,
                    Some("agent_message") => CanonicalType::AssistantMessage,
                    Some("agent_reasoning") => CanonicalType::AssistantDelta,
                    Some("exec_command_begin") | Some("tool_call") => CanonicalType::ToolCall,
                    Some("exec_command_end") | Some("tool_output") => CanonicalType::ToolResult,
                    _ => CanonicalType::Error,
                };
            }
            _ => {}
        }
    }

    match (tool, source_event_type) {
        (_, "SessionStart") | (Tool::Opencode, "session.created") => CanonicalType::SessionStarted,
        (Tool::Codex, "thread.started")
        | (Tool::Codex, "thread/started")
        | (Tool::Codex, "turn.started")
        | (Tool::Codex, "turn/started") => CanonicalType::SessionStarted,
        (_, "SessionEnd") | (_, "Stop") | (Tool::Opencode, "session.idle") => {
            CanonicalType::SessionEnded
        }
        (Tool::Codex, "turn.completed") | (Tool::Codex, "turn/completed") => {
            CanonicalType::SessionEnded
        }
        (_, "UserPromptSubmit") => CanonicalType::UserMessage,
        (Tool::Codex, "item/agentMessage/delta") => CanonicalType::AssistantDelta,
        (Tool::Codex, "item.completed") | (Tool::Codex, "item/completed") => {
            canonical_type_for_codex_item(payload).unwrap_or(CanonicalType::AssistantMessage)
        }
        (Tool::Opencode, "message.part.updated") => CanonicalType::AssistantDelta,
        (Tool::Opencode, "message.part.removed") => CanonicalType::AssistantDelta,
        (Tool::Opencode, "message.updated") => CanonicalType::AssistantMessage,
        (Tool::Opencode, "message.removed") => CanonicalType::AssistantMessage,
        (_, "PreToolUse")
        | (Tool::Codex, "SubagentStart")
        | (Tool::Codex, "item.started")
        | (Tool::Codex, "item/started")
        | (Tool::Opencode, "tool.execute.before") => CanonicalType::ToolCall,
        (_, "PostToolUse")
        | (_, "PostToolUseFailure")
        | (_, "PostToolBatch")
        | (Tool::Codex, "SubagentStop")
        | (Tool::Codex, "item.failed")
        | (Tool::Codex, "item/failed")
        | (Tool::Opencode, "tool.execute.after")
        | (Tool::Opencode, "command.executed") => CanonicalType::ToolResult,
        (_, "PreCompact") => CanonicalType::CompactionBefore,
        (_, "PostCompact") | (Tool::Opencode, "session.compacted") => {
            CanonicalType::CompactionAfter
        }
        (Tool::Opencode, "session.updated") => CanonicalType::SessionResumed,
        (Tool::Opencode, "file.edited") => CanonicalType::FileChanged,
        (Tool::Opencode, "session.error") => CanonicalType::Error,
        _ => CanonicalType::Error,
    }
}

fn canonical_type_for_codex_item(payload: &Value) -> Option<CanonicalType> {
    let item = payload
        .pointer("/item")
        .or_else(|| payload.pointer("/payload/item"))
        .or_else(|| payload.pointer("/params/item"))
        .or_else(|| payload.pointer("/payload"))
        .or_else(|| payload.pointer("/params"))
        .unwrap_or(payload);

    match string_pointer(item, "/role").as_deref() {
        Some("user") => return Some(CanonicalType::UserMessage),
        Some("assistant") => return Some(CanonicalType::AssistantMessage),
        _ => {}
    }

    match string_pointer(item, "/type").as_deref() {
        Some("message") | Some("agent_message") | Some("agentMessage") => {
            Some(CanonicalType::AssistantMessage)
        }
        Some("user_message") | Some("userMessage") => Some(CanonicalType::UserMessage),
        Some("function_call")
        | Some("custom_tool_call")
        | Some("tool_call")
        | Some("tool")
        | Some("exec_command_begin") => Some(CanonicalType::ToolCall),
        Some("function_call_output")
        | Some("custom_tool_call_output")
        | Some("tool_result")
        | Some("tool-output")
        | Some("exec_command_end") => Some(CanonicalType::ToolResult),
        Some("reasoning") | Some("agent_reasoning") => Some(CanonicalType::AssistantDelta),
        Some("error") => Some(CanonicalType::Error),
        _ => None,
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SearchDocument {
    pub(crate) user_text: String,
    pub(crate) assistant_text: String,
    pub(crate) tool_intent: String,
    pub(crate) tool_output: String,
    pub(crate) metadata_text: String,
}

impl SearchDocument {
    pub(crate) fn render(&self) -> String {
        join_non_empty([
            self.user_text.as_str(),
            self.assistant_text.as_str(),
            self.tool_intent.as_str(),
            self.tool_output.as_str(),
            self.metadata_text.as_str(),
        ])
    }

    pub(crate) fn identity_text(&self) -> String {
        self.render()
    }
}

#[allow(dead_code)]
pub(crate) fn embedding_units_for_document(document: &SearchDocument) -> Vec<EmbeddingUnit> {
    let candidates = [
        (EmbeddingUnitKind::UserText, document.user_text.as_str()),
        (
            EmbeddingUnitKind::AssistantText,
            document.assistant_text.as_str(),
        ),
        (EmbeddingUnitKind::ToolIntent, document.tool_intent.as_str()),
        (
            EmbeddingUnitKind::MetadataText,
            document.metadata_text.as_str(),
        ),
    ];
    let mut units = Vec::new();
    for (kind, text) in candidates {
        let text = text.trim();
        if text.is_empty() {
            continue;
        }
        units.push(EmbeddingUnit {
            kind,
            unit_index: 0,
            text: text.to_string(),
            text_hash: sha256_hex(text.as_bytes()),
        });
    }
    units
}

pub(crate) fn search_document_for_event(
    canonical_type: CanonicalType,
    payload: &Value,
) -> SearchDocument {
    let mut document = SearchDocument::default();
    match canonical_type {
        CanonicalType::UserMessage => {
            document.user_text =
                preferred_text(payload, &["prompt", "text", "message", "content", "input"]);
        }
        CanonicalType::AssistantDelta | CanonicalType::AssistantMessage => {
            document.assistant_text =
                preferred_text(payload, &["text", "message", "content", "delta", "summary"]);
        }
        CanonicalType::ToolCall => {
            document.tool_intent = tool_intent_text(
                payload,
                &["tool_name", "command", "description", "input", "arguments"],
            );
        }
        CanonicalType::ToolResult => {
            document.tool_intent =
                tool_intent_text(payload, &["tool_name", "command", "status", "exit_code"]);
            document.tool_output =
                preferred_text(payload, &["output", "stderr", "stdout", "error", "result"]);
        }
        CanonicalType::FileChanged => {
            document.metadata_text =
                preferred_text(payload, &["file", "file_path", "path", "diff", "operation"]);
        }
        CanonicalType::CompactionBefore | CanonicalType::CompactionAfter => {
            document.assistant_text =
                preferred_text(payload, &["summary", "text", "content", "reason"]);
            document.metadata_text = preferred_text(payload, &["trigger"]);
        }
        CanonicalType::SessionEnded => {
            let text = preferred_text(payload, &["message", "reason", "summary", "usage_summary"]);
            let usage = scalar_text_for_keys(payload, &["usage", "usage_metadata"]);
            document.metadata_text = join_non_empty([text.as_str(), usage.as_str()]);
        }
        CanonicalType::Error => {
            document.metadata_text =
                preferred_text(payload, &["error", "message", "reason", "text", "details"]);
        }
        _ => {
            document.metadata_text = nonvolatile_text(payload);
        }
    }

    if document.render().trim().is_empty() {
        document.metadata_text = nonvolatile_text(payload);
    }
    document
}

pub(crate) fn message_text_for_document(
    canonical_type: CanonicalType,
    document: &SearchDocument,
) -> &str {
    match canonical_type {
        CanonicalType::UserMessage => &document.user_text,
        CanonicalType::AssistantDelta | CanonicalType::AssistantMessage => &document.assistant_text,
        CanonicalType::ToolResult => {
            if document.tool_output.trim().is_empty() {
                &document.tool_intent
            } else {
                &document.tool_output
            }
        }
        _ => "",
    }
}

pub(crate) fn normalize_identity_text(text: &str) -> String {
    text.lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .collect::<Vec<_>>()
        .join("\n")
}

fn preferred_text(payload: &Value, keys: &[&str]) -> String {
    let mut values = Vec::new();
    collect_strings_for_keys(payload, keys, &mut values);
    join_owned(values)
}

/// Exec-wrapper scaffolding keys emitted by codex shell tool calls. These hold
/// the execution envelope (working directory, timeouts, escalation metadata),
/// not the command the user issued, and must not bleed into searchable text.
const EXEC_WRAPPER_SCAFFOLD_KEYS: &[&str] = &[
    "workdir",
    "yield_time_ms",
    "timeout_ms",
    "with_escalated_permissions",
    "env",
];

/// Renders tool-call intent, unwrapping the codex shell-exec JSON envelope so
/// the searchable text carries the real command (joined argv / shell string)
/// and any human justification, never the `{"workdir":..,"yield_time_ms":..}`
/// scaffolding. Values that are not exec wrappers fall back to plain string
/// collection, preserving behavior for every other tool shape.
fn tool_intent_text(payload: &Value, keys: &[&str]) -> String {
    let mut values = Vec::new();
    collect_tool_intent_for_keys(payload, keys, &mut values);
    join_owned(values)
}

fn collect_tool_intent_for_keys(value: &Value, keys: &[&str], output: &mut Vec<String>) {
    match value {
        Value::Object(map) => {
            for (key, value) in map {
                if keys.contains(&key.as_str()) {
                    collect_tool_intent_value(value, output);
                } else {
                    collect_tool_intent_for_keys(value, keys, output);
                }
            }
        }
        Value::Array(values) => {
            for value in values {
                collect_tool_intent_for_keys(value, keys, output);
            }
        }
        _ => {}
    }
}

fn collect_tool_intent_value(value: &Value, output: &mut Vec<String>) {
    match value {
        // A string that is itself a JSON-encoded exec wrapper: parse and clean
        // it rather than pushing the raw envelope text.
        Value::String(text) => match serde_json::from_str::<Value>(text.trim()) {
            Ok(parsed) if exec_wrapper_command(&parsed).is_some() => {
                collect_exec_wrapper(&parsed, output)
            }
            _ => push_clean(output, text),
        },
        // An inline wrapper object, or any other object: clean the former and
        // recurse into the latter.
        Value::Object(_) if exec_wrapper_command(value).is_some() => {
            collect_exec_wrapper(value, output)
        }
        _ => collect_strings(value, output),
    }
}

/// Returns the rendered command for a value that is a codex shell-exec wrapper
/// (an object carrying a `command` argv plus exec scaffolding), or `None` when
/// the value is not such a wrapper.
fn exec_wrapper_command(value: &Value) -> Option<String> {
    let map = value.as_object()?;
    let command = map.get("command")?;
    // Only treat this as an exec wrapper when at least one scaffolding key is
    // present; a bare `{"command": ...}` is ambiguous and handled by the normal
    // string path so unrelated tool shapes keep their existing rendering.
    if !EXEC_WRAPPER_SCAFFOLD_KEYS
        .iter()
        .any(|key| map.contains_key(*key))
    {
        return None;
    }
    let command = match command {
        Value::Array(argv) => join_argv(argv),
        Value::String(text) => text.trim().to_string(),
        _ => return None,
    };
    if command.is_empty() {
        None
    } else {
        Some(command)
    }
}

fn collect_exec_wrapper(value: &Value, output: &mut Vec<String>) {
    if let Some(command) = exec_wrapper_command(value) {
        push_clean(output, &command);
    }
    // Preserve human-meaningful narration carried alongside the command while
    // dropping the execution scaffolding.
    if let Some(map) = value.as_object() {
        for key in ["justification", "description"] {
            if let Some(Value::String(text)) = map.get(key) {
                push_clean(output, text);
            }
        }
    }
}

fn join_argv(argv: &[Value]) -> String {
    argv.iter()
        .filter_map(Value::as_str)
        .map(str::trim)
        .filter(|part| !part.is_empty())
        .collect::<Vec<_>>()
        .join(" ")
}

fn scalar_text_for_keys(payload: &Value, keys: &[&str]) -> String {
    let mut values = Vec::new();
    collect_scalars_for_keys(payload, keys, &mut values);
    join_owned(values)
}

fn nonvolatile_text(payload: &Value) -> String {
    let mut values = Vec::new();
    collect_nonvolatile_strings(payload, &mut values);
    join_owned(values)
}

fn collect_strings_for_keys(value: &Value, keys: &[&str], output: &mut Vec<String>) {
    match value {
        Value::Object(map) => {
            for (key, value) in map {
                if keys.contains(&key.as_str()) {
                    collect_strings(value, output);
                } else {
                    collect_strings_for_keys(value, keys, output);
                }
            }
        }
        Value::Array(values) => {
            for value in values {
                collect_strings_for_keys(value, keys, output);
            }
        }
        _ => {}
    }
}

fn collect_scalars_for_keys(value: &Value, keys: &[&str], output: &mut Vec<String>) {
    match value {
        Value::Object(map) => {
            for (key, value) in map {
                if keys.contains(&key.as_str()) {
                    collect_scalar_key_values(value, key, output);
                } else {
                    collect_scalars_for_keys(value, keys, output);
                }
            }
        }
        Value::Array(values) => {
            for value in values {
                collect_scalars_for_keys(value, keys, output);
            }
        }
        _ => {}
    }
}

fn collect_scalar_key_values(value: &Value, key_hint: &str, output: &mut Vec<String>) {
    match value {
        Value::String(text) => push_clean(output, &format!("{key_hint} {text}")),
        Value::Number(number) => push_clean(output, &format!("{key_hint} {number}")),
        Value::Bool(flag) => push_clean(output, &format!("{key_hint} {flag}")),
        Value::Array(values) => {
            for value in values {
                collect_scalar_key_values(value, key_hint, output);
            }
        }
        Value::Object(map) => {
            for (key, value) in map {
                collect_scalar_key_values(value, key, output);
            }
        }
        Value::Null => {}
    }
}

fn collect_nonvolatile_strings(value: &Value, output: &mut Vec<String>) {
    match value {
        Value::String(text) => push_clean(output, text),
        Value::Array(values) => {
            for value in values {
                collect_nonvolatile_strings(value, output);
            }
        }
        Value::Object(map) => {
            for (key, value) in map {
                if !is_volatile_identity_key(key) {
                    collect_nonvolatile_strings(value, output);
                }
            }
        }
        _ => {}
    }
}

fn collect_strings(value: &Value, output: &mut Vec<String>) {
    match value {
        Value::String(text) => push_clean(output, text),
        Value::Array(values) => {
            for value in values {
                collect_strings(value, output);
            }
        }
        Value::Object(map) => {
            for (key, value) in map {
                if key == "type" {
                    continue;
                }
                collect_strings(value, output);
            }
        }
        _ => {}
    }
}

fn push_clean(output: &mut Vec<String>, text: &str) {
    let text = text.trim();
    if !text.is_empty() {
        output.push(text.to_string());
    }
}

fn join_owned(values: Vec<String>) -> String {
    let mut seen = BTreeSet::new();
    let mut output = Vec::new();
    for value in values {
        if seen.insert(value.clone()) {
            output.push(value);
        }
    }
    output.join("\n")
}

fn join_non_empty<'a>(values: impl IntoIterator<Item = &'a str>) -> String {
    values
        .into_iter()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .collect::<Vec<_>>()
        .join("\n")
}

pub(crate) fn identity_payload(value: &Value) -> Value {
    match value {
        Value::Object(map) => Value::Object(
            map.iter()
                .filter(|(key, _)| !is_volatile_identity_key(key))
                .map(|(key, value)| (key.clone(), identity_payload(value)))
                .collect(),
        ),
        Value::Array(values) => Value::Array(values.iter().map(identity_payload).collect()),
        _ => value.clone(),
    }
}

fn is_volatile_identity_key(key: &str) -> bool {
    matches!(
        key,
        "captured_at"
            | "timestamp"
            | "created_at"
            | "updated_at"
            | "tool_version"
            | "session_id"
            | "filename_session_id"
            | "project_root"
            | "cwd"
            | "source"
            | "source_event_type"
            | "source_event_id"
            | "hook_event_name"
            | "event"
            | "type"
            | "dedupe_key"
            | "raw_file"
            | "raw_offset"
            | "raw_line"
            | "message_id"
            | "event_id"
            | "turn_id"
            | "id"
    )
}

pub(crate) fn role_for(canonical_type: CanonicalType) -> Option<&'static str> {
    match canonical_type {
        CanonicalType::UserMessage => Some("user"),
        CanonicalType::AssistantDelta | CanonicalType::AssistantMessage => Some("assistant"),
        CanonicalType::ToolResult => Some("tool"),
        _ => None,
    }
}

pub(crate) fn tool_status_for(canonical_type: CanonicalType) -> Option<&'static str> {
    match canonical_type {
        CanonicalType::ToolCall => Some("started"),
        CanonicalType::ToolResult => Some("completed"),
        _ => None,
    }
}

pub(crate) fn compaction_state_for(canonical_type: CanonicalType) -> &'static str {
    match canonical_type {
        CanonicalType::CompactionBefore => "pre_compaction",
        CanonicalType::CompactionAfter => "post_compaction",
        _ => "none",
    }
}

pub(crate) fn string_field<'a>(payload: &'a Value, key: &str) -> Option<&'a str> {
    payload.get(key).and_then(Value::as_str)
}
