//! Thread command: reconstruct a conversation thread from a tweet ID.
//! Two-step fetch: get root tweet for conversation_id, then search for all replies.

use crate::cost;
use crate::db::{BirdClient, RequestContext};
use crate::fields;
use crate::output;
use crate::requirements::AuthType;
use std::collections::{HashMap, HashSet, VecDeque};

const MAX_PAGES_CAP: u32 = 25;

/// Thread options bundled to avoid clippy::too_many_arguments.
pub struct ThreadOpts<'a> {
    pub tweet_id: &'a str,
    pub pretty: bool,
    pub max_pages: u32,
}

/// Signature takes `&OutputConfig` and injected stdout/stderr writers
/// (Plan 2 U2/U6); per-line stdout writes through `writeln!(stdout, ...)`
/// (R13) and diagnostic sites follow the KTD-1 guarded pattern (R15).
pub fn run_thread(
    client: &mut BirdClient,
    cfg: &crate::output::OutputConfig,
    stdout: &mut dyn std::io::Write,
    stderr: &mut dyn std::io::Write,
    opts: ThreadOpts<'_>,
    auth_type: &AuthType,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let quiet = cfg.suppress_diag();
    validate_tweet_id(opts.tweet_id)?;
    let max_pages = opts.max_pages.clamp(1, MAX_PAGES_CAP);

    let ctx = RequestContext {
        auth_type,
        username: None,
    };

    let (root_tweet, conversation_id, root_age_days) =
        fetch_root(client, cfg, stderr, &ctx, opts.tweet_id)?;

    let root_id = root_tweet
        .get("id")
        .and_then(|i| i.as_str())
        .map(String::from);

    let (all_tweets, pages_fetched, complete) = paginate_conversation(
        client,
        cfg,
        stderr,
        &ctx,
        &conversation_id,
        root_id.as_deref(),
        max_pages,
    )?;

    let nodes = build_thread_tree(&root_tweet, &all_tweets);
    let order = flatten_thread(&nodes);

    let thread_array: Vec<serde_json::Value> = order
        .iter()
        .map(|&idx| {
            let node = &nodes[idx];
            let mut tweet = node.tweet.clone();
            if let Some(obj) = tweet.as_object_mut() {
                obj.insert("depth".to_string(), serde_json::json!(node.depth));
            }
            tweet
        })
        .collect();

    let output = serde_json::json!({
        "thread": thread_array,
        "meta": {
            "conversation_id": conversation_id,
            "tweet_count": thread_array.len(),
            "pages_fetched": pages_fetched,
            "complete": complete,
            "root_tweet_age_days": root_age_days,
        }
    });

    if opts.pretty {
        writeln!(stdout, "{}", serde_json::to_string_pretty(&output)?)?;
    } else {
        writeln!(stdout, "{}", serde_json::to_string(&output)?)?;
    }

    if !quiet {
        writeln!(
            stderr,
            "[thread] {} tweets | {} pages fetched | {}",
            thread_array.len(),
            pages_fetched,
            if complete { "complete" } else { "truncated" }
        )
        .ok();
    }

    Ok(())
}

/// Fetch the root tweet for `tweet_id`, returning `(root_tweet, conversation_id, root_age_days)`.
/// Emits diag lines when the input is a reply (followed to its root) and when the root is older
/// than `search/recent`'s 7-day window.
fn fetch_root(
    client: &mut BirdClient,
    cfg: &crate::output::OutputConfig,
    stderr: &mut dyn std::io::Write,
    ctx: &RequestContext<'_>,
    tweet_id: &str,
) -> Result<(serde_json::Value, String, u32), Box<dyn std::error::Error + Send + Sync>> {
    let quiet = cfg.suppress_diag();

    let root_url = {
        let mut url = url::Url::parse(&format!("https://api.x.com/2/tweets/{}", tweet_id))
            .expect("invariant: tweet endpoint URL is well-formed with validated tweet_id");
        {
            let mut pairs = url.query_pairs_mut();
            for (key, value) in fields::tweet_query_params() {
                pairs.append_pair(key, value);
            }
        }
        url.to_string()
    };

    let response = client.get(&root_url, ctx)?;
    if !response.is_success() {
        return Err(format!(
            "GET tweet {}: {}",
            response.status,
            output::sanitize_for_stderr(&response.body(), 200)
        )
        .into());
    }

    let root_response = response.json.ok_or("invalid JSON from tweet lookup")?;
    let estimate = cost::estimate_cost(&root_response, &root_url, response.cache_hit);
    cost::display_cost(cfg, stderr, &estimate);

    // Check for errors array (X API returns 200 + errors for not-found)
    if let Some(errors) = root_response.get("errors").and_then(|e| e.as_array())
        && let Some(err) = errors.first()
    {
        let detail = err
            .get("detail")
            .and_then(|d| d.as_str())
            .unwrap_or("unknown error");
        return Err(format!("thread failed: {}", detail).into());
    }

    let root_tweet = root_response
        .get("data")
        .ok_or("thread failed: no data in root tweet response")?;

    let conversation_id = root_tweet
        .get("conversation_id")
        .and_then(|c| c.as_str())
        .ok_or("thread failed: root tweet missing conversation_id")?;

    validate_tweet_id(conversation_id)?;

    if conversation_id != tweet_id && !quiet {
        writeln!(
            stderr,
            "[thread] input tweet is a reply; following to root conversation {}",
            conversation_id
        )
        .ok();
    }

    let root_age_days = root_tweet
        .get("created_at")
        .and_then(|c| c.as_str())
        .and_then(parse_age_days)
        .unwrap_or(0);

    if root_age_days > 7 && !quiet {
        writeln!(
            stderr,
            "[thread] warning: root tweet is {} days old; search/recent only covers 7 days",
            root_age_days
        )
        .ok();
    }

    Ok((
        root_tweet.clone(),
        conversation_id.to_string(),
        root_age_days,
    ))
}

/// Paginate `search/recent` for `conversation_id`, returning the deduped tweets,
/// pages actually fetched, and whether the search exhausted (`complete=true`) or
/// stopped at `max_pages` (`complete=false`). `root_id`, when present, is seeded
/// into the seen-set so the root isn't re-added by the search response.
fn paginate_conversation(
    client: &mut BirdClient,
    cfg: &crate::output::OutputConfig,
    stderr: &mut dyn std::io::Write,
    ctx: &RequestContext<'_>,
    conversation_id: &str,
    root_id: Option<&str>,
    max_pages: u32,
) -> Result<(Vec<serde_json::Value>, u32, bool), Box<dyn std::error::Error + Send + Sync>> {
    let mut all_tweets: Vec<serde_json::Value> = Vec::with_capacity(100);
    let mut seen_ids: HashSet<String> = HashSet::new();
    let mut next_token: Option<String> = None;
    let mut pages_fetched: u32 = 0;

    if let Some(rid) = root_id {
        seen_ids.insert(rid.to_string());
    }

    for page_num in 1..=max_pages {
        let search_url = build_search_url(conversation_id, next_token.as_deref());

        let response = client.get(&search_url, ctx)?;
        if !response.is_success() {
            return Err(format!(
                "GET search page {} {}: {}",
                page_num,
                response.status,
                output::sanitize_for_stderr(&response.body(), 200)
            )
            .into());
        }

        let page = response.json.ok_or("invalid JSON from search")?;
        let estimate = cost::estimate_cost(&page, &search_url, response.cache_hit);
        cost::display_cost(cfg, stderr, &estimate);

        let data = match page.get("data").and_then(|d| d.as_array()) {
            Some(arr) if !arr.is_empty() => arr,
            _ => break,
        };

        for tweet in data {
            let id = tweet.get("id").and_then(|v| v.as_str()).unwrap_or("");
            if !id.is_empty() && seen_ids.insert(id.to_string()) {
                all_tweets.push(tweet.clone());
            }
        }

        pages_fetched = page_num;

        next_token = page
            .get("meta")
            .and_then(|m| m.get("next_token"))
            .and_then(|t| t.as_str())
            .map(String::from);

        if next_token.is_none() {
            break;
        }

        if page_num < max_pages {
            std::thread::sleep(std::time::Duration::from_millis(150));
        }
    }

    let complete = next_token.is_none();
    Ok((all_tweets, pages_fetched, complete))
}

/// Validate tweet ID: 1-20 digits, all ASCII numeric.
fn validate_tweet_id(id: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    if id.is_empty() || id.len() > 20 {
        return Err(format!("tweet ID must be 1-20 digits, got {}", id.len()).into());
    }
    if !id.chars().all(|c| c.is_ascii_digit()) {
        return Err(format!("tweet ID must be numeric: {}", id).into());
    }
    Ok(())
}

/// Parse an ISO 8601 created_at string and return age in days.
fn parse_age_days(created_at: &str) -> Option<u32> {
    if created_at.len() < 10 {
        return None;
    }
    let year: u64 = created_at[..4].parse().ok()?;
    let month: u64 = created_at[5..7].parse().ok()?;
    let day: u64 = created_at[8..10].parse().ok()?;
    let tweet_days = year * 365 + (year / 4) + month * 30 + day;

    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .ok()?;
    let now_days = now.as_secs() / 86400;
    let epoch_base: u64 = 1970 * 365 + (1970 / 4) + 30 + 1;
    let now_approx = epoch_base + now_days;

    Some(now_approx.saturating_sub(tweet_days) as u32)
}

fn build_search_url(conversation_id: &str, next_token: Option<&str>) -> String {
    let mut url = url::Url::parse("https://api.x.com/2/tweets/search/recent")
        .expect("invariant: constant search endpoint URL is well-formed");
    {
        let mut pairs = url.query_pairs_mut();
        pairs.append_pair("query", &format!("conversation_id:{}", conversation_id));
        for (key, value) in fields::tweet_query_params() {
            pairs.append_pair(key, value);
        }
        pairs.append_pair("max_results", "100");
        pairs.append_pair("sort_order", "recency");
    }
    if let Some(token) = next_token {
        url.query_pairs_mut().append_pair("next_token", token);
    }
    url.to_string()
}

struct ThreadNode {
    tweet: serde_json::Value,
    parent_id: Option<String>,
    depth: usize,
    children: Vec<usize>,
}

fn build_thread_tree(
    root_tweet: &serde_json::Value,
    search_tweets: &[serde_json::Value],
) -> Vec<ThreadNode> {
    let mut nodes: Vec<ThreadNode> = Vec::with_capacity(search_tweets.len() + 1);
    let mut id_to_index: HashMap<String, usize> = HashMap::new();

    let root_id = root_tweet
        .get("id")
        .and_then(|i| i.as_str())
        .unwrap_or("0")
        .to_string();
    nodes.push(ThreadNode {
        tweet: root_tweet.clone(),
        parent_id: None,
        depth: 0,
        children: vec![],
    });
    id_to_index.insert(root_id, 0);

    for tweet in search_tweets {
        let id = tweet
            .get("id")
            .and_then(|i| i.as_str())
            .unwrap_or("")
            .to_string();
        if id.is_empty() || id_to_index.contains_key(&id) {
            continue;
        }
        let parent_id = tweet
            .get("referenced_tweets")
            .and_then(|refs| refs.as_array())
            .and_then(|refs| {
                refs.iter()
                    .find(|r| r.get("type").and_then(|t| t.as_str()) == Some("replied_to"))
            })
            .and_then(|r| r.get("id").and_then(|i| i.as_str()))
            .map(String::from);

        let idx = nodes.len();
        nodes.push(ThreadNode {
            tweet: tweet.clone(),
            parent_id,
            depth: 0,
            children: vec![],
        });
        id_to_index.insert(id, idx);
    }

    for i in 0..nodes.len() {
        if let Some(ref parent_id) = nodes[i].parent_id
            && let Some(&parent_idx) = id_to_index.get(parent_id)
        {
            nodes[parent_idx].children.push(i);
        }
    }

    let mut queue: VecDeque<(usize, usize)> = VecDeque::new();
    let mut visited = vec![false; nodes.len()];
    queue.push_back((0, 0));
    visited[0] = true;
    while let Some((idx, depth)) = queue.pop_front() {
        nodes[idx].depth = depth;
        let mut children = std::mem::take(&mut nodes[idx].children);
        children.sort_by(|&a, &b| {
            let a_time = nodes[a]
                .tweet
                .get("created_at")
                .and_then(|t| t.as_str())
                .unwrap_or("");
            let b_time = nodes[b]
                .tweet
                .get("created_at")
                .and_then(|t| t.as_str())
                .unwrap_or("");
            a_time.cmp(b_time)
        });
        for &child_idx in &children {
            if !visited[child_idx] {
                visited[child_idx] = true;
                queue.push_back((child_idx, depth + 1));
            }
        }
        nodes[idx].children = children;
    }

    nodes
}

fn flatten_thread(nodes: &[ThreadNode]) -> Vec<usize> {
    if nodes.is_empty() {
        return vec![];
    }
    let mut result = Vec::new();
    let mut stack = vec![0usize];
    while let Some(idx) = stack.pop() {
        result.push(idx);
        for &child_idx in nodes[idx].children.iter().rev() {
            stack.push(child_idx);
        }
    }
    result
}

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

    #[test]
    fn validate_tweet_id_valid() {
        assert!(validate_tweet_id("123456789").is_ok());
        assert!(validate_tweet_id("1").is_ok());
        assert!(validate_tweet_id("00123").is_ok());
    }

    #[test]
    fn validate_tweet_id_empty() {
        assert!(validate_tweet_id("").is_err());
    }

    #[test]
    fn validate_tweet_id_too_long() {
        assert!(validate_tweet_id("123456789012345678901").is_err());
    }

    #[test]
    fn validate_tweet_id_non_numeric() {
        assert!(validate_tweet_id("abc").is_err());
        assert!(validate_tweet_id("123abc").is_err());
        assert!(validate_tweet_id("12-34").is_err());
    }

    fn make_tweet(id: &str, replied_to: Option<&str>, created_at: &str) -> serde_json::Value {
        let mut tweet = serde_json::json!({
            "id": id,
            "text": format!("Tweet {}", id),
            "created_at": created_at,
        });
        if let Some(parent) = replied_to {
            tweet["referenced_tweets"] = serde_json::json!([{"type": "replied_to", "id": parent}]);
        }
        tweet
    }

    #[test]
    fn tree_linear_thread() {
        let root = make_tweet("100", None, "2026-02-11T10:00:00Z");
        let tweets = vec![
            make_tweet("101", Some("100"), "2026-02-11T10:01:00Z"),
            make_tweet("102", Some("101"), "2026-02-11T10:02:00Z"),
        ];
        let nodes = build_thread_tree(&root, &tweets);
        let order = flatten_thread(&nodes);

        assert_eq!(order, vec![0, 1, 2]);
        assert_eq!(nodes[0].depth, 0);
        assert_eq!(nodes[1].depth, 1);
        assert_eq!(nodes[2].depth, 2);
    }

    #[test]
    fn tree_branching_replies() {
        let root = make_tweet("100", None, "2026-02-11T10:00:00Z");
        let tweets = vec![
            make_tweet("101", Some("100"), "2026-02-11T10:01:00Z"),
            make_tweet("102", Some("100"), "2026-02-11T10:02:00Z"),
        ];
        let nodes = build_thread_tree(&root, &tweets);
        let order = flatten_thread(&nodes);

        assert_eq!(order.len(), 3);
        assert_eq!(order[0], 0); // root first
        assert_eq!(nodes[1].depth, 1);
        assert_eq!(nodes[2].depth, 1);
    }

    #[test]
    fn tree_single_tweet_no_replies() {
        let root = make_tweet("100", None, "2026-02-11T10:00:00Z");
        let nodes = build_thread_tree(&root, &[]);
        let order = flatten_thread(&nodes);

        assert_eq!(order, vec![0]);
        assert_eq!(nodes[0].depth, 0);
    }

    #[test]
    fn tree_orphaned_tweets() {
        let root = make_tweet("100", None, "2026-02-11T10:00:00Z");
        let tweets = vec![make_tweet("200", Some("999"), "2026-02-11T10:01:00Z")];
        let nodes = build_thread_tree(&root, &tweets);
        let order = flatten_thread(&nodes);

        assert_eq!(order, vec![0]); // orphan not reachable from root
        assert_eq!(nodes.len(), 2); // still stored
    }

    #[test]
    fn tree_deduplicates_root_in_search() {
        let root = make_tweet("100", None, "2026-02-11T10:00:00Z");
        let tweets = vec![
            make_tweet("100", None, "2026-02-11T10:00:00Z"), // duplicate of root
            make_tweet("101", Some("100"), "2026-02-11T10:01:00Z"),
        ];
        let nodes = build_thread_tree(&root, &tweets);
        assert_eq!(nodes.len(), 2); // root + 101, no duplicate
    }

    #[test]
    fn tree_circular_reference_guard() {
        let root = make_tweet("100", None, "2026-02-11T10:00:00Z");
        let tweets = vec![
            make_tweet("101", Some("102"), "2026-02-11T10:01:00Z"),
            make_tweet("102", Some("101"), "2026-02-11T10:02:00Z"),
        ];
        let nodes = build_thread_tree(&root, &tweets);
        let order = flatten_thread(&nodes);
        // Should not infinite loop — orphaned cycle won't be visited
        assert_eq!(order, vec![0]);
    }

    #[test]
    fn flatten_empty() {
        assert_eq!(flatten_thread(&[]), Vec::<usize>::new());
    }

    #[test]
    fn build_search_url_basic() {
        let url = build_search_url("123", None);
        assert!(url.contains("conversation_id%3A123"));
        assert!(!url.contains("next_token"));
    }

    #[test]
    fn build_search_url_with_token() {
        let url = build_search_url("123", Some("abc"));
        assert!(url.contains("next_token=abc"));
    }

    #[test]
    fn parse_age_days_recent() {
        // A tweet from "today-ish" should have age 0-2
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .expect("test");
        let secs = now.as_secs();
        let days = secs / 86400;
        // Rough reverse: epoch days to date (imprecise but good enough for test)
        let year = 1970 + days / 365;
        let remaining = days % 365;
        let month = 1 + remaining / 30;
        let day = 1 + remaining % 30;
        let ts = format!("{:04}-{:02}-{:02}T00:00:00Z", year, month, day);
        let age = parse_age_days(&ts);
        assert!(age.is_some());
        assert!(
            age.expect("test") <= 2,
            "recent tweet should be ~0 days old, got {}",
            age.expect("test")
        );
    }

    #[test]
    fn parse_age_days_old_tweet() {
        let age = parse_age_days("2020-01-01T00:00:00Z");
        assert!(age.is_some());
        assert!(age.expect("test") > 365);
    }

    #[test]
    fn parse_age_days_invalid() {
        assert!(parse_age_days("short").is_none());
        assert!(parse_age_days("").is_none());
    }
}
