use agentic_tools_core::fmt::TextFormat;
use agentic_tools_core::fmt::TextOptions;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::sync::OnceLock;

/// Filter for comment source type: robot (bot), human, or all.
#[derive(
    Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq, clap::ValueEnum,
)]
#[serde(rename_all = "lowercase")]
pub enum CommentSourceType {
    Robot,
    Human,
    #[default]
    All,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ReviewComment {
    pub id: u64,
    pub user: String,
    pub is_bot: bool,
    pub body: String,
    pub path: String,
    pub line: Option<u64>,
    pub side: Option<String>,
    pub created_at: String,
    pub updated_at: String,
    pub html_url: String,
    pub pull_request_review_id: Option<u64>,
    pub in_reply_to_id: Option<u64>,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PrSummary {
    pub number: u64,
    pub title: String,
    pub author: String,
    pub state: String,
    pub created_at: String,
    pub updated_at: String,
    pub comment_count: u32,
    pub review_comment_count: u32,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ReviewCommentList {
    pub owner: String,
    pub repo: String,
    pub pr_number: u64,
    pub pr_url: String,
    pub comments: Vec<ReviewComment>,

    /// Number of threads shown so far (cumulative across pagination calls)
    pub shown_threads: usize,
    /// Total number of threads available for this query
    pub total_threads: usize,
    /// Whether there are more pages available
    pub has_more: bool,

    /// Optional pagination hint message for structured consumers.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
}

/// A thread of review comments: a parent comment and its replies.
/// Used internally for pagination (threads stay together).
#[derive(Debug, Clone)]
pub struct Thread {
    pub parent: ReviewComment,
    pub replies: Vec<ReviewComment>,
    pub is_resolved: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PrSummaryList {
    pub owner: String,
    pub repo: String,
    pub state: String,
    pub prs: Vec<PrSummary>,

    pub shown_prs: usize,
    pub total_prs: usize,
    pub has_more: bool,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct FormatOptions {
    pub show_ids: bool,
    pub show_urls: bool,
    pub show_dates: bool,      // created_at/updated_at
    pub show_review_ids: bool, // pull_request_review_id
    pub show_counts: bool,     // PR list: comment_count, review_comment_count
    pub show_author: bool,     // PR list: author
}

impl Default for FormatOptions {
    fn default() -> Self {
        Self {
            show_ids: true,
            show_urls: false,
            show_dates: false,
            show_review_ids: false,
            show_counts: false,
            show_author: false,
        }
    }
}

// Cache options once per process
static FORMAT_OPTIONS: OnceLock<FormatOptions> = OnceLock::new();

impl FormatOptions {
    pub fn from_env() -> Self {
        let raw = std::env::var("PR_COMMENTS_EXTRAS").unwrap_or_default();
        Self::from_csv(&raw)
    }

    // Pure helper for testing
    pub fn from_csv(csv: &str) -> Self {
        let mut opts = Self::default();
        for flag in csv
            .split(',')
            .map(|s| s.trim().to_lowercase())
            .filter(|s| !s.is_empty())
        {
            match flag.as_str() {
                "id" | "ids" => opts.show_ids = true,
                "noid" | "no_ids" => opts.show_ids = false,
                "url" | "urls" => opts.show_urls = true,
                "date" | "dates" | "time" | "times" => opts.show_dates = true,
                "review" | "review_id" | "review_ids" => opts.show_review_ids = true,
                "count" | "counts" => opts.show_counts = true,
                "author" | "authors" => opts.show_author = true,
                _ => {}
            }
        }
        opts
    }

    pub fn get() -> &'static Self {
        FORMAT_OPTIONS.get_or_init(Self::from_env)
    }
}

// Helpers

pub fn group_by_path(comments: &[ReviewComment]) -> BTreeMap<&str, Vec<&ReviewComment>> {
    let mut map: BTreeMap<&str, Vec<&ReviewComment>> = BTreeMap::new();
    for c in comments {
        map.entry(&c.path).or_default().push(c);
    }
    map
}

pub fn compress_side(side: Option<&str>) -> &'static str {
    match side {
        Some(s) if s.eq_ignore_ascii_case("RIGHT") => "R",
        Some(s) if s.eq_ignore_ascii_case("LEFT") => "L",
        _ => "-",
    }
}

pub fn format_legend() -> &'static str {
    "Legend: L = old (LEFT), R = new (RIGHT), - = unknown"
}

pub fn indent_multiline(s: &str, indent: &str) -> String {
    let mut out = String::new();
    for (i, line) in s.lines().enumerate() {
        if i != 0 {
            out.push('\n');
            out.push_str(indent);
        }
        out.push_str(line);
    }
    out
}

fn fmt_header(title: &str) -> String {
    // simple single-line header
    title.to_string()
}

fn fmt_user(user: &str) -> &str {
    if user.is_empty() { "<unknown>" } else { user }
}

fn fmt_ts(ts: &str) -> &str {
    // Avoid dependency on chrono; use as-is
    ts
}

// McpFormatter implementations

impl TextFormat for ReviewCommentList {
    fn fmt_text(&self, _opts: &TextOptions) -> String {
        self.fmt_text_with_options(FormatOptions::get())
    }
}

impl ReviewCommentList {
    pub fn fmt_text_with_options(&self, opts: &FormatOptions) -> String {
        let mut out = String::new();
        let _ = writeln!(
            out,
            "{}",
            fmt_header(&format!(
                "Review comments for {owner}/{repo}#{pr_number}:",
                owner = self.owner,
                repo = self.repo,
                pr_number = self.pr_number
            ))
        );
        let _ = writeln!(out, "PR: {pr_url}", pr_url = self.pr_url);
        let _ = writeln!(
            out,
            "Threads shown: {shown_threads} of {total_threads}",
            shown_threads = self.shown_threads,
            total_threads = self.total_threads
        );

        if self.comments.is_empty() {
            let _ = writeln!(out, "No matching review comment threads.");
            let _ = writeln!(out, "{}", self.pagination_footer());
            return out.trim_end().to_string();
        }

        let _ = writeln!(out, "{}", format_legend());

        let grouped = group_by_path(&self.comments);

        for (path, comments) in grouped {
            let _ = writeln!(out, "\n{path}");

            // Build replies map
            let mut replies_by_parent: std::collections::BTreeMap<u64, Vec<&ReviewComment>> =
                std::collections::BTreeMap::new();
            for c in comments.iter().filter(|c| c.in_reply_to_id.is_some()) {
                if let Some(pid) = c.in_reply_to_id {
                    replies_by_parent.entry(pid).or_default().push(c);
                }
            }

            // Render top-level comments, then their replies
            for c in comments {
                if c.in_reply_to_id.is_some() {
                    continue; // replies rendered under their parent
                }

                // Format top-level comment
                let side = compress_side(c.side.as_deref());
                let line_disp = c.line.map_or_else(|| "?".into(), |n| n.to_string());
                let user = fmt_user(&c.user);
                let mut head = format!("  [{line_disp} {side}] {user}");
                if opts.show_ids {
                    let _ = write!(head, " #{id}", id = c.id);
                }
                if opts.show_review_ids
                    && let Some(rid) = c.pull_request_review_id
                {
                    let _ = write!(head, " (review:{rid})");
                }
                if opts.show_dates {
                    let _ = write!(head, " @{}", fmt_ts(&c.created_at));
                }
                let _ = writeln!(out, "{head}");
                let body = indent_multiline(&c.body, "    ");
                let _ = writeln!(out, "    {body}");
                let _ = writeln!(out, "    Thread: {html_url}", html_url = c.html_url);

                // Render replies indented under parent
                if let Some(replies) = replies_by_parent.get(&c.id) {
                    for r in replies {
                        let side_r = compress_side(r.side.as_deref());
                        let line_r = r.line.map_or_else(|| "?".into(), |n| n.to_string());
                        let user = fmt_user(&r.user);
                        let mut head_r = format!("    ↳ [{line_r} {side_r}] {user}");
                        if opts.show_ids {
                            let _ = write!(head_r, " #{id}", id = r.id);
                        }
                        if opts.show_review_ids
                            && let Some(rid) = r.pull_request_review_id
                        {
                            let _ = write!(head_r, " (review:{rid})");
                        }
                        if opts.show_urls {
                            let _ = write!(head_r, " {html_url}", html_url = r.html_url);
                        }
                        if opts.show_dates {
                            let _ = write!(head_r, " @{}", fmt_ts(&r.created_at));
                        }
                        let _ = writeln!(out, "{head_r}");
                        let body_r = indent_multiline(&r.body, "      ");
                        let _ = writeln!(out, "      {body_r}");
                    }
                }
            }
        }

        let _ = writeln!(out, "\n{}", self.pagination_footer());
        out.trim_end().to_string()
    }

    fn pagination_footer(&self) -> String {
        if self.has_more {
            format!(
                "(more results — showing {shown_threads} of {total_threads} threads; call gh_get_comments again with same params for next page)",
                shown_threads = self.shown_threads,
                total_threads = self.total_threads
            )
        } else {
            format!(
                "(complete — showing {shown_threads} of {total_threads} threads; stop here; another identical gh_get_comments call restarts from page 1)",
                shown_threads = self.shown_threads,
                total_threads = self.total_threads
            )
        }
    }
}

impl TextFormat for PrSummaryList {
    fn fmt_text(&self, _opts: &TextOptions) -> String {
        self.fmt_text_with_options(FormatOptions::get())
    }
}

impl PrSummaryList {
    pub fn fmt_text_with_options(&self, opts: &FormatOptions) -> String {
        let mut out = String::new();
        let _ = writeln!(
            out,
            "{}",
            fmt_header(&format!(
                "Pull requests for {owner}/{repo} (state={state}):",
                owner = self.owner,
                repo = self.repo,
                state = self.state
            ))
        );
        let _ = writeln!(
            out,
            "PRs shown: {shown_prs} of {total_prs}",
            shown_prs = self.shown_prs,
            total_prs = self.total_prs
        );

        if self.prs.is_empty() {
            let _ = writeln!(out, "No matching pull requests.");
            let _ = writeln!(out, "{}", self.pagination_footer());
            return out.trim_end().to_string();
        }

        for pr in &self.prs {
            let mut line = format!(
                "#{number} {state} — {title}",
                number = pr.number,
                state = pr.state,
                title = pr.title
            );
            if opts.show_author {
                let _ = write!(line, " (by {author})", author = pr.author);
            }
            if opts.show_counts {
                let _ = write!(
                    line,
                    " [comments={comment_count}, review_comments={review_comment_count}]",
                    comment_count = pr.comment_count,
                    review_comment_count = pr.review_comment_count
                );
            }
            if opts.show_dates {
                let _ = write!(line, " @{}", fmt_ts(&pr.updated_at));
            }
            let _ = writeln!(out, "{line}");
        }

        let _ = writeln!(out, "\n{}", self.pagination_footer());
        out.trim_end().to_string()
    }

    fn pagination_footer(&self) -> String {
        if self.has_more {
            format!(
                "(more results — showing {shown_prs} of {total_prs} pull requests; call gh_get_prs again with same params for next page)",
                shown_prs = self.shown_prs,
                total_prs = self.total_prs
            )
        } else {
            format!(
                "(complete — showing {shown_prs} of {total_prs} pull requests; stop here; another identical gh_get_prs call restarts from page 1)",
                shown_prs = self.shown_prs,
                total_prs = self.total_prs
            )
        }
    }
}

impl TextFormat for ReviewComment {
    fn fmt_text(&self, _opts: &TextOptions) -> String {
        let opts = FormatOptions::get();
        let mut out = String::new();

        let _ = writeln!(out, "{}", fmt_header("Reply posted:"));

        // Format similar to review comment list but for single comment
        let side = compress_side(self.side.as_deref());
        let line_disp = self.line.map_or_else(|| "?".into(), |n| n.to_string());
        let user = fmt_user(&self.user);
        let mut head = format!(
            "{path} [{line_disp} {side}] {user}",
            path = self.path,
            line_disp = line_disp,
            side = side,
            user = user,
        );
        if opts.show_ids {
            let _ = write!(head, " #{id}", id = self.id);
        }
        if opts.show_review_ids
            && let Some(rid) = self.pull_request_review_id
        {
            let _ = write!(head, " (review:{rid})");
        }
        if opts.show_urls {
            let _ = write!(head, " {html_url}", html_url = self.html_url);
        }
        if opts.show_dates {
            let _ = write!(head, " @{}", fmt_ts(&self.created_at));
        }
        let _ = writeln!(out, "{head}");
        let body = indent_multiline(&self.body, "  ");
        let _ = writeln!(out, "  {body}");

        out
    }
}

impl From<octocrab::models::pulls::Comment> for ReviewComment {
    fn from(comment: octocrab::models::pulls::Comment) -> Self {
        let (user, is_bot) = match comment.user {
            Some(u) => {
                // Bot detection: Author.r#type == "Bot" OR login ends with "[bot]"
                let is_bot = u.r#type == "Bot" || u.login.ends_with("[bot]");
                (u.login, is_bot)
            }
            None => (String::new(), false),
        };
        Self {
            id: comment.id.0,
            user,
            is_bot,
            body: comment.body,
            path: comment.path,
            line: comment.line,
            side: comment.side,
            created_at: comment.created_at.to_rfc3339(),
            updated_at: comment.updated_at.to_rfc3339(),
            html_url: comment.html_url,
            pull_request_review_id: comment.pull_request_review_id.map(|id| id.0),
            in_reply_to_id: comment.in_reply_to_id.map(|id| id.0),
        }
    }
}

impl ReviewComment {
    /// Convert from octocrab's `ReviewComment` type (returned by `reply_to_comment`).
    pub fn from_review_comment(comment: octocrab::models::pulls::ReviewComment) -> Self {
        let (user, is_bot) = match comment.user {
            Some(u) => {
                // Bot detection: Author.r#type == "Bot" OR login ends with "[bot]"
                let is_bot = u.r#type == "Bot" || u.login.ends_with("[bot]");
                (u.login, is_bot)
            }
            None => (String::new(), false),
        };
        // Convert Side enum to String
        let side = comment.side.map(|s| match s {
            octocrab::models::pulls::Side::Left => "LEFT".to_string(),
            octocrab::models::pulls::Side::Right => "RIGHT".to_string(),
            _ => "UNKNOWN".to_string(),
        });
        Self {
            id: comment.id.0,
            user,
            is_bot,
            body: comment.body,
            path: comment.path,
            line: comment.line,
            side,
            created_at: comment.created_at.to_rfc3339(),
            updated_at: comment.updated_at.to_rfc3339(),
            html_url: comment.html_url.clone(),
            pull_request_review_id: comment.pull_request_review_id.map(|id| id.0),
            in_reply_to_id: comment.in_reply_to_id.map(|id| id.0),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphQLResponse<T> {
    pub data: Option<T>,
    pub errors: Option<Vec<GraphQLError>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphQLError {
    pub message: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PullRequestData {
    pub repository: Repository,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Repository {
    #[serde(rename = "pullRequest")]
    pub pull_request: PullRequest,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PullRequest {
    #[serde(rename = "reviewThreads")]
    pub review_threads: ReviewThreadConnection,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewThreadConnection {
    pub nodes: Vec<ReviewThread>,
    #[serde(rename = "pageInfo")]
    pub page_info: PageInfo,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewThread {
    pub id: String,
    #[serde(rename = "isResolved")]
    pub is_resolved: bool,
    pub comments: ReviewThreadCommentConnection,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewThreadCommentConnection {
    pub nodes: Vec<ReviewThreadComment>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewThreadComment {
    pub id: String,
    #[serde(rename = "databaseId")]
    pub database_id: Option<u64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageInfo {
    #[serde(rename = "hasNextPage")]
    pub has_next_page: bool,
    #[serde(rename = "endCursor")]
    pub end_cursor: Option<String>,
}

#[cfg(test)]
mod format_options_tests {
    use super::FormatOptions;

    #[test]
    fn parses_empty_flags() {
        let o = FormatOptions::from_csv("");
        assert!(
            o.show_ids
                && !o.show_urls
                && !o.show_dates
                && !o.show_review_ids
                && !o.show_counts
                && !o.show_author
        );
    }

    #[test]
    fn parses_known_flags_with_synonyms() {
        let o = FormatOptions::from_csv("id, url, dates, review, counts, author");
        assert!(
            o.show_ids
                && o.show_urls
                && o.show_dates
                && o.show_review_ids
                && o.show_counts
                && o.show_author
        );

        let o2 = FormatOptions::from_csv("ids, urls, times, review_id, count, authors");
        assert!(
            o2.show_ids
                && o2.show_urls
                && o2.show_dates
                && o2.show_review_ids
                && o2.show_counts
                && o2.show_author
        );
    }

    #[test]
    fn parses_noid_and_precedence() {
        let o = FormatOptions::from_csv("noid");
        assert!(!o.show_ids);

        let o2 = FormatOptions::from_csv("no_ids");
        assert!(!o2.show_ids);

        // Precedence: last wins
        let o3 = FormatOptions::from_csv("id,noid");
        assert!(!o3.show_ids);

        let o4 = FormatOptions::from_csv("noid,id");
        assert!(o4.show_ids);
    }
}

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

    #[expect(clippy::too_many_arguments, reason = "Test helper keeps cases concise")]
    fn sample_review(
        id: u64,
        path: &str,
        line: Option<u64>,
        side: Option<&str>,
        user: &str,
        body: &str,
        html_url: &str,
        in_reply_to_id: Option<u64>,
    ) -> ReviewComment {
        ReviewComment {
            id,
            user: user.into(),
            is_bot: false,
            body: body.into(),
            path: path.into(),
            line,
            side: side.map(std::convert::Into::into),
            created_at: "2025-01-01T00:00:00Z".into(),
            updated_at: "2025-01-01T00:00:00Z".into(),
            html_url: html_url.into(),
            pull_request_review_id: Some(42),
            in_reply_to_id,
        }
    }

    fn sample_review_list(comments: Vec<ReviewComment>, has_more: bool) -> ReviewCommentList {
        let shown_threads = comments
            .iter()
            .filter(|c| c.in_reply_to_id.is_none())
            .count();
        let total_threads = if has_more {
            shown_threads.saturating_add(1)
        } else {
            shown_threads
        };
        ReviewCommentList {
            owner: "octo".into(),
            repo: "hello-world".into(),
            pr_number: 123,
            pr_url: "https://github.com/octo/hello-world/pull/123".into(),
            comments,
            shown_threads,
            total_threads,
            has_more,
            message: has_more.then(|| {
                format!(
                    "Showing {shown_threads} out of {total_threads} threads. Call gh_get_comments again for more."
                )
            }),
        }
    }

    fn sample_pr_summary_list(prs: Vec<PrSummary>, has_more: bool) -> PrSummaryList {
        let shown_prs = prs.len();
        let total_prs = if has_more {
            shown_prs.saturating_add(1)
        } else {
            shown_prs
        };

        PrSummaryList {
            owner: "octo".into(),
            repo: "hello-world".into(),
            state: "open".into(),
            prs,
            shown_prs,
            total_prs,
            has_more,
            message: has_more.then(|| {
                format!(
                    "Showing {shown_prs} out of {total_prs} pull requests. Call gh_get_prs again for more."
                )
            }),
        }
    }

    #[test]
    fn group_by_path_groups_and_orders() {
        let cs = vec![
            sample_review(
                1,
                "a.rs",
                Some(1),
                Some("RIGHT"),
                "u",
                "x",
                "https://x/1",
                None,
            ),
            sample_review(
                2,
                "b.rs",
                Some(2),
                Some("LEFT"),
                "u",
                "y",
                "https://x/2",
                None,
            ),
            sample_review(3, "a.rs", None, None, "u2", "z", "https://x/3", None),
        ];
        let g = group_by_path(&cs);
        let keys: Vec<_> = g.keys().copied().collect();
        assert_eq!(keys, vec!["a.rs", "b.rs"]);
        assert_eq!(g["a.rs"].len(), 2);
        assert_eq!(g["b.rs"].len(), 1);
    }

    #[test]
    fn side_compression() {
        assert_eq!(compress_side(Some("RIGHT")), "R");
        assert_eq!(compress_side(Some("LEFT")), "L");
        assert_eq!(compress_side(Some("right")), "R");
        assert_eq!(compress_side(None), "-");
    }

    #[test]
    fn indent_multiline_preserves_and_indents() {
        let s = "line1\nline2\nline3";
        let out = indent_multiline(s, "  ");
        assert!(out.starts_with("line1"));
        assert!(out.contains("\n  line2"));
        assert!(out.ends_with("\n  line3"));
    }

    #[test]
    fn format_review_comment_list_shows_header_thread_url_and_completion_footer() {
        let list = sample_review_list(
            vec![
                sample_review(
                    1,
                    "src/lib.rs",
                    Some(12),
                    Some("RIGHT"),
                    "alice",
                    "Body A\nMore",
                    "https://example.com/review/1",
                    None,
                ),
                sample_review(
                    2,
                    "src/lib.rs",
                    Some(42),
                    Some("LEFT"),
                    "bob",
                    "Body B",
                    "https://example.com/review/2",
                    None,
                ),
            ],
            false,
        );
        let text = list.fmt_text_with_options(&FormatOptions::default());
        assert!(text.contains("Review comments for octo/hello-world#123:"));
        assert!(text.contains("PR: https://github.com/octo/hello-world/pull/123"));
        assert!(text.contains("Threads shown: 2 of 2"));
        assert!(text.contains("Legend:"));
        assert!(text.contains("src/lib.rs"));
        assert!(text.contains("[12 R] alice"));
        assert!(text.contains("[42 L] bob"));
        assert!(text.contains("Body A"));
        assert!(text.contains("\n    More"));
        assert!(text.contains("Thread: https://example.com/review/1"));
        assert!(text.contains("#1")); // ids are ON by default
        assert!(text.contains("complete — showing 2 of 2 threads"));
        assert!(text.contains("restarts from page 1"));
    }

    #[test]
    fn format_review_comment_list_shows_more_results_footer() {
        let list = sample_review_list(
            vec![sample_review(
                1,
                "src/lib.rs",
                Some(12),
                Some("RIGHT"),
                "alice",
                "Body A",
                "https://example.com/review/1",
                None,
            )],
            true,
        );

        let text = list.fmt_text_with_options(&FormatOptions::default());
        assert!(text.contains("Threads shown: 1 of 2"));
        assert!(text.contains("more results — showing 1 of 2 threads"));
        assert!(text.contains("call gh_get_comments again with same params"));
    }

    #[test]
    fn format_review_comment_list_hides_reply_urls_by_default() {
        let list = sample_review_list(
            vec![
                sample_review(
                    1,
                    "src/lib.rs",
                    Some(12),
                    Some("RIGHT"),
                    "alice",
                    "Parent",
                    "https://example.com/review/1",
                    None,
                ),
                sample_review(
                    2,
                    "src/lib.rs",
                    Some(12),
                    Some("RIGHT"),
                    "bob",
                    "Reply",
                    "https://example.com/review/2",
                    Some(1),
                ),
            ],
            false,
        );

        let text = list.fmt_text_with_options(&FormatOptions::default());
        assert!(text.contains("Thread: https://example.com/review/1"));
        assert!(!text.contains("https://example.com/review/2"));
    }

    #[test]
    fn format_review_comment_list_shows_reply_urls_when_enabled() {
        let list = sample_review_list(
            vec![
                sample_review(
                    1,
                    "src/lib.rs",
                    Some(12),
                    Some("RIGHT"),
                    "alice",
                    "Parent",
                    "https://example.com/review/1",
                    None,
                ),
                sample_review(
                    2,
                    "src/lib.rs",
                    Some(12),
                    Some("RIGHT"),
                    "bob",
                    "Reply",
                    "https://example.com/review/2",
                    Some(1),
                ),
            ],
            false,
        );

        let text = list.fmt_text_with_options(&FormatOptions::from_csv("url"));
        assert!(text.contains("https://example.com/review/2"));
    }

    #[test]
    fn format_review_comment_list_empty_still_shows_pr_context() {
        let list = ReviewCommentList {
            owner: "octo".into(),
            repo: "hello-world".into(),
            pr_number: 123,
            pr_url: "https://github.com/octo/hello-world/pull/123".into(),
            comments: vec![],
            shown_threads: 0,
            total_threads: 0,
            has_more: false,
            message: None,
        };

        let text = list.fmt_text_with_options(&FormatOptions::default());
        assert!(text.contains("Review comments for octo/hello-world#123:"));
        assert!(text.contains("No matching review comment threads."));
        assert!(text.contains("complete — showing 0 of 0 threads"));
    }

    #[test]
    fn format_pr_summary_list_basic() {
        let list = sample_pr_summary_list(
            vec![PrSummary {
                number: 123,
                title: "Fix bug".into(),
                author: "dana".into(),
                state: "open".into(),
                created_at: "2025-01-01T00:00:00Z".into(),
                updated_at: "2025-01-02T00:00:00Z".into(),
                comment_count: 2,
                review_comment_count: 3,
            }],
            false,
        );
        let text = list.fmt_text_with_options(&FormatOptions::default());
        assert!(text.contains("Pull requests for octo/hello-world (state=open):"));
        assert!(text.contains("PRs shown: 1 of 1"));
        assert!(text.contains("#123 open — Fix bug"));
        assert!(!text.contains("comments=")); // counts off by default
        assert!(!text.contains("(by ")); // author off by default
        assert!(text.contains("complete — showing 1 of 1 pull requests"));
    }

    #[test]
    fn format_pr_summary_list_shows_more_results_footer() {
        let list = sample_pr_summary_list(
            vec![PrSummary {
                number: 123,
                title: "Fix bug".into(),
                author: "dana".into(),
                state: "open".into(),
                created_at: "2025-01-01T00:00:00Z".into(),
                updated_at: "2025-01-02T00:00:00Z".into(),
                comment_count: 2,
                review_comment_count: 3,
            }],
            true,
        );

        let text = list.fmt_text_with_options(&FormatOptions::default());
        assert!(text.contains("PRs shown: 1 of 2"));
        assert!(text.contains("more results — showing 1 of 2 pull requests"));
        assert!(text.contains("call gh_get_prs again with same params"));
    }

    #[test]
    fn format_pr_summary_list_empty_still_shows_context() {
        let list = PrSummaryList {
            owner: "octo".into(),
            repo: "hello-world".into(),
            state: "open".into(),
            prs: vec![],
            shown_prs: 0,
            total_prs: 0,
            has_more: false,
            message: None,
        };

        let text = list.fmt_text_with_options(&FormatOptions::default());
        assert!(text.contains("Pull requests for octo/hello-world (state=open):"));
        assert!(text.contains("No matching pull requests."));
        assert!(text.contains("complete — showing 0 of 0 pull requests"));
    }

    #[test]
    fn extras_flags_parsing() {
        // Test that from_csv correctly enables all optional fields
        let opts = FormatOptions::from_csv("id,url,dates,review,counts,author");
        assert!(opts.show_ids);
        assert!(opts.show_urls);
        assert!(opts.show_dates);
        assert!(opts.show_review_ids);
        assert!(opts.show_counts);
        assert!(opts.show_author);

        // Test default has ids enabled, others disabled
        let default_opts = FormatOptions::from_csv("");
        assert!(default_opts.show_ids);
        assert!(!default_opts.show_urls);
        assert!(!default_opts.show_dates);
        assert!(!default_opts.show_review_ids);
        assert!(!default_opts.show_counts);
        assert!(!default_opts.show_author);
    }

    #[test]
    fn wrapper_serializes_as_object() {
        let w = ReviewCommentList {
            owner: "octo".into(),
            repo: "hello-world".into(),
            pr_number: 123,
            pr_url: "https://github.com/octo/hello-world/pull/123".into(),
            comments: vec![],
            shown_threads: 0,
            total_threads: 0,
            has_more: false,
            message: None,
        };
        let s = match serde_json::to_string(&w) {
            Ok(json) => json,
            Err(err) => panic!("ReviewCommentList should serialize: {err}"),
        };
        assert!(s.contains("\"owner\""));
        assert!(s.contains("\"repo\""));
        assert!(s.contains("\"pr_number\""));
        assert!(s.contains("\"pr_url\""));
        assert!(s.contains("\"comments\""));
        assert!(s.contains("\"shown_threads\""));
        assert!(s.contains("\"total_threads\""));
        assert!(s.contains("\"has_more\""));
        // message should be omitted when None
        assert!(!s.contains("\"message\""));
    }

    #[test]
    fn pagination_message_only_when_has_more() {
        // When has_more is false, message should be None
        let list_complete = ReviewCommentList {
            owner: "octo".into(),
            repo: "hello-world".into(),
            pr_number: 123,
            pr_url: "https://github.com/octo/hello-world/pull/123".into(),
            comments: vec![],
            shown_threads: 5,
            total_threads: 5,
            has_more: false,
            message: None,
        };
        assert!(list_complete.message.is_none());

        // When has_more is true, message should contain "Showing X out of Y threads"
        let msg = "Showing 5 out of 15 threads. Call gh_get_comments again for more.".to_string();
        let list_partial = ReviewCommentList {
            owner: "octo".into(),
            repo: "hello-world".into(),
            pr_number: 123,
            pr_url: "https://github.com/octo/hello-world/pull/123".into(),
            comments: vec![],
            shown_threads: 5,
            total_threads: 15,
            has_more: true,
            message: Some(msg.clone()),
        };
        assert!(list_partial.message.is_some());
        let Some(m) = list_partial.message else {
            panic!("message should be present when has_more is true");
        };
        assert!(m.contains("Showing 5 out of 15 threads"));
        assert!(m.contains("Call gh_get_comments again"));

        // Verify message is serialized when present
        let list_with_msg = ReviewCommentList {
            owner: "octo".into(),
            repo: "hello-world".into(),
            pr_number: 123,
            pr_url: "https://github.com/octo/hello-world/pull/123".into(),
            comments: vec![],
            shown_threads: 5,
            total_threads: 15,
            has_more: true,
            message: Some(msg),
        };
        let s = match serde_json::to_string(&list_with_msg) {
            Ok(json) => json,
            Err(err) => panic!("ReviewCommentList with message should serialize: {err}"),
        };
        assert!(s.contains("\"message\""));
    }
}
