//! `Read` tool — read a text file with offset/limit, return line-numbered output.

use std::sync::Arc;

use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use async_trait::async_trait;
use serde::Deserialize;

use crate::error::ToolsError;
use crate::tracker::Tracker;
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle};

const DESCRIPTION: &str = "Read a text file from the local filesystem. \
Supports offset/limit for large files. Returns line-numbered output (1-based). \
Directories cannot be read. The file must be read before Write or Edit can \
modify it. Paths are relative to the bound Workdir.";

const DEFAULT_LIMIT: usize = 2000;
const PROVIDER_BYTE_LIMIT: usize = 256 * 1024;

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub(crate) struct ReadParams {
    /// Logical path relative to the bound Workdir root.
    pub file_path: String,
    /// 0-based line offset from the start. Defaults to 0.
    #[serde(default)]
    pub offset: Option<usize>,
    /// Maximum number of lines to return. Defaults to 2000.
    #[serde(default)]
    pub limit: Option<usize>,
}

pub(crate) struct ReadTool {
    session: WorkdirSessionHandle,
    tracker: Tracker,
}

#[async_trait]
impl Tool for ReadTool {
    async fn execute(
        &self,
        input_json: &str,
        _ctx: agen::tool::ToolExecutionContext,
    ) -> Result<ToolOutput, ToolError> {
        let params: ReadParams = serde_json::from_str(input_json)
            .map_err(|e| ToolError::InvalidArgument(format!("invalid Read input: {e}")))?;
        let offset = params.offset.unwrap_or(0);
        let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);

        let path = WorkdirPath::new(&params.file_path).map_err(ToolsError::from)?;
        tracing::debug!(path = %path, offset, limit, "Read");

        let result = self
            .session
            .read(ReadRequest {
                path: path.clone(),
                offset,
                limit,
                max_bytes: PROVIDER_BYTE_LIMIT,
            })
            .await
            .map_err(ToolsError::from)?;
        self.tracker.record_workdir_hash(&path, result.content_hash);

        let text = String::from_utf8_lossy(&result.bytes).into_owned();
        let rendered = render_provider_read(
            &text,
            result.start_line,
            result.total_lines,
            result.truncated,
        );

        let summary = if rendered.truncated {
            format!(
                "Read {} line(s) [{}..{}] of {} from {}",
                rendered.line_count,
                offset + 1,
                offset + rendered.line_count,
                rendered.total_lines,
                path
            )
        } else {
            format!("Read {} line(s) from {}", rendered.line_count, path)
        };

        Ok(ToolOutput {
            summary,
            content: Some(rendered.body),
            attachments: Vec::new(),
        })
    }
}

struct Rendered {
    body: String,
    line_count: usize,
    total_lines: usize,
    truncated: bool,
}

fn render_provider_read(
    text: &str,
    start_line: usize,
    total_lines: usize,
    truncated: bool,
) -> Rendered {
    use std::fmt::Write as _;
    let lines = text.lines().collect::<Vec<_>>();
    let mut body = String::with_capacity(text.len().saturating_add(lines.len() * 8));
    for (index, line) in lines.iter().enumerate() {
        let _ = writeln!(&mut body, "{:>6}\t{}", start_line + index + 1, line);
    }
    Rendered {
        body,
        line_count: lines.len(),
        total_lines,
        truncated: start_line > 0 || truncated,
    }
}

/// Format a slice of lines from `text` with `cat -n` style 1-based line
/// numbers. Pure function — no I/O, no history touching.
#[cfg(test)]
fn render_numbered(text: &str, offset: usize, limit: usize) -> Rendered {
    let all_lines: Vec<&str> = text.lines().collect();
    let total_lines = all_lines.len();
    let start = offset.min(total_lines);
    let end = start.saturating_add(limit).min(total_lines);
    let slice = &all_lines[start..end];
    let line_count = slice.len();

    use std::fmt::Write as _;
    let mut body = String::with_capacity(text.len().saturating_add(line_count * 8));
    for (i, line) in slice.iter().enumerate() {
        let lineno = start + i + 1;
        let _ = writeln!(&mut body, "{:>6}\t{}", lineno, line);
    }

    Rendered {
        body,
        line_count,
        total_lines,
        truncated: start > 0 || end < total_lines,
    }
}

/// Factory for the `Read` tool.
pub fn read_tool(session: WorkdirSessionHandle, tracker: Tracker) -> ToolDefinition {
    Arc::new(move || {
        let schema = schemars::schema_for!(ReadParams);
        let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
        let meta = ToolMeta::new("Read")
            .description(DESCRIPTION)
            .input_schema(schema_value);
        let tool: Arc<dyn Tool> = Arc::new(ReadTool {
            session: session.clone(),
            tracker: tracker.clone(),
        });
        (meta, tool)
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use manifest::Scope;
    use tempfile::TempDir;
    use workdir::LocalWorkdirSession;

    fn setup() -> (TempDir, WorkdirSessionHandle, Tracker) {
        let dir = TempDir::new().unwrap();
        let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(
            Scope::writable(dir.path()).unwrap(),
            dir.path().to_path_buf(),
        ));
        (dir, session, Tracker::new())
    }

    #[tokio::test]
    async fn read_tool_basic_records_history() {
        let (dir, fs, tracker) = setup();
        let file = dir.path().join("a.txt");
        std::fs::write(&file, "alpha\nbeta\ngamma\n").unwrap();

        let def = read_tool(fs, tracker.clone());
        let (meta, tool) = def();
        assert_eq!(meta.name, "Read");

        let input = serde_json::json!({ "file_path": file.file_name().unwrap().to_str().unwrap() });
        let out = tool
            .execute(&input.to_string(), Default::default())
            .await
            .unwrap();
        assert!(out.summary.contains("Read 3 line(s)"));
        let body = out.content.unwrap();
        assert!(body.contains("     1\talpha"));
        assert!(body.contains("     3\tgamma"));

        // History recorded
        assert!(
            tracker
                .expected_workdir_hash(&WorkdirPath::new("a.txt").unwrap())
                .is_ok()
        );
    }

    #[tokio::test]
    async fn read_tool_offset_limit() {
        let (dir, fs, tracker) = setup();
        let file = dir.path().join("a.txt");
        std::fs::write(&file, "1\n2\n3\n4\n5\n").unwrap();

        let def = read_tool(fs, tracker);
        let (_, tool) = def();
        let input = serde_json::json!({
            "file_path": file.file_name().unwrap().to_str().unwrap(),
            "offset": 1,
            "limit": 2,
        });
        let out = tool
            .execute(&input.to_string(), Default::default())
            .await
            .unwrap();
        assert!(out.summary.contains("[2..3] of 5"));
        let body = out.content.unwrap();
        assert!(body.contains("     2\t2"));
        assert!(body.contains("     3\t3"));
    }

    #[tokio::test]
    async fn read_tool_missing_file() {
        let (dir, fs, tracker) = setup();
        let def = read_tool(fs, tracker);
        let (_, tool) = def();
        let input = serde_json::json!({
            "file_path": "nope.txt"
        });
        let err = tool
            .execute(&input.to_string(), Default::default())
            .await
            .unwrap_err();
        assert!(matches!(err, ToolError::ExecutionFailed(_)));
    }

    #[tokio::test]
    async fn read_tool_bad_json() {
        let (_dir, fs, tracker) = setup();
        let def = read_tool(fs, tracker);
        let (_, tool) = def();
        let err = tool
            .execute("not json", Default::default())
            .await
            .unwrap_err();
        assert!(matches!(err, ToolError::InvalidArgument(_)));
    }
}
