xmaster 1.6.3

Enterprise-grade X/Twitter CLI — post, reply, like, retweet, DM, search, and more
use crate::context::AppContext;
use crate::errors::XmasterError;
use crate::intel::preflight::{self, AnalyzeContext};
use crate::intel::store::IntelStore;
use crate::output::{self, CsvRenderable, OutputFormat, Tableable};
use crate::providers::xapi::XApi;
use serde::Serialize;
use std::sync::Arc;

#[derive(Serialize)]
struct ThreadResult {
    #[serde(rename = "ids")]
    tweet_ids: Vec<String>,
    total: usize,
    succeeded: usize,
    failed: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    hook_score: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    hook_grade: Option<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    warnings: Vec<String>,
}

impl Tableable for ThreadResult {
    fn to_table(&self) -> comfy_table::Table {
        let mut table = comfy_table::Table::new();
        table.set_header(vec!["#", "Tweet ID", "Status"]);
        for (i, id) in self.tweet_ids.iter().enumerate() {
            table.add_row(vec![
                (i + 1).to_string(),
                id.clone(),
                "Posted".to_string(),
            ]);
        }
        if self.failed > 0 {
            table.add_row(vec![
                "".to_string(),
                format!("{} tweet(s) failed", self.failed),
                "Failed".to_string(),
            ]);
        }
        if let Some(score) = self.hook_score {
            let grade = self.hook_grade.as_deref().unwrap_or("?");
            table.add_row(vec![
                "".to_string(),
                format!("Hook quality: {score}/100 ({grade})"),
                "".to_string(),
            ]);
        }
        table
    }
}

impl CsvRenderable for ThreadResult {
    fn csv_headers() -> Vec<&'static str> {
        vec!["index", "tweet_id", "status"]
    }
    fn csv_rows(&self) -> Vec<Vec<String>> {
        self.tweet_ids
            .iter()
            .enumerate()
            .map(|(i, id)| vec![(i + 1).to_string(), id.clone(), "posted".into()])
            .collect()
    }
}

pub async fn execute(
    ctx: Arc<AppContext>,
    format: OutputFormat,
    texts: &[String],
    media: &[String],
) -> Result<(), XmasterError> {
    if texts.is_empty() {
        return Err(XmasterError::Api {
            provider: "x",
            code: "invalid_input",
            message: "Thread must contain at least one tweet".into(),
        });
    }

    let api = XApi::new(ctx.clone());

    // ── Pre-flight on the hook (first tweet) ──
    let thread_ctx = AnalyzeContext {
        goal: Some("impressions".to_string()),
        premium: ctx.config.account.premium,
        ..Default::default()
    };
    let analysis = preflight::analyze(&texts[0], &thread_ctx);
    let mut warnings = Vec::new();

    for issue in &analysis.issues {
        if issue.severity == preflight::Severity::Critical {
            warnings.push(format!("[CRITICAL] {}: {}", issue.code, issue.message));
        } else if issue.severity == preflight::Severity::Warning {
            warnings.push(format!("[WARN] {}", issue.message));
        }
    }

    // 2026 algorithm update: threads drop off after 4 tweets. The feed splits
    // long threads into separate items, and impressions on tweets 5+ fall ~80%
    // vs the previous bundle-delivery behaviour (source: community analysis
    // of the Jan 2026 home-mixer rewrite). Warn the user before posting.
    if texts.len() > 4 {
        warnings.push(format!(
            "[WARN] thread_too_long: {} tweets — 2026 feed split means tweets 5+ drop ~80% reach. Consider: (a) posting the first 4 now, (b) repurposing the rest as a standalone post 2h later, or (c) posting it as an Article (boosted format for Premium accounts)",
            texts.len()
        ));
    }

    if format == OutputFormat::Table {
        eprintln!(
            "--- Thread hook pre-flight ({}/100, {}) ---",
            analysis.score, analysis.grade
        );
        for w in &warnings {
            eprintln!("  {w}");
        }
        if !analysis.suggestions.is_empty() {
            eprintln!("  Tip: {}", analysis.suggestions[0]);
        }
        eprintln!("  Posting {} tweets with natural pacing...", texts.len());
        eprintln!("---");
    }

    // Upload media if provided (attach to first tweet only)
    let media_ids = if !media.is_empty() {
        let mut ids = Vec::new();
        for path in media {
            let id = api.upload_media(path).await?;
            ids.push(id);
        }
        Some(ids)
    } else {
        None
    };

    let mut posted_ids: Vec<String> = Vec::new();
    let mut failed = 0usize;

    for (i, text) in texts.iter().enumerate() {
        // ── Natural pacing between thread tweets (1-3 seconds) ──
        if i > 0 {
            let jitter_ms = 1000 + (rand::random::<u64>() % 2000);
            tokio::time::sleep(std::time::Duration::from_millis(jitter_ms)).await;
        }

        let reply_to = if i == 0 {
            None
        } else {
            posted_ids.last().map(|s| s.as_str())
        };
        let tweet_media = if i == 0 { media_ids.as_deref() } else { None };

        match api
            .create_tweet(text, reply_to, None, tweet_media, None, None)
            .await
        {
            Ok(resp) => {
                // Log each tweet to store via record_published_post
                if let Ok(store) = IntelStore::open() {
                    let content_type = if i == 0 { "thread_hook" } else { "thread_reply" };
                    let analysis_json = if i == 0 {
                        serde_json::to_string(&analysis).ok()
                    } else {
                        None
                    };
                    let _ = store.record_published_post(
                        &resp.id,
                        text,
                        content_type,
                        reply_to,
                        None,
                        if i == 0 { Some(analysis.score as f64) } else { None },
                        analysis_json.as_deref(),
                        None,
                    );
                }
                posted_ids.push(resp.id);
            }
            Err(e) => {
                failed += 1;
                let remaining = texts.len() - i - 1;
                failed += remaining;
                eprintln!(
                    "Thread broken at tweet {}/{}: {e}. {} tweet(s) not posted.",
                    i + 1,
                    texts.len(),
                    remaining
                );
                break;
            }
        }
    }

    // If ALL tweets failed, return an error instead of a success envelope
    if posted_ids.is_empty() && !texts.is_empty() {
        return Err(XmasterError::Api {
            provider: "x",
            code: "thread_failed",
            message: format!("Thread failed: 0/{} tweets posted", texts.len()),
        });
    }

    let display = ThreadResult {
        total: texts.len(),
        succeeded: posted_ids.len(),
        failed,
        tweet_ids: posted_ids.clone(),
        hook_score: Some(analysis.score),
        hook_grade: Some(analysis.grade.clone()),
        warnings: if format == OutputFormat::Json { warnings } else { vec![] },
    };
    output::render(format, &display, None);

    // Undo hint
    if format == OutputFormat::Table && !posted_ids.is_empty() {
        eprintln!(
            "Delete thread: {}",
            posted_ids
                .iter()
                .map(|id| format!("xmaster delete {id}"))
                .collect::<Vec<_>>()
                .join(" && ")
        );
    }
    Ok(())
}