//! [`ClaudeTranscriptInspector`] — a best-effort [`SessionInspector`] adapter
//! (CONTEXT §T6) for the **Claude Code** CLI.
//!
//! Claude Code records each conversation as a JSONL transcript under the user's
//! home directory:
//!
//! ```text
//! <home>/.claude/projects/<encoded-cwd>/<conversation-id>.jsonl
//! ```
//!
//! where `<encoded-cwd>` is the agent's working directory with its path
//! separators flattened to `-` (see [`encode_cwd`]). Each line of the `.jsonl`
//! is one JSON message object.
//!
//! This adapter is the **only** place that knows the Claude transcript shape:
//! it reads the file through the injected [`FileSystem`] port, extracts a
//! best-effort `last_topic` (the last `user` message text) and `token_count`
//! (the cumulative `usage` input+output tokens of `assistant` messages), and
//! returns the CLI-agnostic [`ConversationDetails`]. No Claude-specific type
//! ever leaves this module. Anything it cannot parse is skipped, never fatal.

use std::sync::Arc;

use async_trait::async_trait;
use serde::Deserialize;

use domain::ports::{ConversationDetails, FileSystem, FsError, InspectError, SessionInspector};
use domain::profile::AgentProfile;
use domain::project::ProjectPath;

use super::claude_paths::{claude_transcript_path, supports_claude};

/// Max byte length of the extracted `last_topic` before it is truncated.
///
/// Kept small: the topic only labels a resume popup, it is not the message.
const TOPIC_MAX_LEN: usize = 120;

/// Reads Claude Code conversation transcripts.
///
/// Composes a [`FileSystem`] port (so it stays Tauri- and OS-agnostic and is
/// trivially testable) plus the base directory that stands in for the user's
/// home (`<home>/.claude/projects/`). The composition root passes the real
/// `$HOME`; tests pass a temp directory via [`with_base_dir`](Self::with_base_dir).
#[derive(Clone)]
pub struct ClaudeTranscriptInspector {
    fs: Arc<dyn FileSystem>,
    /// Directory that plays the role of the user's home directory; the adapter
    /// looks under `<home>/.claude/projects/`.
    home_dir: String,
}

impl ClaudeTranscriptInspector {
    /// Builds the inspector from an injected [`FileSystem`] and the user's home
    /// directory (resolved by the composition root, e.g. from `$HOME`).
    #[must_use]
    pub fn new(fs: Arc<dyn FileSystem>, home_dir: impl Into<String>) -> Self {
        Self {
            fs,
            home_dir: home_dir.into(),
        }
    }

    /// Test/seam constructor: identical to [`new`](Self::new) but named to make
    /// the injected base directory explicit at call sites (tests point it at a
    /// temp `~/.claude/projects/` fixture).
    #[must_use]
    pub fn with_base_dir(fs: Arc<dyn FileSystem>, home_dir: impl Into<String>) -> Self {
        Self::new(fs, home_dir)
    }
}

/// One transcript line, as far as we care about it. Everything is optional so
/// that an unexpected/partial line never fails deserialization of the fields we
/// *can* read; truly unparseable lines are skipped by the caller.
#[derive(Debug, Deserialize)]
struct TranscriptLine {
    #[serde(default)]
    role: Option<String>,
    /// Some Claude transcript variants nest the chat turn under `message`.
    #[serde(default)]
    message: Option<InnerMessage>,
    #[serde(default)]
    content: Option<Content>,
    #[serde(default)]
    usage: Option<Usage>,
}

/// The nested `{ role, content, usage }` object some transcript shapes use.
#[derive(Debug, Deserialize)]
struct InnerMessage {
    #[serde(default)]
    role: Option<String>,
    #[serde(default)]
    content: Option<Content>,
    #[serde(default)]
    usage: Option<Usage>,
}

/// Message content is either a plain string or an array of typed blocks.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum Content {
    /// `"content": "hello"`.
    Text(String),
    /// `"content": [{ "type": "text", "text": "hello" }, ...]`.
    Blocks(Vec<ContentBlock>),
}

impl Content {
    /// Flattens the content to plain text (concatenating text blocks).
    fn to_text(&self) -> String {
        match self {
            Self::Text(s) => s.clone(),
            Self::Blocks(blocks) => blocks
                .iter()
                .filter_map(|b| b.text.as_deref())
                .collect::<Vec<_>>()
                .join(" "),
        }
    }
}

/// A single content block; we only care about text blocks.
#[derive(Debug, Deserialize)]
struct ContentBlock {
    #[serde(default)]
    text: Option<String>,
}

/// Token usage as Claude reports it on assistant turns.
#[derive(Debug, Deserialize)]
struct Usage {
    #[serde(default)]
    input_tokens: Option<u64>,
    #[serde(default)]
    output_tokens: Option<u64>,
}

impl TranscriptLine {
    /// The effective role, looking at the top level then the nested message.
    fn effective_role(&self) -> Option<&str> {
        self.role
            .as_deref()
            .or_else(|| self.message.as_ref().and_then(|m| m.role.as_deref()))
    }

    /// The effective content, top level then nested.
    fn effective_content(&self) -> Option<&Content> {
        self.content
            .as_ref()
            .or_else(|| self.message.as_ref().and_then(|m| m.content.as_ref()))
    }

    /// The effective usage, top level then nested.
    fn effective_usage(&self) -> Option<&Usage> {
        self.usage
            .as_ref()
            .or_else(|| self.message.as_ref().and_then(|m| m.usage.as_ref()))
    }
}

/// Parses an already-read transcript body (the JSONL bytes) into best-effort
/// [`ConversationDetails`]. Pulled out of the async `details` so it is a pure,
/// directly unit-testable function. Malformed lines are silently skipped.
fn parse_transcript(body: &[u8]) -> ConversationDetails {
    let text = String::from_utf8_lossy(body);

    let mut last_topic: Option<String> = None;
    let mut token_total: u64 = 0;
    let mut saw_usage = false;

    for line in text.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        // Malformed line → skip, never fatal.
        let Ok(parsed) = serde_json::from_str::<TranscriptLine>(line) else {
            continue;
        };

        let role = parsed.effective_role();

        // last_topic = the last user message's text (best-effort).
        if role == Some("user") {
            if let Some(content) = parsed.effective_content() {
                let t = content.to_text();
                let t = t.trim();
                if !t.is_empty() {
                    last_topic = Some(truncate_topic(t));
                }
            }
        }

        // token_count = cumulative usage across assistant turns (best-effort).
        if let Some(usage) = parsed.effective_usage() {
            let input = usage.input_tokens.unwrap_or(0);
            let output = usage.output_tokens.unwrap_or(0);
            if input != 0 || output != 0 {
                saw_usage = true;
                token_total = token_total.saturating_add(input).saturating_add(output);
            }
        }
    }

    ConversationDetails {
        last_topic,
        token_count: saw_usage.then_some(token_total),
    }
}

/// Truncates a topic to [`TOPIC_MAX_LEN`] bytes on a char boundary, appending an
/// ellipsis when cut.
fn truncate_topic(s: &str) -> String {
    if s.len() <= TOPIC_MAX_LEN {
        return s.to_owned();
    }
    let mut end = TOPIC_MAX_LEN;
    while end > 0 && !s.is_char_boundary(end) {
        end -= 1;
    }
    format!("{}…", &s[..end])
}

#[async_trait]
impl SessionInspector for ClaudeTranscriptInspector {
    fn supports(&self, profile: &AgentProfile) -> bool {
        supports_claude(profile)
    }

    async fn details(
        &self,
        _profile: &AgentProfile,
        conversation_id: &str,
        cwd: &ProjectPath,
    ) -> Result<ConversationDetails, InspectError> {
        let path = claude_transcript_path(&self.home_dir, conversation_id, cwd);
        match self.fs.read(&path).await {
            Ok(bytes) => Ok(parse_transcript(&bytes)),
            Err(FsError::NotFound(_)) => Err(InspectError::NotFound),
            Err(e) => Err(InspectError::Read(e.to_string())),
        }
    }
}

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

    #[test]
    fn parse_extracts_last_user_topic_and_token_sum() {
        let body = concat!(
            r#"{"role":"user","content":"first question"}"#,
            "\n",
            r#"{"role":"assistant","content":"hi","usage":{"input_tokens":10,"output_tokens":5}}"#,
            "\n",
            r#"{"role":"user","content":"second question"}"#,
            "\n",
            r#"{"role":"assistant","content":"there","usage":{"input_tokens":20,"output_tokens":7}}"#,
            "\n",
        );
        let d = parse_transcript(body.as_bytes());
        assert_eq!(d.last_topic.as_deref(), Some("second question"));
        assert_eq!(d.token_count, Some(42));
    }

    #[test]
    fn parse_skips_malformed_lines_without_panicking() {
        let body = concat!(
            r#"{"role":"user","content":"valid one"}"#,
            "\n",
            "this is not json at all",
            "\n",
            r#"{"role":"assistant","usage":{"input_tokens":3,"output_tokens":4}}"#,
            "\n",
            "{ broken json",
            "\n",
        );
        let d = parse_transcript(body.as_bytes());
        assert_eq!(d.last_topic.as_deref(), Some("valid one"));
        assert_eq!(d.token_count, Some(7));
    }

    #[test]
    fn parse_handles_nested_message_and_block_content() {
        let body = concat!(
            r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"nested topic"}]}}"#,
            "\n",
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":100,"output_tokens":50}}}"#,
            "\n",
        );
        let d = parse_transcript(body.as_bytes());
        assert_eq!(d.last_topic.as_deref(), Some("nested topic"));
        assert_eq!(d.token_count, Some(150));
    }

    #[test]
    fn parse_returns_none_when_no_topic_or_usage() {
        let body = concat!(r#"{"role":"assistant","content":"no usage here"}"#, "\n",);
        let d = parse_transcript(body.as_bytes());
        assert_eq!(d.last_topic, None);
        assert_eq!(d.token_count, None);
    }

    #[test]
    fn truncate_topic_cuts_long_text() {
        let long = "x".repeat(TOPIC_MAX_LEN + 50);
        let t = truncate_topic(&long);
        assert!(t.ends_with('…'));
        assert!(t.len() <= TOPIC_MAX_LEN + 4);
    }
}
