//! JSON output types for `wt list --format=json`
//!
//! This module defines the structured JSON output format, designed for:
//! - Query-friendly filtering with jq
//! - Self-describing field names
//! - Alignment with CLI status subcolumns
//!
//! ## Structure
//!
//! Fields are organized by concept, matching the status display subcolumns:
//! - `working_tree`: staged/modified/untracked changes
//! - `main_state`: relationship to the default branch (would_conflict, same_commit, integrated, diverged, ahead, behind)
//! - `operation_state`: git operations in progress (conflicts, rebase, merge)
//! - `main`: relationship to the default branch (ahead/behind/diff counts)
//! - `remote`: relationship to tracking branch
//! - `worktree`: worktree-specific state (locked, prunable, etc.)

use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;

use schemars::JsonSchema;
use serde::Serialize;
use worktrunk::git::{GitRepoInfo, LineDiff, Repository};

use super::ci_status::{CiSource, PrStatus, ReviewState};
use super::custom_columns::ResolvedCustomColumn;
use super::model::{ItemKind, ListItem, UpstreamStatus};

/// JSON output for a single list item
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct JsonItem {
    /// Branch name, null for detached HEAD
    pub branch: Option<String>,

    /// Filesystem path to the worktree
    #[serde(skip_serializing_if = "Option::is_none")]
    pub path: Option<PathBuf>,

    /// Item kind: "worktree" or "branch"
    pub kind: &'static str,

    /// Commit information
    pub commit: JsonCommit,

    /// Working tree state (staged, modified, untracked changes)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub working_tree: Option<JsonWorkingTree>,

    /// Default branch relationship: would_conflict, same_commit, integrated, diverged, ahead, behind
    /// (null for default branch itself)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub main_state: Option<&'static str>,

    /// Why branch is integrated (only present when main_state == "integrated")
    /// Values: ancestor, trees_match, no_added_changes, merge_adds_nothing
    #[serde(skip_serializing_if = "Option::is_none")]
    pub integration_reason: Option<&'static str>,

    /// Git operation in progress: conflicts, rebase, merge (null when none)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub operation_state: Option<&'static str>,

    /// Relationship to default branch (absent when is_main == true)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub main: Option<JsonMain>,

    /// Relationship to remote tracking branch (absent when no tracking branch)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub remote: Option<JsonRemote>,

    /// Worktree-specific state
    #[serde(skip_serializing_if = "Option::is_none")]
    pub worktree: Option<JsonWorktree>,

    /// This is the main worktree
    pub is_main: bool,

    /// This is the current worktree (matches repo discovery path: PWD or `-C`)
    pub is_current: bool,

    /// This was the previous worktree (from `worktrunk.history`)
    pub is_previous: bool,

    /// CI status from PR or branch workflow
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ci: Option<JsonCi>,

    /// Repository web URL derived from the primary remote (absent when no parseable remote).
    /// This is the local checkout's repo; for the repo a PR/MR targets (e.g. the upstream of a
    /// fork), see `ci.repo_url`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub repo_url: Option<String>,

    /// Structured repository metadata derived from the primary remote.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub repo: Option<GitRepoInfo>,

    /// Dev server URL from project config template
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,

    /// Whether the dev server URL's port is listening
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url_active: Option<bool>,

    /// LLM-generated branch summary (requires `[list] summary = true`)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub summary: Option<String>,

    /// Pre-formatted statusline for statusline tools (tmux, starship)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub statusline: Option<String>,

    /// Raw status symbols without ANSI colors (e.g., "+! ✖ ↑")
    #[serde(skip_serializing_if = "Option::is_none")]
    pub symbols: Option<String>,

    /// Custom variables stored via `wt config state vars`
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub vars: BTreeMap<String, String>,

    /// Rendered `[list.custom-columns]` values, keyed by column header.
    /// Empty cells are omitted.
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub columns: BTreeMap<String, String>,
}

/// Commit information
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct JsonCommit {
    /// Full commit SHA
    pub sha: String,

    /// Short commit SHA, abbreviated per `core.abbrev` (auto-extends for
    /// ambiguous prefixes).
    pub short_sha: String,

    /// Commit message (first line)
    pub message: String,

    /// Unix timestamp of commit
    pub timestamp: i64,
}

/// Working tree state
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct JsonWorkingTree {
    /// Has staged files (+)
    pub staged: bool,

    /// Has modified files (!)
    pub modified: bool,

    /// Has untracked files (?)
    pub untracked: bool,

    /// Has renamed files (»)
    pub renamed: bool,

    /// Has deleted files (✘)
    pub deleted: bool,

    /// Lines added/deleted in working tree vs HEAD
    #[serde(skip_serializing_if = "Option::is_none")]
    pub diff: Option<JsonDiff>,
}

/// Line diff statistics
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct JsonDiff {
    pub added: usize,
    pub deleted: usize,
}

impl From<LineDiff> for JsonDiff {
    fn from(d: LineDiff) -> Self {
        Self {
            added: d.added,
            deleted: d.deleted,
        }
    }
}

/// Relationship to default branch
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct JsonMain {
    /// Commits ahead of default branch
    pub ahead: usize,

    /// Commits behind default branch
    pub behind: usize,

    /// Lines added/deleted vs default branch
    #[serde(skip_serializing_if = "Option::is_none")]
    pub diff: Option<JsonDiff>,
}

/// Relationship to remote tracking branch
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct JsonRemote {
    /// Remote name (e.g., "origin")
    pub name: String,

    /// Remote branch name (e.g., "feature-login")
    pub branch: String,

    /// Commits ahead of remote
    pub ahead: usize,

    /// Commits behind remote
    pub behind: usize,
}

/// Worktree-specific state
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct JsonWorktree {
    /// Worktree state: "branch_worktree_mismatch", "prunable", "locked" (absent when normal)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub state: Option<&'static str>,

    /// Reason for locked/prunable state
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,

    /// HEAD is detached (not on a branch)
    pub detached: bool,
}

/// CI status from PR or branch workflow
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct JsonCi {
    /// CI status: "passed", "running", "failed", "conflicts", "no-ci", "error"
    pub status: &'static str,

    /// Source: "pr" or "branch"
    pub source: CiSource,

    /// PR/MR number (absent for branch workflows)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub number: Option<u64>,

    /// True if local HEAD differs from remote HEAD (unpushed changes)
    pub stale: bool,

    /// URL to the PR/MR (if available)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,

    /// Web URL of the repository the PR/MR targets, derived from `url`. For a
    /// fork PR this is the upstream repo (not the local fork). Absent when `url`
    /// is absent or not a recognized PR/MR link.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub repo_url: Option<String>,

    /// Structured metadata for the repository the PR/MR targets, derived from `url`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub repo: Option<GitRepoInfo>,

    /// Review state: "approved", "changes_requested", "pending", "draft"
    /// (absent when the forge reports no review signal)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub review_state: Option<ReviewState>,
}

impl JsonItem {
    /// Convert a ListItem to the new JSON structure
    ///
    /// `all_vars` is pre-fetched vars data for all branches (from
    /// `repo.all_vars_from_snapshot()`), avoiding N+1 git process spawns.
    /// Entries are moved out via `.remove()` to avoid cloning.
    ///
    /// `repo` is repository metadata (from `repo.repo_info()`). It is repo-wide, so the caller
    /// computes it once and passes the same value to every item.
    pub fn from_list_item(
        item: &ListItem,
        all_vars: &mut HashMap<String, BTreeMap<String, String>>,
        repo: Option<&GitRepoInfo>,
        ci_provider_override: Option<&str>,
        custom_columns: &[ResolvedCustomColumn],
    ) -> Self {
        let (kind_str, worktree_data) = match &item.kind {
            ItemKind::Worktree(data) => ("worktree", Some(data.as_ref())),
            // Local and remote branch rows both serialize as "branch" — the
            // remote-qualified `branch` name (e.g. "origin/feature") already
            // carries the local/remote distinction for JSON consumers.
            ItemKind::Branch(_) => ("branch", None),
        };

        let is_main = worktree_data.is_some_and(|d| d.is_main);
        let is_current = worktree_data.is_some_and(|d| d.is_current);
        let is_previous = worktree_data.is_some_and(|d| d.is_previous);

        // Commit info — empty strings for null OID (unborn branches). The
        // short form is fetched in the same `git log --no-walk` batch as the
        // subject and timestamp (see `commit_details_many`), so it honors
        // `core.abbrev` and disambiguates without an extra subprocess. Lives on
        // `ListItem` directly so prunable worktrees still carry it even though
        // their `commit` (timestamp + message) is intentionally left empty.
        let sha = if item.head == worktrunk::git::NULL_OID {
            String::new()
        } else {
            item.head.clone()
        };
        let commit = JsonCommit {
            sha,
            short_sha: item.short_sha.clone(),
            message: item
                .commit
                .as_ref()
                .map(|c| c.commit_message.clone())
                .unwrap_or_default(),
            timestamp: item.commit.as_ref().map(|c| c.timestamp).unwrap_or(0),
        };

        // Working tree: read directly from `WorktreeData`, not from
        // `status_symbols.working_tree`. `status_symbols` may be set by
        // the metadata-only Gate 2 of `refresh_status_symbols` (locked
        // / prunable / mismatched worktrees) carrying a default
        // `WorkingTreeStatus`. The JSON output must reflect whether
        // `WorkingTreeDiff` actually loaded the field — collapsing
        // "unknown" into "clean" misleads consumers.
        let working_tree = worktree_data.and_then(|data| {
            data.working_tree_status.map(|wt| JsonWorkingTree {
                staged: wt.staged,
                modified: wt.modified,
                untracked: wt.untracked,
                renamed: wt.renamed,
                deleted: wt.deleted,
                diff: data.working_tree_diff.map(JsonDiff::from),
            })
        });

        // Main state and integration reason. Unresolved gate 3 → both
        // absent from JSON. Resolved → emit as today.
        let main_state = item.status_symbols.main_state.and_then(|s| s.as_json_str());
        let integration_reason = item
            .status_symbols
            .main_state
            .and_then(|s| s.integration_reason())
            .map(|r| r.into());

        // Operation state (conflicts, rebase, merge). Unresolved gate 2
        // (operation family) → absent from JSON.
        let operation_state = item
            .status_symbols
            .operation_state
            .and_then(|s| s.as_json_str());

        // Main relationship (absent when is_main)
        let main = if is_main {
            None
        } else {
            item.counts.map(|counts| JsonMain {
                ahead: counts.ahead,
                behind: counts.behind,
                diff: item.branch_diff.map(|bd| JsonDiff::from(bd.diff)),
            })
        };

        // Remote relationship
        let remote = item
            .upstream
            .as_ref()
            .and_then(|u| upstream_to_json(u, &item.branch));

        // Worktree state
        let worktree = worktree_data.map(|data| {
            let (state, reason) = worktree_state_to_json(data, &item.status_symbols);
            JsonWorktree {
                state,
                reason,
                detached: data.detached,
            }
        });

        // Path
        let path = worktree_data.map(|d| d.path.clone());

        // CI status
        let ci = item
            .pr_status
            .as_ref()
            .and_then(|opt| opt.as_ref())
            .map(|pr| JsonCi::from_pr_status(pr, ci_provider_override));

        // Statusline and symbols (raw, without ANSI codes)
        let statusline = item.statusline.clone();
        let symbols = Some(format_raw_symbols(&item.status_symbols)).filter(|s| !s.is_empty());

        // Per-branch vars data (pre-fetched, moved out to avoid cloning)
        let vars = super::json_v2::take_vars(item.branch.as_deref(), all_vars);

        // Summary: flatten Option<Option<String>> → Option<String>
        let summary = item.summary.as_ref().and_then(|s| s.clone());

        // Rendered custom column values, keyed by header; empty cells omitted
        let columns = super::json_v2::columns_map(custom_columns, &item.custom_values);

        JsonItem {
            branch: item.branch.clone(),
            path,
            kind: kind_str,
            commit,
            working_tree,
            main_state,
            integration_reason,
            operation_state,
            main,
            remote,
            worktree,
            is_main,
            is_current,
            is_previous,
            ci,
            repo_url: repo.map(|repo| repo.url.clone()),
            repo: repo.cloned(),
            url: item.url.clone(),
            url_active: item.url_active,
            summary,
            statusline,
            symbols,
            vars,
            columns,
        }
    }
}

/// Convert UpstreamStatus to JsonRemote
fn upstream_to_json(upstream: &UpstreamStatus, branch: &Option<String>) -> Option<JsonRemote> {
    upstream.active().map(|active| {
        // Use local branch name since UpstreamStatus only stores the remote name,
        // not the full tracking refspec. In most cases these match (e.g., feature -> origin/feature).
        JsonRemote {
            name: active.remote.to_string(),
            branch: branch.clone().unwrap_or_default(),
            ahead: active.ahead,
            behind: active.behind,
        }
    })
}

/// Extract worktree state and reason from WorktreeData
fn worktree_state_to_json(
    data: &super::model::WorktreeData,
    status_symbols: &super::model::StatusSymbols,
) -> (Option<&'static str>, Option<String>) {
    use super::model::WorktreeState;

    // Check status symbols for worktree state. `None` means gate 2
    // (metadata family) hasn't been populated yet; fall through to the
    // direct-field fallback below.
    match status_symbols.worktree_state {
        None | Some(WorktreeState::None) => {}
        Some(WorktreeState::Branch) => return (Some("no_worktree"), None),
        Some(WorktreeState::BranchWorktreeMismatch) => {
            return (Some("branch_worktree_mismatch"), None);
        }
        Some(WorktreeState::Prunable) => return (Some("prunable"), data.prunable.clone()),
        Some(WorktreeState::Locked) => return (Some("locked"), data.locked.clone()),
    }

    // Fallback: check direct fields when status_symbols hasn't resolved
    // worktree_state yet. This can happen early in progressive rendering
    // before status is computed.
    if data.is_prunable() {
        return (Some("prunable"), data.prunable.clone());
    }
    if data.locked.is_some() {
        return (Some("locked"), data.locked.clone());
    }

    (None, None)
}

impl JsonCi {
    /// Build a `JsonCi` from a [`PrStatus`], deriving the target-repo metadata
    /// from the PR/MR URL. `provider_override` is the configured
    /// `[forge].platform` (see [`Repository::forge_platform_override`]); it lets
    /// a self-hosted instance whose host can't be auto-detected still report the
    /// right provider.
    pub(crate) fn from_pr_status(pr: &PrStatus, provider_override: Option<&str>) -> Self {
        let repo = super::json_v2::pr_target_repo(pr.url.as_deref(), provider_override);
        Self {
            status: pr.ci_status.into(),
            source: pr.source,
            number: pr.number.map(|r| r.number),
            stale: pr.is_stale,
            repo_url: repo.as_ref().map(|repo| repo.url.clone()),
            repo,
            url: pr.url.clone(),
            review_state: pr.review_state,
            // TODO(json-pr-title-body): `PrStatus` now carries `title`/`body`
            // and `comment_count` (for the picker's `pr` preview pane), but
            // they're deliberately not surfaced here — `wt list --json` stays
            // scoped to CI/review status. Add them (with
            // `skip_serializing_if = "Option::is_none"` plus a row in the
            // docs/content/list.md ci-object table) if a JSON consumer needs them.
        }
    }
}

/// Format status symbols as raw characters (no ANSI codes).
///
/// Unresolved gates (`None` fields) contribute nothing — their symbols are
/// simply absent from the output string, not replaced by a placeholder.
/// This matches the per-symbol atomic model: machine consumers see only the
/// symbols that have been computed so far.
///
/// Shared with `json_v2` (the `display.symbols` field).
pub(crate) fn format_raw_symbols(symbols: &super::model::StatusSymbols) -> String {
    let mut result = String::new();

    // Working tree symbols (gate 1)
    if let Some(wt) = symbols.working_tree {
        result.push_str(&wt.to_symbols());
    }

    // Main state (gate 3) — merged column: ^_⊂✗↕↑↓
    if let Some(ms) = symbols.main_state {
        let s = ms.to_string();
        if !s.is_empty() {
            result.push_str(&s);
        }
    }

    // Upstream divergence (gate 4)
    if let Some(div) = symbols.upstream_divergence {
        let s = div.symbol();
        if !s.is_empty() {
            result.push_str(s);
        }
    }

    // Worktree state (gate 2) — operations (✘⤴⤵) take priority over
    // location (/⚑⊟⊞). Gate 2 is "operation_state is Some"; the metadata
    // worktree_state is filled synchronously and always Some by the time
    // the operation family is known.
    if let Some(op) = symbols.operation_state {
        let s = op.to_string();
        if !s.is_empty() {
            result.push_str(&s);
        } else if let Some(wt_state) = symbols.worktree_state {
            let s = wt_state.to_string();
            if !s.is_empty() {
                result.push_str(&s);
            }
        }
    }

    // User marker (gate 5)
    if let Some(Some(ref marker)) = symbols.user_marker {
        result.push_str(marker);
    }

    result
}

/// Convert a list of ListItems to JSON output
///
/// Reads all vars from the bulk config snapshot (no subprocess, and —
/// unlike the line-parsed `--get-regexp` read — multiline values survive),
/// then distributes per-branch. The same source feeds the rendered `columns`
/// values, so the two fields can't diverge. The snapshot was loaded earlier
/// in this invocation; an error here means the bulk read failed, in which
/// case `collect()` already surfaced it, so the empty fallback is dead code.
pub fn to_json_items(
    items: &[ListItem],
    custom_columns: &[ResolvedCustomColumn],
    repo: &Repository,
) -> Vec<JsonItem> {
    let mut all_vars = repo.all_vars_from_snapshot().unwrap_or_default();
    let repo_metadata = repo.repo_info();
    let ci_provider_override = repo.forge_platform_override();
    items
        .iter()
        .map(|item| {
            JsonItem::from_list_item(
                item,
                &mut all_vars,
                repo_metadata.as_ref(),
                ci_provider_override.as_deref(),
                custom_columns,
            )
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use insta::assert_snapshot;
    use worktrunk::git::GitRepoProvider;

    use super::*;
    use crate::commands::list::ci_status::{CiStatus, PrRef};
    use crate::commands::list::model::{
        ActiveGitOperation, Divergence, MainState, OperationState, StatusSymbols,
        WorkingTreeStatus, WorktreeData, WorktreeState,
    };

    // ============================================================================
    // JsonDiff Tests
    // ============================================================================

    #[test]
    fn test_json_diff_from_line_diff() {
        let nonzero = JsonDiff::from(LineDiff {
            added: 10,
            deleted: 5,
        });
        assert_eq!(nonzero.added, 10);
        assert_eq!(nonzero.deleted, 5);

        let zeros = JsonDiff::from(LineDiff {
            added: 0,
            deleted: 0,
        });
        assert_eq!(zeros.added, 0);
        assert_eq!(zeros.deleted, 0);
    }

    // ============================================================================
    // JsonCi::from_pr_status Tests
    // ============================================================================

    #[test]
    fn test_json_ci_from_pr_status() {
        // Full field mapping with URL
        let passed = JsonCi::from_pr_status(
            &PrStatus {
                ci_status: CiStatus::Passed,
                source: CiSource::PullRequest,
                is_stale: false,
                is_priming: false,
                url: Some("https://github.com/org/repo/pull/123".to_string()),
                number: Some(PrRef::pr(123)),
                review_state: None,
                title: None,
                body: None,
                author: None,
                comment_count: None,
                updated_at: None,
            },
            None,
        );
        assert_eq!(passed.status, "passed");
        assert_eq!(passed.source, CiSource::PullRequest);
        assert!(!passed.stale);
        assert_eq!(
            passed.url,
            Some("https://github.com/org/repo/pull/123".to_string())
        );
        // repo_url is derived from the PR URL (the target repo).
        assert_eq!(
            passed.repo_url,
            Some("https://github.com/org/repo".to_string())
        );
        assert_eq!(
            passed.repo,
            Some(GitRepoInfo {
                url: "https://github.com/org/repo".to_string(),
                provider: GitRepoProvider::GitHub,
                host: "github.com".to_string(),
                owner: "org".to_string(),
                name: "repo".to_string(),
                project: None,
                remote: None,
            })
        );

        // Stale branch with no URL
        let failed = JsonCi::from_pr_status(
            &PrStatus {
                ci_status: CiStatus::Failed,
                source: CiSource::Branch,
                is_stale: true,
                is_priming: false,
                url: None,
                number: None,
                review_state: None,
                title: None,
                body: None,
                author: None,
                comment_count: None,
                updated_at: None,
            },
            None,
        );
        assert_eq!(failed.status, "failed");
        assert_eq!(failed.source, CiSource::Branch);
        assert!(failed.stale);
        assert!(failed.url.is_none());
        // No PR URL means no derived repo_url or repo.
        assert!(failed.repo_url.is_none());
        assert!(failed.repo.is_none());

        // All status string mappings
        let status_mappings = [
            (CiStatus::Running, "running"),
            (CiStatus::Conflicts, "conflicts"),
            (CiStatus::NoCI, "no-ci"),
            (CiStatus::Error, "error"),
        ];
        for (ci_status, expected) in status_mappings {
            let json = JsonCi::from_pr_status(
                &PrStatus {
                    ci_status,
                    source: CiSource::Branch,
                    is_stale: false,
                    is_priming: false,
                    url: None,
                    number: None,
                    review_state: None,
                    title: None,
                    body: None,
                    author: None,
                    comment_count: None,
                    updated_at: None,
                },
                None,
            );
            assert_eq!(json.status, expected);
        }
    }

    /// The configured `[forge].platform` override decides the provider when the
    /// PR URL's host can't be auto-detected. Without the override an opaque-host
    /// `pull` URL is `Unknown`; with `github` it resolves to GitHub.
    #[test]
    fn test_json_ci_from_pr_status_provider_override() {
        let pr = PrStatus {
            ci_status: CiStatus::Passed,
            source: CiSource::PullRequest,
            is_stale: false,
            is_priming: false,
            url: Some("https://git.example.com/org/repo/pull/7".to_string()),
            number: Some(PrRef::pr(7)),
            review_state: None,
            title: None,
            body: None,
            author: None,
            comment_count: None,
            updated_at: None,
        };

        let without = JsonCi::from_pr_status(&pr, None);
        assert_eq!(
            without.repo.as_ref().map(|r| r.provider),
            Some(GitRepoProvider::Unknown)
        );

        let with = JsonCi::from_pr_status(&pr, Some("github"));
        assert_eq!(
            with.repo.as_ref().map(|r| r.provider),
            Some(GitRepoProvider::GitHub)
        );
    }

    // ============================================================================
    // upstream_to_json Tests
    // ============================================================================

    #[test]
    fn test_upstream_to_json_with_remote() {
        let upstream = UpstreamStatus {
            remote: Some("origin".to_string()),
            ahead: 3,
            behind: 2,
            ..Default::default()
        };
        let branch = Some("feature".to_string());
        let json = upstream_to_json(&upstream, &branch);
        assert!(json.is_some());
        let json = json.unwrap();
        assert_eq!(json.name, "origin");
        assert_eq!(json.branch, "feature");
        assert_eq!(json.ahead, 3);
        assert_eq!(json.behind, 2);
    }

    #[test]
    fn test_upstream_to_json_no_remote() {
        let upstream = UpstreamStatus {
            remote: None,
            ahead: 0,
            behind: 0,
            ..Default::default()
        };
        let branch = Some("feature".to_string());
        let json = upstream_to_json(&upstream, &branch);
        assert!(json.is_none());
    }

    #[test]
    fn test_upstream_to_json_no_branch() {
        let upstream = UpstreamStatus {
            remote: Some("origin".to_string()),
            ahead: 1,
            behind: 0,
            ..Default::default()
        };
        let branch = None;
        let json = upstream_to_json(&upstream, &branch);
        assert!(json.is_some());
        let json = json.unwrap();
        assert_eq!(json.branch, ""); // Empty string when branch is None
    }

    // ============================================================================
    // worktree_state_to_json Tests
    // ============================================================================

    fn make_worktree_data() -> WorktreeData {
        WorktreeData {
            path: PathBuf::from("/test/path"),
            is_main: false,
            is_current: false,
            is_previous: false,
            detached: false,
            locked: None,
            prunable: None,
            working_tree_diff: None,
            working_tree_status: None,
            has_conflicts: None,
            has_working_tree_conflicts: None,
            git_operation: Some(ActiveGitOperation::None),
            branch_worktree_mismatch: false,
        }
    }

    fn make_status_symbols_with_worktree_state(state: WorktreeState) -> StatusSymbols {
        StatusSymbols {
            working_tree: Some(WorkingTreeStatus::default()),
            worktree_state: Some(state),
            main_state: Some(MainState::None),
            operation_state: Some(OperationState::None),
            upstream_divergence: Some(Divergence::None),
            user_marker: Some(None),
        }
    }

    #[test]
    fn test_worktree_state_to_json_none() {
        let data = make_worktree_data();
        let symbols = make_status_symbols_with_worktree_state(WorktreeState::None);
        let (state, reason) = worktree_state_to_json(&data, &symbols);
        assert!(state.is_none());
        assert!(reason.is_none());
    }

    #[test]
    fn test_worktree_state_to_json_no_worktree() {
        let data = make_worktree_data();
        let symbols = make_status_symbols_with_worktree_state(WorktreeState::Branch);
        let (state, reason) = worktree_state_to_json(&data, &symbols);
        assert_eq!(state, Some("no_worktree"));
        assert!(reason.is_none());
    }

    #[test]
    fn test_worktree_state_to_json_branch_worktree_mismatch() {
        let data = make_worktree_data();
        let symbols =
            make_status_symbols_with_worktree_state(WorktreeState::BranchWorktreeMismatch);
        let (state, reason) = worktree_state_to_json(&data, &symbols);
        assert_eq!(state, Some("branch_worktree_mismatch"));
        assert!(reason.is_none());
    }

    #[test]
    fn test_worktree_state_to_json_locked() {
        let mut data = make_worktree_data();
        data.locked = Some("manual lock".to_string());
        let symbols = make_status_symbols_with_worktree_state(WorktreeState::Locked);
        let (state, reason) = worktree_state_to_json(&data, &symbols);
        assert_eq!(state, Some("locked"));
        assert_eq!(reason, Some("manual lock".to_string()));
    }

    #[test]
    fn test_worktree_state_to_json_prunable() {
        let mut data = make_worktree_data();
        data.prunable = Some("gitdir file missing".to_string());
        let symbols = make_status_symbols_with_worktree_state(WorktreeState::Prunable);
        let (state, reason) = worktree_state_to_json(&data, &symbols);
        assert_eq!(state, Some("prunable"));
        assert_eq!(reason, Some("gitdir file missing".to_string()));
    }

    #[test]
    fn test_worktree_state_to_json_fallback_prunable() {
        let mut data = make_worktree_data();
        data.prunable = Some("missing gitdir".to_string());
        // Default status symbols — fallback to data fields
        let symbols = StatusSymbols::default();
        let (state, reason) = worktree_state_to_json(&data, &symbols);
        assert_eq!(state, Some("prunable"));
        assert_eq!(reason, Some("missing gitdir".to_string()));
    }

    #[test]
    fn test_worktree_state_to_json_fallback_locked() {
        let mut data = make_worktree_data();
        data.locked = Some("in use".to_string());
        let symbols = StatusSymbols::default();
        let (state, reason) = worktree_state_to_json(&data, &symbols);
        assert_eq!(state, Some("locked"));
        assert_eq!(reason, Some("in use".to_string()));
    }

    // ============================================================================
    // format_raw_symbols Tests
    // ============================================================================

    #[test]
    fn test_format_raw_symbols_empty() {
        let symbols = StatusSymbols::default();
        assert!(format_raw_symbols(&symbols).is_empty());
    }

    #[test]
    fn test_format_raw_symbols_each_category() {
        let working_tree = format_raw_symbols(&StatusSymbols {
            working_tree: Some(WorkingTreeStatus::new(true, true, true, false, false)),
            ..Default::default()
        });
        assert_snapshot!(working_tree, @"+!?");

        let main_state = format_raw_symbols(&StatusSymbols {
            main_state: Some(MainState::Ahead),
            ..Default::default()
        });
        assert_snapshot!(main_state, @"↑");

        let upstream = format_raw_symbols(&StatusSymbols {
            upstream_divergence: Some(Divergence::Behind),
            ..Default::default()
        });
        assert_snapshot!(upstream, @"⇣");

        // Operation state takes priority over worktree state
        let operation = format_raw_symbols(&StatusSymbols {
            operation_state: Some(OperationState::Rebase),
            ..Default::default()
        });
        assert_snapshot!(operation, @"⤴");

        // Worktree metadata renders only once the operation-family gate
        // has resolved to "no operation" — otherwise we can't rule out
        // ✘⤴⤵ taking priority. Callers that want just the metadata
        // symbol must set both `operation_state` and `worktree_state`.
        let worktree = format_raw_symbols(&StatusSymbols {
            operation_state: Some(OperationState::None),
            worktree_state: Some(WorktreeState::Locked),
            ..Default::default()
        });
        assert_snapshot!(worktree, @"⊞");

        let marker = format_raw_symbols(&StatusSymbols {
            user_marker: Some(Some("\u{1f525}".to_string())),
            ..Default::default()
        });
        assert_snapshot!(marker, @"🔥");
    }

    #[test]
    fn test_format_raw_symbols_combined() {
        let result = format_raw_symbols(&StatusSymbols {
            working_tree: Some(WorkingTreeStatus::new(true, false, false, false, false)),
            main_state: Some(MainState::Behind),
            upstream_divergence: Some(Divergence::Ahead),
            ..Default::default()
        });
        assert_snapshot!(result, @"+↓⇡");
    }

    // ============================================================================
    // JSON Serialization Tests
    // ============================================================================

    #[test]
    fn test_json_serialization() {
        let commit = serde_json::to_string_pretty(&JsonCommit {
            sha: "abc123def456".to_string(),
            short_sha: "abc123d".to_string(),
            message: "Fix bug".to_string(),
            timestamp: 1700000000,
        })
        .unwrap();
        assert_snapshot!(commit, @r#"
        {
          "sha": "abc123def456",
          "short_sha": "abc123d",
          "message": "Fix bug",
          "timestamp": 1700000000
        }
        "#);

        let working_tree = serde_json::to_string_pretty(&JsonWorkingTree {
            staged: true,
            modified: false,
            untracked: true,
            renamed: false,
            deleted: false,
            diff: Some(JsonDiff {
                added: 10,
                deleted: 5,
            }),
        })
        .unwrap();
        assert_snapshot!(working_tree, @r#"
        {
          "staged": true,
          "modified": false,
          "untracked": true,
          "renamed": false,
          "deleted": false,
          "diff": {
            "added": 10,
            "deleted": 5
          }
        }
        "#);

        let main = serde_json::to_string_pretty(&JsonMain {
            ahead: 3,
            behind: 1,
            diff: Some(JsonDiff {
                added: 50,
                deleted: 20,
            }),
        })
        .unwrap();
        assert_snapshot!(main, @r#"
        {
          "ahead": 3,
          "behind": 1,
          "diff": {
            "added": 50,
            "deleted": 20
          }
        }
        "#);

        let remote = serde_json::to_string_pretty(&JsonRemote {
            name: "origin".to_string(),
            branch: "feature".to_string(),
            ahead: 2,
            behind: 0,
        })
        .unwrap();
        assert_snapshot!(remote, @r#"
        {
          "name": "origin",
          "branch": "feature",
          "ahead": 2,
          "behind": 0
        }
        "#);

        let worktree = serde_json::to_string_pretty(&JsonWorktree {
            state: Some("locked"),
            reason: Some("manual".to_string()),
            detached: false,
        })
        .unwrap();
        assert_snapshot!(worktree, @r#"
        {
          "state": "locked",
          "reason": "manual",
          "detached": false
        }
        "#);
    }

    fn test_repo() -> (worktrunk::testing::TestRepo, Repository) {
        let t = worktrunk::testing::TestRepo::new();
        let repo = Repository::at(t.path()).unwrap();
        (t, repo)
    }

    #[test]
    fn test_json_item_summary_present() {
        let (_tmp, repo) = test_repo();
        let mut all_vars = repo.all_vars_entries();

        let mut item = ListItem::new_branch("abc1234".into(), "feature".into());
        item.summary = Some(Some("Add login page".to_string()));

        // repo_url/repo are set when provided, absent (skipped) when None.
        let json_item = JsonItem::from_list_item(&item, &mut all_vars, None, None, &[]);
        assert_eq!(json_item.summary, Some("Add login page".to_string()));
        assert!(json_item.repo_url.is_none());
        assert!(json_item.repo.is_none());

        let repo = GitRepoInfo {
            url: "https://github.com/owner/repo".to_string(),
            provider: GitRepoProvider::GitHub,
            host: "github.com".to_string(),
            owner: "owner".to_string(),
            name: "repo".to_string(),
            project: None,
            remote: Some("origin".to_string()),
        };
        let json_item = JsonItem::from_list_item(&item, &mut all_vars, Some(&repo), None, &[]);
        assert_eq!(
            json_item.repo_url,
            Some("https://github.com/owner/repo".to_string())
        );
        assert_eq!(json_item.repo, Some(repo));
    }

    #[test]
    fn test_json_item_summary_absent() {
        let (_tmp, repo) = test_repo();
        let mut all_vars = repo.all_vars_entries();

        let mut item = ListItem::new_branch("abc1234".into(), "feature".into());
        // Both "not collected" and "no summary" should be absent in JSON
        assert!(
            JsonItem::from_list_item(&item, &mut all_vars, None, None, &[])
                .summary
                .is_none()
        );

        item.summary = Some(None);
        assert!(
            JsonItem::from_list_item(&item, &mut all_vars, None, None, &[])
                .summary
                .is_none()
        );
    }

    #[test]
    fn test_json_ci_serialization() {
        let json = serde_json::to_string_pretty(&JsonCi {
            status: "passed",
            source: CiSource::PullRequest,
            number: Some(7),
            stale: false,
            url: Some("https://github.com/org/repo/pull/7".to_string()),
            review_state: Some(ReviewState::ChangesRequested),
            repo_url: Some("https://github.com/org/repo".to_string()),
            repo: Some(GitRepoInfo {
                url: "https://github.com/org/repo".to_string(),
                provider: GitRepoProvider::GitHub,
                host: "github.com".to_string(),
                owner: "org".to_string(),
                name: "repo".to_string(),
                project: None,
                remote: None,
            }),
        })
        .unwrap();
        // review_state vocabulary matches Claude Code's statusline `pr.review_state`
        assert_snapshot!(json, @r#"
        {
          "status": "passed",
          "source": "pr",
          "number": 7,
          "stale": false,
          "url": "https://github.com/org/repo/pull/7",
          "repo_url": "https://github.com/org/repo",
          "repo": {
            "url": "https://github.com/org/repo",
            "provider": "github",
            "host": "github.com",
            "owner": "org",
            "name": "repo"
          },
          "review_state": "changes_requested"
        }
        "#);
    }
}
