//! The canonical model — the hub every harness converts through.
//!
//! This is a *rich superset*: where harnesses disagree on representation we
//! pick one canonical shape (so a transcript is functional when continued in
//! a different harness), and where a harness carries detail the others lack we
//! keep it in typed optional fields (so a same-harness round-trip loses
//! nothing). There are no opaque `serde_json::Value` escape hatches except at
//! the genuinely open boundaries — tool inputs/outputs we don't model yet, and
//! the [`Tool::Raw`] catch-all for MCP and unknown tools.
//!
//! A [`Transcript<Common>`](crate::Transcript) pairs a [`Meta`] with a
//! `Vec<Message>`. The model is deliberately harness-agnostic: it does not
//! record where it came from.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;

/// Session-level metadata, common to every harness.
///
/// Harness-specific header fields (codex's `base_instructions`, opencode's
/// `projectID`, …) do not live here — they belong to the native `Body` and are
/// regenerated by `from_common`. `Meta` is only the cross-harness denominator.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Meta {
    /// Session identifier, as the originating harness knows it.
    pub id: String,
    /// When the session started.
    pub timestamp: DateTime<Utc>,
    /// Working directory the session ran in.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    /// Git branch checked out at session time.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub git_branch: Option<String>,
    /// Human-facing title, if the harness records one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    /// Version of the CLI that produced the session.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cli_version: Option<String>,
    /// Primary model used across the session.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
}

/// Who authored a message. Tool results ride on [`Role::User`] messages, per
/// the Anthropic convention every harness is normalized into.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
    User,
    Assistant,
}

/// One turn in the conversation: a role and its content blocks, plus the
/// assistant-only attribution fields when present.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Message {
    pub role: Role,
    pub content: Vec<Block>,
    pub timestamp: DateTime<Utc>,
    /// Model that produced this turn (assistant only).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    /// Why the turn ended (assistant only).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub stop_reason: Option<StopReason>,
    /// Token accounting for the turn (assistant only).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub usage: Option<Usage>,
}

/// A single content block. Tagged by `type` on the wire to match the Anthropic
/// block shape every harness is reconciled against.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Block {
    /// Plain text.
    Text { text: String },
    /// Model reasoning. `signature`/`encrypted` carry the opaque provider
    /// reasoning token (Anthropic signature, codex `encrypted_content`) so a
    /// same-harness round-trip can replay it verbatim.
    Thinking {
        text: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        signature: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        encrypted: Option<String>,
    },
    /// A tool invocation. The `id` pairs it with its [`Block::ToolResult`].
    ToolUse { id: String, tool: Tool },
    /// The outcome of a tool invocation. Lives on a [`Role::User`] message.
    ToolResult {
        tool_use_id: String,
        content: ToolOutput,
        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
        is_error: bool,
    },
    /// An inline image.
    Image { source: ImageSource },
}

/// Why an assistant turn ended. `Other` keeps any harness-specific reason
/// round-trippable rather than collapsing it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StopReason {
    /// The model finished its turn normally.
    EndTurn,
    /// The model stopped to call a tool.
    ToolUse,
    /// The output token limit was hit.
    MaxTokens,
    /// A stop sequence was produced.
    StopSequence,
    /// The turn was cancelled or aborted.
    Aborted,
    /// The turn ended in an error.
    Error,
    /// Any reason not in the canonical set, preserved verbatim.
    Other(String),
}

/// Token accounting for one assistant turn.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Usage {
    pub input_tokens: u64,
    pub output_tokens: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cache_read_input_tokens: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cache_creation_input_tokens: Option<u64>,
}

/// A base64-encoded inline image.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImageSource {
    /// Encoding, e.g. `"base64"`.
    #[serde(rename = "type")]
    pub source_type: String,
    /// MIME type, e.g. `"image/png"`.
    pub media_type: String,
    /// The encoded bytes.
    pub data: String,
}

/// The result payload of a tool call. Text is by far the common case; some
/// harnesses return structured or multi-block output, kept as JSON until the
/// superset grows to type it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ToolOutput {
    Text(String),
    Json(Value),
}

/// A canonical tool invocation.
///
/// Known tools are normalized to Claude-canonical names with typed arguments,
/// so a thread is functional when continued in any harness. Everything else —
/// MCP tools (`mcp__*`), harness-private tools, anything we don't model — lands
/// in [`Tool::Raw`] with its name and untouched input. The typed set grows as
/// harnesses are added.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "name")]
pub enum Tool {
    Read {
        file_path: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        offset: Option<u64>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        limit: Option<u64>,
    },
    Write {
        file_path: String,
        content: String,
    },
    Edit {
        file_path: String,
        old_string: String,
        new_string: String,
        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
        replace_all: bool,
    },
    MultiEdit {
        file_path: String,
        edits: Vec<EditOp>,
    },
    Bash {
        command: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        workdir: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        timeout_ms: Option<u64>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        description: Option<String>,
        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
        run_in_background: bool,
    },
    /// MCP tools, harness-private tools, and anything whose input doesn't fit a
    /// typed variant. Holds the canonical name and the untouched input, so it
    /// is always lossless.
    Raw {
        tool_name: String,
        input: Value,
    },
}

impl Tool {
    /// Build a canonical [`Tool`] from a *canonical* tool name and input — i.e.
    /// names and argument keys already normalized to the Claude convention
    /// (`Read`/`Edit`/`Bash`, `file_path`/`old_string`/…). Each harness's codec
    /// maps its native names onto this convention first, then calls this.
    ///
    /// A typed variant is used only when the input fits its schema exactly; any
    /// unexpected key falls through to [`Tool::Raw`] rather than be dropped, so
    /// the mapping is always lossless.
    pub fn from_canonical(name: &str, input: Value) -> Tool {
        fn typed<A: for<'de> Deserialize<'de>>(input: &Value) -> Option<A> {
            serde_json::from_value(input.clone()).ok()
        }
        match name {
            "Read" => typed::<ReadArgs>(&input)
                .map(|a| Tool::Read {
                    file_path: a.file_path,
                    offset: a.offset,
                    limit: a.limit,
                })
                .unwrap_or(Tool::Raw {
                    tool_name: name.to_string(),
                    input,
                }),
            "Write" => typed::<WriteArgs>(&input)
                .map(|a| Tool::Write {
                    file_path: a.file_path,
                    content: a.content,
                })
                .unwrap_or(Tool::Raw {
                    tool_name: name.to_string(),
                    input,
                }),
            "Edit" => typed::<EditArgs>(&input)
                .map(|a| Tool::Edit {
                    file_path: a.file_path,
                    old_string: a.old_string,
                    new_string: a.new_string,
                    replace_all: a.replace_all,
                })
                .unwrap_or(Tool::Raw {
                    tool_name: name.to_string(),
                    input,
                }),
            "MultiEdit" => typed::<MultiEditArgs>(&input)
                .map(|a| Tool::MultiEdit {
                    file_path: a.file_path,
                    edits: a.edits,
                })
                .unwrap_or(Tool::Raw {
                    tool_name: name.to_string(),
                    input,
                }),
            "Bash" => typed::<BashArgs>(&input)
                .map(|a| Tool::Bash {
                    command: a.command,
                    workdir: a.workdir,
                    timeout_ms: a.timeout_ms,
                    description: a.description,
                    run_in_background: a.run_in_background,
                })
                .unwrap_or(Tool::Raw {
                    tool_name: name.to_string(),
                    input,
                }),
            other => Tool::Raw {
                tool_name: other.to_string(),
                input,
            },
        }
    }

    /// Inverse of [`Tool::from_canonical`]: the canonical name and input for
    /// this tool, ready for a codec to denormalize into a harness's native
    /// names and keys.
    pub fn to_canonical(&self) -> (String, Value) {
        let value = |v: serde_json::Result<Value>| v.unwrap_or(Value::Null);
        match self {
            Tool::Read {
                file_path,
                offset,
                limit,
            } => (
                "Read".into(),
                value(serde_json::to_value(ReadArgs {
                    file_path: file_path.clone(),
                    offset: *offset,
                    limit: *limit,
                })),
            ),
            Tool::Write { file_path, content } => (
                "Write".into(),
                value(serde_json::to_value(WriteArgs {
                    file_path: file_path.clone(),
                    content: content.clone(),
                })),
            ),
            Tool::Edit {
                file_path,
                old_string,
                new_string,
                replace_all,
            } => (
                "Edit".into(),
                value(serde_json::to_value(EditArgs {
                    file_path: file_path.clone(),
                    old_string: old_string.clone(),
                    new_string: new_string.clone(),
                    replace_all: *replace_all,
                })),
            ),
            Tool::MultiEdit { file_path, edits } => (
                "MultiEdit".into(),
                value(serde_json::to_value(MultiEditArgs {
                    file_path: file_path.clone(),
                    edits: edits.clone(),
                })),
            ),
            Tool::Bash {
                command,
                workdir,
                timeout_ms,
                description,
                run_in_background,
            } => (
                "Bash".into(),
                value(serde_json::to_value(BashArgs {
                    command: command.clone(),
                    workdir: workdir.clone(),
                    timeout_ms: *timeout_ms,
                    description: description.clone(),
                    run_in_background: *run_in_background,
                })),
            ),
            Tool::Raw { tool_name, input } => (tool_name.clone(), input.clone()),
        }
    }
}

// Argument structs for the typed tools. `deny_unknown_fields` is what makes
// `from_canonical` lossless: an input carrying a key we don't model fails to
// parse and falls back to `Tool::Raw` rather than silently losing the key.
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReadArgs {
    file_path: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    offset: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    limit: Option<u64>,
}

#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WriteArgs {
    file_path: String,
    content: String,
}

#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct EditArgs {
    file_path: String,
    old_string: String,
    new_string: String,
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    replace_all: bool,
}

#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct MultiEditArgs {
    file_path: String,
    edits: Vec<EditOp>,
}

#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct BashArgs {
    command: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    workdir: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    timeout_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    description: Option<String>,
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    run_in_background: bool,
}

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

    /// A known tool with a known schema becomes the typed variant, and round
    /// trips back to the same canonical name and input.
    #[test]
    fn typed_tool_round_trips() {
        let input = json!({
            "file_path": "/a/b.rs",
            "old_string": "x",
            "new_string": "y",
        });
        let tool = Tool::from_canonical("Edit", input.clone());
        assert!(matches!(tool, Tool::Edit { .. }));
        let (name, back) = tool.to_canonical();
        assert_eq!(name, "Edit");
        assert_eq!(back, input);
    }

    /// `replace_all` survives the round trip when set.
    #[test]
    fn edit_preserves_replace_all() {
        let input = json!({
            "file_path": "/a", "old_string": "x", "new_string": "y", "replace_all": true,
        });
        let (_, back) = Tool::from_canonical("Edit", input.clone()).to_canonical();
        assert_eq!(back, input);
    }

    /// The whole point of `deny_unknown_fields`: an unmodeled key on a known
    /// tool must NOT be silently dropped. It falls back to `Raw`, which keeps
    /// the input intact, so the mapping stays lossless.
    #[test]
    fn unknown_key_on_known_tool_falls_back_to_raw_losslessly() {
        let input = json!({"file_path": "/a", "surprise": 1});
        let tool = Tool::from_canonical("Read", input.clone());
        match &tool {
            Tool::Raw {
                tool_name,
                input: kept,
            } => {
                assert_eq!(tool_name, "Read");
                assert_eq!(*kept, input);
            }
            other => panic!("expected Raw, got {other:?}"),
        }
        assert_eq!(tool.to_canonical(), ("Read".to_string(), input));
    }

    /// MCP and unknown tools pass through as `Raw` with name and input intact.
    #[test]
    fn mcp_tool_is_raw() {
        let input = json!({"q": "hello"});
        let tool = Tool::from_canonical("mcp__search__query", input.clone());
        assert_eq!(
            tool.to_canonical(),
            ("mcp__search__query".to_string(), input)
        );
    }

    /// Bash carries its incidental fields (description/timeout) rather than
    /// dropping them to match a narrower schema.
    #[test]
    fn bash_keeps_incidental_fields() {
        let input = json!({"command": "ls", "description": "list", "timeout_ms": 5000});
        let tool = Tool::from_canonical("Bash", input.clone());
        assert!(matches!(tool, Tool::Bash { .. }));
        assert_eq!(tool.to_canonical().1, input);
    }
}

/// One find/replace within a [`Tool::MultiEdit`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EditOp {
    pub old_string: String,
    pub new_string: String,
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub replace_all: bool,
}
