//! Assembles a bounded "situation report" for the subconscious tick.
//! Gathers deltas since the last tick from memory, tools, environment,
//! and pending tasks — capped at the configured token budget.

use crate::openhuman::memory::MemoryClient;
use std::fmt::Write;
use std::path::Path;

/// Rough chars-per-token estimate for budget enforcement.
const CHARS_PER_TOKEN: usize = 4;

/// Assemble the situation report for a subconscious tick.
///
/// `last_tick_at` is 0.0 on the first tick (cold start → include everything).
/// `token_budget` caps the output size; sections are truncated if exceeded.
pub async fn build_situation_report(
    memory: Option<&MemoryClient>,
    workspace_dir: &Path,
    last_tick_at: f64,
    token_budget: u32,
) -> String {
    let char_budget = (token_budget as usize) * CHARS_PER_TOKEN;
    let mut report = String::with_capacity(char_budget.min(64_000));
    let mut remaining = char_budget;

    // Section 1: Environment
    let env_section = build_environment_section(workspace_dir);
    append_section(&mut report, &mut remaining, &env_section);

    // Section 2: Pending tasks from SQLite
    let tasks_section = build_tasks_section(workspace_dir);
    append_section(&mut report, &mut remaining, &tasks_section);

    // Section 3: Memory documents (delta since last tick)
    if let Some(client) = memory {
        let docs_section = build_memory_docs_section(client, last_tick_at).await;
        append_section(&mut report, &mut remaining, &docs_section);

        // Section 4: Graph relations (delta since last tick)
        let graph_section = build_graph_section(client, last_tick_at).await;
        append_section(&mut report, &mut remaining, &graph_section);
    } else {
        append_section(
            &mut report,
            &mut remaining,
            "## Memory\n\nMemory client not available.\n",
        );
    }

    // Section 5: Skills runtime health
    let skills_section = build_skills_section().await;
    append_section(&mut report, &mut remaining, &skills_section);

    if report.trim().is_empty() {
        report.push_str("No state changes detected since last tick.\n");
    }

    report
}

fn build_environment_section(workspace_dir: &Path) -> String {
    let host =
        hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string());
    let now = chrono::Local::now();
    format!(
        "## Environment\n\n\
         Workspace: {}\n\
         Host: {} | OS: {}\n\
         Time: {}\n",
        workspace_dir.display(),
        host,
        std::env::consts::OS,
        now.format("%Y-%m-%d %H:%M:%S %Z"),
    )
}

fn build_tasks_section(workspace_dir: &Path) -> String {
    let tasks = match super::store::with_connection(workspace_dir, |conn| {
        super::store::list_tasks(conn, false)
    }) {
        Ok(tasks) => tasks,
        Err(_) => return "## Pending Tasks\n\nFailed to read tasks.\n".to_string(),
    };

    if tasks.is_empty() {
        return "## Pending Tasks\n\nNo tasks defined.\n".to_string();
    }

    let mut section = String::from("## Pending Tasks\n\n");
    for task in &tasks {
        let _ = writeln!(section, "- {}", task.title);
    }
    section
}

/// Max chars of content to include per document in the report.
const DOC_CONTENT_SNIPPET_CHARS: usize = 500;
/// Max number of new docs to include full content for.
const MAX_DOCS_WITH_CONTENT: usize = 10;

async fn build_memory_docs_section(client: &MemoryClient, last_tick_at: f64) -> String {
    let docs = match client.list_documents(None).await {
        Ok(raw) => raw,
        Err(e) => {
            return format!("## Memory Documents\n\nFailed to list documents: {e}\n");
        }
    };

    let doc_array = docs
        .as_array()
        .or_else(|| docs.get("documents").and_then(|v| v.as_array()));

    let Some(doc_array) = doc_array else {
        return "## Memory Documents\n\nNo documents found.\n".to_string();
    };

    let is_cold_start = last_tick_at <= 0.0;

    // Collect new/updated doc metadata
    let mut new_doc_keys: Vec<(String, String, String)> = Vec::new(); // (namespace, key, title)
    for doc in doc_array {
        let updated_at = doc
            .get("updated_at")
            .or_else(|| doc.get("updatedAt"))
            .and_then(|v| v.as_f64())
            .unwrap_or(0.0);

        if !is_cold_start && updated_at <= last_tick_at {
            continue;
        }

        let title = doc
            .get("title")
            .or_else(|| doc.get("key"))
            .and_then(|v| v.as_str())
            .unwrap_or("untitled")
            .to_string();
        let namespace = doc
            .get("namespace")
            .and_then(|v| v.as_str())
            .unwrap_or("?")
            .to_string();
        let key = doc
            .get("key")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();

        new_doc_keys.push((namespace, key, title));
    }

    if new_doc_keys.is_empty() {
        return format!(
            "## Memory Documents\n\n{} total documents. No changes since last tick.\n",
            doc_array.len()
        );
    }

    let mut section = format!(
        "## Memory Documents\n\n{} total, {} new/updated since last tick:\n\n",
        doc_array.len(),
        new_doc_keys.len()
    );

    // Recall content per namespace for new docs
    let mut recalled_namespaces = std::collections::HashSet::new();
    let mut docs_with_content = 0;

    for (namespace, _key, title) in &new_doc_keys {
        let _ = writeln!(section, "### [{namespace}] {title}\n");

        // Only recall each namespace once (may have multiple docs per namespace)
        if docs_with_content < MAX_DOCS_WITH_CONTENT
            && recalled_namespaces.insert(namespace.clone())
        {
            if let Ok(Some(context)) = client.recall_namespace(namespace, 3).await {
                let snippet = truncate_at_char_boundary(&context, DOC_CONTENT_SNIPPET_CHARS);
                if !snippet.trim().is_empty() {
                    let _ = writeln!(section, "{snippet}\n");
                }
            }
            docs_with_content += 1;
        }
    }
    section
}

fn truncate_at_char_boundary(text: &str, max_bytes: usize) -> String {
    if text.len() <= max_bytes {
        return text.to_string();
    }
    // Find the last char whose END offset fits within the budget
    let truncate_at = text
        .char_indices()
        .map(|(i, ch)| i + ch.len_utf8())
        .take_while(|end| *end <= max_bytes)
        .last()
        .unwrap_or(0);
    format!("{}...", &text[..truncate_at])
}

async fn build_graph_section(client: &MemoryClient, last_tick_at: f64) -> String {
    let relations = match client.graph_query(None, None, None).await {
        Ok(rows) => rows,
        Err(e) => {
            return format!("## Knowledge Graph\n\nFailed to query graph: {e}\n");
        }
    };

    if relations.is_empty() {
        return "## Knowledge Graph\n\nNo relations.\n".to_string();
    }

    // Filter to relations updated since last tick
    let is_cold_start = last_tick_at <= 0.0;
    let mut new_relations: Vec<(&str, &str, &str)> = Vec::new();
    for rel in &relations {
        let updated_at = rel
            .get("updatedAt")
            .or_else(|| rel.get("updated_at"))
            .and_then(|v| v.as_f64())
            .unwrap_or(0.0);

        if !is_cold_start && updated_at <= last_tick_at {
            continue;
        }

        let subject = rel.get("subject").and_then(|v| v.as_str()).unwrap_or("?");
        let predicate = rel.get("predicate").and_then(|v| v.as_str()).unwrap_or("?");
        let object = rel.get("object").and_then(|v| v.as_str()).unwrap_or("?");
        new_relations.push((subject, predicate, object));
    }

    if new_relations.is_empty() {
        return format!(
            "## Knowledge Graph\n\n{} total relations. No changes since last tick.\n",
            relations.len()
        );
    }

    let mut section = format!(
        "## Knowledge Graph\n\n{} total, {} new/updated:\n\n",
        relations.len(),
        new_relations.len()
    );
    for (s, p, o) in new_relations.iter().take(20) {
        let _ = writeln!(section, "- {s} → {p} → {o}");
    }
    if new_relations.len() > 20 {
        let _ = writeln!(section, "- ... and {} more", new_relations.len() - 20);
    }
    section
}

async fn build_skills_section() -> String {
    "## Integrations\n\nLocal QuickJS skills have been removed. Use the Composio and channel sections for current integration state.\n".to_string()
}

fn append_section(report: &mut String, remaining: &mut usize, section: &str) {
    if *remaining == 0 {
        return;
    }
    // +1 for the trailing newline we append
    let needed = section.len().saturating_add(1);
    if needed <= *remaining {
        report.push_str(section);
        report.push('\n');
        *remaining -= needed;
    } else {
        // Truncate at a valid UTF-8 char boundary within budget
        let budget = *remaining;
        let truncate_at = section
            .char_indices()
            .map(|(i, ch)| i + ch.len_utf8())
            .take_while(|end| *end <= budget)
            .last()
            .unwrap_or(0);
        report.push_str(&section[..truncate_at]);
        report.push_str("\n[... truncated — token budget exceeded]\n");
        *remaining = 0;
    }
}

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

    #[test]
    fn environment_section_contains_os_and_host() {
        let section = build_environment_section(Path::new("/tmp/workspace"));
        assert!(section.contains("## Environment"));
        assert!(section.contains("Workspace: /tmp/workspace"));
        assert!(section.contains("OS:"));
    }

    #[test]
    fn append_section_truncates_on_budget() {
        let mut report = String::new();
        let mut remaining = 10;
        append_section(&mut report, &mut remaining, "Hello, this is a long section");
        assert!(report.starts_with("Hello, thi"));
        assert!(report.contains("truncated"));
        assert_eq!(remaining, 0);
    }

    #[test]
    fn append_section_exact_fit_does_not_underflow() {
        let mut report = String::new();
        // "Hello" (5 bytes) + newline (1 byte) = 6 bytes needed
        let mut remaining = 6;
        append_section(&mut report, &mut remaining, "Hello");
        assert_eq!(report, "Hello\n");
        assert_eq!(remaining, 0);
    }

    #[test]
    fn append_section_truncates_at_char_boundary() {
        let mut report = String::new();
        // "日本語" is 9 bytes (3 chars × 3 bytes each)
        // Budget of 5 should truncate to "日" (3 bytes), not panic
        let mut remaining = 5;
        append_section(&mut report, &mut remaining, "日本語タスク");
        assert!(report.starts_with("日"));
        assert!(report.contains("truncated"));
        assert_eq!(remaining, 0);
    }

    #[test]
    fn append_section_fits_within_budget() {
        let mut report = String::new();
        let mut remaining = 1000;
        append_section(&mut report, &mut remaining, "Short");
        assert!(report.contains("Short"));
        assert!(remaining < 1000);
    }
}
