//! GitHub context extraction from Bash tool events.
//!
//! This module parses Bash commands and their output to extract GitHub issue/PR
//! references and worktree information. This provides more accurate context than
//! branch name parsing alone, capturing:
//!
//! - Explicit `gh issue/pr` commands
//! - GitHub URLs in command output (e.g., from `gh pr create`)
//! - `Fixes #N` references in PR bodies
//! - Git worktree operations
//!
//! # Fields Populated
//!
//! | Field | Description | Sources |
//! |-------|-------------|---------|
//! | `github_issue` | Issue number | `gh` commands, URLs, PR body |
//! | `github_pr` | PR number | `gh` commands, URLs |
//! | `github_repo` | Repository (`owner/repo`) | `gh --repo` flag, GitHub URLs |
//! | `git_worktree_path` | Worktree directory path | `git worktree add`, `git -C`, `cd` |
//! | `git_branch` | Current branch | Set when capturing PR from `gh pr create` |
//!
//! # GitHub CLI Command Parsing
//!
//! The [`parse_gh_command`] function parses `gh` CLI commands to extract explicit
//! issue/PR references.
//!
//! ## Supported Commands
//!
//! ```text
//! gh issue {view,checks,diff,merge,close,edit,reopen,comment} <number> [--repo owner/repo]
//! gh pr {view,checks,diff,merge,close,edit,reopen,comment} <number> [--repo owner/repo]
//! ```
//!
//! **Regex:** `gh\s+(issue|pr)\s+(view|checks|diff|merge|close|edit|reopen|comment)\s+(\d+)(?:.*--repo\s+([^\s]+))?`
//!
//! ## PR Body References
//!
//! When `gh pr create --body "..."` is detected, the body is scanned for issue references:
//!
//! ```text
//! Closes #N
//! Fixes #N
//! Resolves #N
//! ```
//!
//! **Regex (case-insensitive):** `(?i)\b(closes?|fixes?|resolves?)\s*#(\d+)`
//!
//! ## Examples
//!
//! ```text
//! gh issue view 52                          -> issue: 52
//! gh issue view 52 --repo paradigmxyz/mi6   -> issue: 52, repo: paradigmxyz/mi6
//! gh pr checks 86                           -> pr: 86
//! gh pr view 64 --repo paradigmxyz/mi6      -> pr: 64, repo: paradigmxyz/mi6
//! gh pr create --title "Fix" --body "Fixes #52"  -> issue: 52
//! ```
//!
//! # GitHub URL Parsing
//!
//! The [`parse_github_urls`] function extracts repo, issue, and PR from GitHub URLs
//! in command output.
//!
//! **Regex:** `https://github\.com/([^/]+/[^/]+)/(issues|pull)/(\d+)`
//!
//! ## Examples
//!
//! ```text
//! https://github.com/paradigmxyz/mi6/issues/236  -> issue: 236, repo: paradigmxyz/mi6
//! https://github.com/paradigmxyz/mi6/pull/237    -> pr: 237, repo: paradigmxyz/mi6
//! ```
//!
//! This is particularly useful for capturing context from `gh pr create` output:
//! ```text
//! Created https://github.com/paradigmxyz/mi6/pull/237
//! ```
//!
//! **Note:** URLs are only parsed from output of creation commands (`gh issue create`,
//! `gh pr create`) to avoid capturing incidental URLs from CI logs or code comments.
//!
//! # Worktree and Directory Parsing
//!
//! ## Git Worktree Add
//!
//! The [`parse_worktree_add`] function extracts path and branch from worktree commands.
//!
//! **Regex:** `git\s+worktree\s+add\s+(?:-f\s+)?([^\s]+)(?:.*-b\s+([^\s]+))?`
//!
//! ```text
//! git worktree add ../mi6-issue-42
//!   -> path: /current/dir/../mi6-issue-42
//!
//! git worktree add ../mi6-fix -b fix/issue-42 origin/main
//!   -> path: /current/dir/../mi6-fix
//!   -> branch: fix/issue-42
//! ```
//!
//! ## Git -C Path
//!
//! The [`parse_git_c_path`] function extracts the path from `git -C <path>` commands.
//!
//! **Regex:** `git\s+-C\s+([^\s]+)`
//!
//! ## CD Command
//!
//! The [`parse_cd_path`] function parses cd commands with quoted/unquoted paths.
//!
//! **Regex:** `cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))`
//!
//! ## Path Resolution
//!
//! All paths are resolved:
//! - `~` is expanded to `$HOME`
//! - Relative paths are joined with the session's `cwd`
//!
//! # Update Semantics
//!
//! Updates use SQL `COALESCE` semantics:
//! - New non-NULL values overwrite existing values
//! - NULL values preserve existing values
//! - "Most recent wins" for non-NULL values
//!
//! # Chained Command Support
//!
//! Commands are split on `&&`, `||`, and `;` to detect references in compound commands:
//!
//! ```text
//! echo foo && gh issue view 42   -> detected
//! git status; gh pr view 86      -> detected
//! ```
//!
//! # Limitations
//!
//! 1. **Chained commands** - Simple split on delimiters; doesn't handle quoted delimiters
//! 2. **Path resolution** - Doesn't canonicalize paths (may contain `..`)
//! 3. **Tool output truncation** - Very long output may be truncated before URL parsing
//!
//! # Usage
//!
//! ```rust,ignore
//! use mi6_core::github::{extract_context, GitContextUpdate};
//! use std::path::Path;
//!
//! let cwd = Path::new("/home/user/project");
//! let update = extract_context(
//!     "gh issue view 52 --repo paradigmxyz/mi6",
//!     None,
//!     cwd,
//! );
//!
//! assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
//! assert_eq!(update.github_issue, Some(52));
//! ```

use std::path::Path;
use std::sync::LazyLock;

use regex::Regex;

/// Git and GitHub context update derived from a Bash tool event.
///
/// This struct captures both git-related context (branch, worktree) and
/// GitHub-specific context (repo, issue, PR) extracted from shell commands
/// and their output.
///
/// Each field is `Some` only if the parsing found a new value.
/// Use with COALESCE semantics to update session fields.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct GitContextUpdate {
    /// GitHub repository in `owner/repo` format
    pub github_repo: Option<String>,
    /// GitHub issue number
    pub github_issue: Option<i32>,
    /// GitHub PR number
    pub github_pr: Option<i32>,
    /// Lines added (from git diff --shortstat or gh pr view)
    pub git_additions: Option<i32>,
    /// Lines deleted (from git diff --shortstat or gh pr view)
    pub git_deletions: Option<i32>,
    /// Absolute path to git worktree
    pub worktree_path: Option<String>,
    /// Branch name in the worktree
    pub worktree_branch: Option<String>,
    /// Current git branch (set when capturing PR from gh pr create)
    pub git_branch: Option<String>,
}

impl GitContextUpdate {
    /// Returns true if this update has any values
    pub fn has_values(&self) -> bool {
        self.github_repo.is_some()
            || self.github_issue.is_some()
            || self.github_pr.is_some()
            || self.git_additions.is_some()
            || self.git_deletions.is_some()
            || self.worktree_path.is_some()
            || self.worktree_branch.is_some()
            || self.git_branch.is_some()
    }

    /// Merge another update into this one (other values take precedence)
    pub fn merge(&mut self, other: GitContextUpdate) {
        if other.github_repo.is_some() {
            self.github_repo = other.github_repo;
        }
        if other.github_issue.is_some() {
            self.github_issue = other.github_issue;
        }
        if other.github_pr.is_some() {
            self.github_pr = other.github_pr;
        }
        if other.git_additions.is_some() {
            self.git_additions = other.git_additions;
        }
        if other.git_deletions.is_some() {
            self.git_deletions = other.git_deletions;
        }
        if other.worktree_path.is_some() {
            self.worktree_path = other.worktree_path;
        }
        if other.worktree_branch.is_some() {
            self.worktree_branch = other.worktree_branch;
        }
        if other.git_branch.is_some() {
            self.git_branch = other.git_branch;
        }
    }
}

/// Deprecated: Use [`GitContextUpdate`] instead.
#[deprecated(since = "0.4.0", note = "Renamed to GitContextUpdate for clarity")]
pub type SessionContextUpdate = GitContextUpdate;

// Lazy-compiled regex patterns.
// These patterns are static literals that are verified at compile time,
// so expect() is safe here.

#[expect(
    clippy::expect_used,
    reason = "static regex pattern verified at compile time"
)]
static GH_ISSUE_PR_RE: LazyLock<Regex> = LazyLock::new(|| {
    // gh issue/pr {view,checks,diff,merge,close,edit,reopen,comment} N [--repo R]
    Regex::new(
        r"gh\s+(issue|pr)\s+(view|checks|diff|merge|close|edit|reopen|comment)\s+(\d+)(?:.*--repo\s+([^\s]+))?"
    ).expect("invalid regex")
});

#[expect(
    clippy::expect_used,
    reason = "static regex pattern verified at compile time"
)]
static GITHUB_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
    // https://github.com/owner/repo/issues/N or /pull/N
    Regex::new(r"https://github\.com/([^/]+/[^/]+)/(issues|pull)/(\d+)").expect("invalid regex")
});

#[expect(
    clippy::expect_used,
    reason = "static regex pattern verified at compile time"
)]
static CLOSES_RE: LazyLock<Regex> = LazyLock::new(|| {
    // Closes/Fixes/Resolves #N (case-insensitive)
    Regex::new(r"(?i)\b(closes?|fixes?|resolves?)\s*#(\d+)").expect("invalid regex")
});

#[expect(
    clippy::expect_used,
    reason = "static regex pattern verified at compile time"
)]
static GH_PR_CREATE_BODY_RE: LazyLock<Regex> = LazyLock::new(|| {
    // gh pr create ... --body "..." or --body '...'
    // Capture the body content for scanning
    Regex::new(r#"gh\s+pr\s+create\s+.*--body\s+(?:"([^"]*)"|'([^']*)')"#).expect("invalid regex")
});

#[expect(
    clippy::expect_used,
    reason = "static regex pattern verified at compile time"
)]
static GIT_WORKTREE_ADD_RE: LazyLock<Regex> = LazyLock::new(|| {
    // git worktree add [-f] <path> [-b <branch>] [<commit>]
    Regex::new(r"git\s+worktree\s+add\s+(?:-f\s+)?([^\s]+)(?:.*-b\s+([^\s]+))?")
        .expect("invalid regex")
});

#[expect(
    clippy::expect_used,
    reason = "static regex pattern verified at compile time"
)]
static GIT_C_PATH_RE: LazyLock<Regex> = LazyLock::new(|| {
    // git -C <path> ...
    Regex::new(r"git\s+-C\s+([^\s]+)").expect("invalid regex")
});

#[expect(
    clippy::expect_used,
    reason = "static regex pattern verified at compile time"
)]
static CD_PATH_RE: LazyLock<Regex> = LazyLock::new(|| {
    // cd <path> with optional quotes
    Regex::new(r#"cd\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))"#).expect("invalid regex")
});

#[expect(
    clippy::expect_used,
    reason = "static regex pattern verified at compile time"
)]
static GH_CREATE_RE: LazyLock<Regex> = LazyLock::new(|| {
    // gh issue create or gh pr create - commands that output URLs we want to capture
    Regex::new(r"gh\s+(issue|pr)\s+create").expect("invalid regex")
});

#[expect(
    clippy::expect_used,
    reason = "static regex pattern verified at compile time"
)]
static GH_PR_VIEW_ADDITIONS_RE: LazyLock<Regex> = LazyLock::new(|| {
    // gh pr view output: "additions:	148" or "additions:  148"
    Regex::new(r"additions:\s+(\d+)").expect("invalid regex")
});

#[expect(
    clippy::expect_used,
    reason = "static regex pattern verified at compile time"
)]
static GH_PR_VIEW_DELETIONS_RE: LazyLock<Regex> = LazyLock::new(|| {
    // gh pr view output: "deletions:	7" or "deletions:  7"
    Regex::new(r"deletions:\s+(\d+)").expect("invalid regex")
});

#[expect(
    clippy::expect_used,
    reason = "static regex pattern verified at compile time"
)]
static GH_PR_VIEW_RE: LazyLock<Regex> = LazyLock::new(|| {
    // Match "gh pr view" command
    Regex::new(r"gh\s+pr\s+view\b").expect("invalid regex")
});

/// Parse a Bash command for gh issue/pr references.
///
/// Handles commands like:
/// - `gh issue view 52`
/// - `gh pr checks 86 --repo paradigmxyz/mi6`
/// - `gh issue close 42`
pub fn parse_gh_command(command: &str) -> GitContextUpdate {
    let mut update = GitContextUpdate::default();

    // Split command on && || ; to handle chained commands
    for part in split_chained_commands(command) {
        if let Some(caps) = GH_ISSUE_PR_RE.captures(part) {
            let cmd_type = caps.get(1).map(|m| m.as_str());
            let number_str = caps.get(3).map(|m| m.as_str());
            let repo = caps.get(4).map(|m| m.as_str().to_string());

            if let Some(num) = number_str.and_then(|s| s.parse::<i32>().ok()) {
                match cmd_type {
                    Some("issue") => update.github_issue = Some(num),
                    Some("pr") => update.github_pr = Some(num),
                    _ => {}
                }
            }

            if let Some(r) = repo {
                update.github_repo = Some(r);
            }
        }

        // Also check for gh pr create --body "... Fixes #N ..."
        if let Some(caps) = GH_PR_CREATE_BODY_RE.captures(part) {
            let body = caps.get(1).or_else(|| caps.get(2)).map(|m| m.as_str());
            if let Some(body_text) = body {
                let issues = parse_closes_references(body_text);
                if let Some(&first_issue) = issues.first() {
                    update.github_issue = Some(first_issue);
                }
            }
        }
    }

    update
}

/// Parse tool output for GitHub URLs.
///
/// Extracts repo, issue, and PR from URLs like:
/// - `https://github.com/paradigmxyz/mi6/issues/236`
/// - `https://github.com/paradigmxyz/mi6/pull/237`
pub fn parse_github_urls(output: &str) -> GitContextUpdate {
    let mut update = GitContextUpdate::default();

    for caps in GITHUB_URL_RE.captures_iter(output) {
        let repo = caps.get(1).map(|m| m.as_str().to_string());
        let url_type = caps.get(2).map(|m| m.as_str());
        let number_str = caps.get(3).map(|m| m.as_str());

        if let Some(num) = number_str.and_then(|s| s.parse::<i32>().ok()) {
            match url_type {
                Some("issues") => update.github_issue = Some(num),
                Some("pull") => update.github_pr = Some(num),
                _ => {}
            }
        }

        if let Some(r) = repo {
            update.github_repo = Some(r);
        }
    }

    update
}

/// Parse PR body text for "Closes #N" references.
///
/// Returns a list of issue numbers found.
pub fn parse_closes_references(body: &str) -> Vec<i32> {
    CLOSES_RE
        .captures_iter(body)
        .filter_map(|caps| caps.get(2).and_then(|m| m.as_str().parse::<i32>().ok()))
        .collect()
}

/// Parse git worktree add command.
///
/// Extracts path and optional branch from commands like:
/// - `git worktree add ../foo-issue-42`
/// - `git worktree add ../foo -b fix/issue-42`
/// - `git worktree add -f /path/to/worktree`
pub fn parse_worktree_add(command: &str, cwd: &Path) -> Option<(String, Option<String>)> {
    for part in split_chained_commands(command) {
        if let Some(caps) = GIT_WORKTREE_ADD_RE.captures(part) {
            let path_str = caps.get(1)?.as_str();
            let branch = caps.get(2).map(|m| m.as_str().to_string());

            // Resolve path relative to cwd
            let resolved = resolve_path(path_str, cwd);
            return Some((resolved, branch));
        }
    }
    None
}

/// Parse `git -C <path>` from a command.
///
/// Returns the path argument if found.
pub fn parse_git_c_path(command: &str, cwd: &Path) -> Option<String> {
    for part in split_chained_commands(command) {
        if let Some(caps) = GIT_C_PATH_RE.captures(part) {
            let path_str = caps.get(1)?.as_str();
            return Some(resolve_path(path_str, cwd));
        }
    }
    None
}

/// Parse cd command for directory changes.
///
/// Handles quoted and unquoted paths:
/// - `cd /path/to/dir`
/// - `cd "/path with spaces/dir"`
/// - `cd '/path/dir'`
pub fn parse_cd_path(command: &str, cwd: &Path) -> Option<String> {
    for part in split_chained_commands(command) {
        if let Some(caps) = CD_PATH_RE.captures(part) {
            // Match groups: 1=double-quoted, 2=single-quoted, 3=unquoted
            let path_str = caps
                .get(1)
                .or_else(|| caps.get(2))
                .or_else(|| caps.get(3))?
                .as_str();
            return Some(resolve_path(path_str, cwd));
        }
    }
    None
}

/// Parse gh pr view output for additions/deletions.
///
/// Handles output containing:
/// ```text
/// additions:  148
/// deletions:  7
/// ```
pub fn parse_gh_pr_view_stats(output: &str) -> GitContextUpdate {
    let mut update = GitContextUpdate::default();

    if let Some(caps) = GH_PR_VIEW_ADDITIONS_RE.captures(output)
        && let Some(m) = caps.get(1)
        && let Ok(additions) = m.as_str().parse::<i32>()
    {
        update.git_additions = Some(additions);
    }

    if let Some(caps) = GH_PR_VIEW_DELETIONS_RE.captures(output)
        && let Some(m) = caps.get(1)
        && let Ok(deletions) = m.as_str().parse::<i32>()
    {
        update.git_deletions = Some(deletions);
    }

    update
}

/// Check if the command is a gh pr view command.
///
/// These commands output additions/deletions stats.
pub fn is_gh_pr_view_command(command: &str) -> bool {
    for part in split_chained_commands(command) {
        if GH_PR_VIEW_RE.is_match(part) {
            return true;
        }
    }
    false
}

/// Check if the command is one that creates GitHub resources and outputs URLs.
///
/// Only these commands should have their output parsed for GitHub URLs:
/// - `gh issue create` - outputs the created issue URL
/// - `gh pr create` - outputs the created PR URL
///
/// Other commands (like `gh run view`, `cat`, etc.) may have GitHub URLs in their
/// output, but these are incidental and shouldn't be treated as session context.
fn should_parse_output_urls(command: &str) -> bool {
    for part in split_chained_commands(command) {
        if GH_CREATE_RE.is_match(part) {
            return true;
        }
    }
    false
}

/// Extract all context from a Bash tool event.
///
/// Combines command parsing and output parsing to extract:
/// - GitHub repo, issue, and PR references
/// - Worktree path and branch
/// - Diff statistics (additions/deletions)
///
/// # Arguments
/// * `command` - The Bash command (from event payload)
/// * `output` - The tool output (from event tool_output), if any
/// * `cwd` - Current working directory for resolving relative paths
pub fn extract_context(command: &str, output: Option<&str>, cwd: &Path) -> GitContextUpdate {
    let mut update = GitContextUpdate::default();

    // Parse command for gh commands
    update.merge(parse_gh_command(command));

    // Parse output for GitHub URLs, but only for creation commands.
    // This prevents incidental URLs (e.g., in CI logs, code comments) from
    // being treated as session context.
    if let Some(out) = output
        && should_parse_output_urls(command)
    {
        update.merge(parse_github_urls(out));
    }

    // Parse gh pr view output for additions/deletions
    if let Some(out) = output
        && is_gh_pr_view_command(command)
    {
        update.merge(parse_gh_pr_view_stats(out));
    }

    // Parse worktree operations
    if let Some((path, branch)) = parse_worktree_add(command, cwd) {
        update.worktree_path = Some(path);
        update.worktree_branch = branch;
    }

    // Parse git -C path (updates worktree_path)
    if let Some(path) = parse_git_c_path(command, cwd) {
        update.worktree_path = Some(path);
    }

    // Parse cd commands
    if let Some(path) = parse_cd_path(command, cwd) {
        update.worktree_path = Some(path);
    }

    update
}

/// Split command on `&&`, `||`, and `;` to handle chained commands.
///
/// Splits on the earliest delimiter found in the string. This is a simple split
/// that doesn't handle quoted delimiters.
///
/// # Examples
///
/// ```ignore
/// use mi6_core::context::github::split_chained_commands;
///
/// let parts = split_chained_commands("echo foo && git checkout main");
/// assert_eq!(parts, vec!["echo foo", "git checkout main"]);
///
/// let parts = split_chained_commands("git status; git pull || echo failed");
/// assert_eq!(parts, vec!["git status", "git pull", "echo failed"]);
/// ```
pub fn split_chained_commands(command: &str) -> Vec<&str> {
    // Split on && || ; but not inside quotes
    // For simplicity, we do a basic split that handles most cases
    let mut parts = Vec::new();
    let mut current = command;

    // Simple split that works for most shell commands
    while !current.is_empty() {
        // Find the earliest delimiter (by position, not by type)
        let delim_pos = [
            current.find("&&").map(|p| (p, 2)),
            current.find("||").map(|p| (p, 2)),
            current.find(';').map(|p| (p, 1)),
        ]
        .into_iter()
        .flatten()
        .min_by_key(|(pos, _)| *pos);

        if let Some((pos, len)) = delim_pos {
            let part = current[..pos].trim();
            if !part.is_empty() {
                parts.push(part);
            }
            current = &current[pos + len..];
        } else {
            let part = current.trim();
            if !part.is_empty() {
                parts.push(part);
            }
            break;
        }
    }

    parts
}

/// Resolve a path relative to cwd, handling ~ expansion.
fn resolve_path(path: &str, cwd: &Path) -> String {
    let path = if path.starts_with('~') {
        // Expand ~ to home directory
        if let Ok(home) = std::env::var("HOME") {
            path.replacen('~', &home, 1)
        } else {
            path.to_string()
        }
    } else {
        path.to_string()
    };

    let path = Path::new(&path);
    if path.is_absolute() {
        path.to_string_lossy().into_owned()
    } else {
        cwd.join(path).to_string_lossy().into_owned()
    }
}

#[cfg(test)]
#[expect(clippy::unwrap_used, reason = "test code uses unwrap for clarity")]
mod tests {
    use super::*;
    use std::path::PathBuf;

    #[test]
    fn test_parse_gh_issue_view() {
        let update = parse_gh_command("gh issue view 52");
        assert_eq!(update.github_issue, Some(52));
        assert_eq!(update.github_pr, None);
        assert_eq!(update.github_repo, None);
    }

    #[test]
    fn test_parse_gh_issue_with_repo() {
        let update = parse_gh_command("gh issue view 52 --repo paradigmxyz/mi6");
        assert_eq!(update.github_issue, Some(52));
        assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
    }

    #[test]
    fn test_parse_gh_pr_checks() {
        let update = parse_gh_command("gh pr checks 86");
        assert_eq!(update.github_pr, Some(86));
        assert_eq!(update.github_issue, None);
    }

    #[test]
    fn test_parse_gh_pr_with_repo() {
        let update = parse_gh_command("gh pr view 64 --repo paradigmxyz/mi6");
        assert_eq!(update.github_pr, Some(64));
        assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
    }

    #[test]
    fn test_parse_github_url_issue() {
        let update = parse_github_urls("Created https://github.com/paradigmxyz/mi6/issues/236");
        assert_eq!(update.github_issue, Some(236));
        assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
    }

    #[test]
    fn test_parse_github_url_pr() {
        let update = parse_github_urls("Created https://github.com/paradigmxyz/mi6/pull/237");
        assert_eq!(update.github_pr, Some(237));
        assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
    }

    #[test]
    fn test_parse_closes_references() {
        let issues = parse_closes_references("This PR fixes #52 and closes #53");
        assert_eq!(issues, vec![52, 53]);
    }

    #[test]
    fn test_parse_closes_references_case_insensitive() {
        let issues = parse_closes_references("FIXES #42\nResolves #43\ncloses #44");
        assert_eq!(issues, vec![42, 43, 44]);
    }

    #[test]
    fn test_parse_gh_pr_create_with_fixes() {
        let update = parse_gh_command(r#"gh pr create --title "Fix bug" --body "Fixes #52""#);
        assert_eq!(update.github_issue, Some(52));
    }

    #[test]
    fn test_parse_worktree_add_simple() {
        let cwd = PathBuf::from("/test/repos/mi6");
        let result = parse_worktree_add("git worktree add ../mi6-issue-42", &cwd);
        assert!(result.is_some());
        let (path, branch) = result.unwrap();
        assert_eq!(path, "/test/repos/mi6/../mi6-issue-42");
        assert_eq!(branch, None);
    }

    #[test]
    fn test_parse_worktree_add_with_branch() {
        let cwd = PathBuf::from("/test/repos/mi6");
        let result = parse_worktree_add(
            "git worktree add ../mi6-fix -b fix/issue-42 origin/main",
            &cwd,
        );
        assert!(result.is_some());
        let (path, branch) = result.unwrap();
        assert_eq!(path, "/test/repos/mi6/../mi6-fix");
        assert_eq!(branch, Some("fix/issue-42".to_string()));
    }

    #[test]
    fn test_parse_git_c_path() {
        let cwd = PathBuf::from("/test/repos");
        let cmd = "git -C /test/worktree status";
        let result = parse_git_c_path(cmd, &cwd);
        assert_eq!(result, Some("/test/worktree".to_string()));
    }

    #[test]
    fn test_parse_cd_path_unquoted() {
        let cwd = PathBuf::from("/test");
        let cmd = "cd /test/path/to/dir";
        let result = parse_cd_path(cmd, &cwd);
        assert_eq!(result, Some("/test/path/to/dir".to_string()));
    }

    #[test]
    fn test_parse_cd_path_quoted() {
        let cwd = PathBuf::from("/test");
        let cmd = r#"cd "/test/path with spaces/dir""#;
        let result = parse_cd_path(cmd, &cwd);
        assert_eq!(result, Some("/test/path with spaces/dir".to_string()));
    }

    #[test]
    fn test_parse_cd_path_relative() {
        let cwd = PathBuf::from("/test/repos");
        let result = parse_cd_path("cd mi6-issue-42", &cwd);
        assert_eq!(result, Some("/test/repos/mi6-issue-42".to_string()));
    }

    #[test]
    fn test_chained_commands() {
        let update =
            parse_gh_command("cargo build && gh pr create --title 'Fix' --body 'Fixes #52'");
        assert_eq!(update.github_issue, Some(52));
    }

    #[test]
    fn test_chained_commands_semicolon() {
        let update = parse_gh_command("echo hello; gh issue view 42");
        assert_eq!(update.github_issue, Some(42));
    }

    #[test]
    fn test_extract_context_combined() {
        let cwd = PathBuf::from("/test/repos/mi6");
        let update = extract_context(
            "gh pr view 86 --repo paradigmxyz/mi6",
            Some("Some output here"),
            &cwd,
        );
        assert_eq!(update.github_pr, Some(86));
        assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
    }

    #[test]
    fn test_extract_context_from_output() {
        let cwd = PathBuf::from("/test/repos/mi6");
        let update = extract_context(
            "gh pr create --title 'Fix' --body 'Some fix'",
            Some("Created https://github.com/paradigmxyz/mi6/pull/237"),
            &cwd,
        );
        assert_eq!(update.github_pr, Some(237));
        assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
    }

    #[test]
    fn test_session_context_update_has_values() {
        let empty = GitContextUpdate::default();
        assert!(!empty.has_values());

        let with_issue = GitContextUpdate {
            github_issue: Some(42),
            ..Default::default()
        };
        assert!(with_issue.has_values());

        // git_branch field also triggers has_values
        let with_branch = GitContextUpdate {
            git_branch: Some("feature/test".to_string()),
            ..Default::default()
        };
        assert!(with_branch.has_values());
    }

    #[test]
    fn test_session_context_update_merge() {
        let mut base = GitContextUpdate {
            github_repo: Some("foo/bar".to_string()),
            github_issue: Some(1),
            ..Default::default()
        };

        let other = GitContextUpdate {
            github_issue: Some(2),
            github_pr: Some(42),
            ..Default::default()
        };

        base.merge(other);

        assert_eq!(base.github_repo, Some("foo/bar".to_string())); // kept
        assert_eq!(base.github_issue, Some(2)); // overwritten
        assert_eq!(base.github_pr, Some(42)); // added
    }

    #[test]
    fn test_session_context_update_merge_git_branch() {
        let mut base = GitContextUpdate {
            github_pr: Some(42),
            git_branch: Some("main".to_string()),
            ..Default::default()
        };

        let other = GitContextUpdate {
            git_branch: Some("feature/fix".to_string()),
            ..Default::default()
        };

        base.merge(other);

        assert_eq!(base.github_pr, Some(42)); // kept
        assert_eq!(base.git_branch, Some("feature/fix".to_string())); // overwritten
    }

    #[test]
    fn test_should_parse_output_urls() {
        // Creation commands should have output parsed
        assert!(should_parse_output_urls("gh pr create --title 'Fix'"));
        assert!(should_parse_output_urls("gh issue create --title 'Bug'"));
        assert!(should_parse_output_urls(
            "cargo build && gh pr create --title 'Fix'"
        ));

        // Non-creation commands should NOT have output parsed
        assert!(!should_parse_output_urls("gh run view 12345"));
        assert!(!should_parse_output_urls("gh pr view 42"));
        assert!(!should_parse_output_urls("gh issue view 42"));
        assert!(!should_parse_output_urls("cat README.md"));
        assert!(!should_parse_output_urls(
            "gh run download 12345 --name logs"
        ));
    }

    #[test]
    fn test_extract_context_ignores_urls_in_non_creation_output() {
        // This test reproduces a bug where GitHub URLs in CI logs were incorrectly
        // extracted as session context. The URL below appeared in a comment within
        // the dtolnay/rust-toolchain action's shell script.
        let cwd = PathBuf::from("/test/repos/mi6");
        let ci_log_output = r#"
            # GitHub does not enforce `required: true` inputs itself. https://github.com/actions/runner/issues/1070
            echo "toolchain=$toolchain" >> $GITHUB_OUTPUT
        "#;

        let update = extract_context("gh run view 12345 --log", Some(ci_log_output), &cwd);

        // Should NOT extract the incidental URL from CI logs
        assert_eq!(update.github_issue, None);
        assert_eq!(update.github_repo, None);
        assert_eq!(update.github_pr, None);
    }

    #[test]
    fn test_extract_context_issue_create_output() {
        let cwd = PathBuf::from("/test/repos/mi6");
        let update = extract_context(
            "gh issue create --title 'Bug report'",
            Some("https://github.com/paradigmxyz/mi6/issues/42"),
            &cwd,
        );
        assert_eq!(update.github_issue, Some(42));
        assert_eq!(update.github_repo, Some("paradigmxyz/mi6".to_string()));
    }

    #[test]
    fn test_parse_gh_pr_view_stats() {
        let output = r#"title:	feat(theme): add pane backgrounds
additions:	148
deletions:	7
"#;
        let update = parse_gh_pr_view_stats(output);
        assert_eq!(update.git_additions, Some(148));
        assert_eq!(update.git_deletions, Some(7));
    }

    #[test]
    fn test_parse_gh_pr_view_stats_with_spaces() {
        let output = "additions:  50\ndeletions:  25";
        let update = parse_gh_pr_view_stats(output);
        assert_eq!(update.git_additions, Some(50));
        assert_eq!(update.git_deletions, Some(25));
    }

    #[test]
    fn test_is_gh_pr_view_command() {
        assert!(is_gh_pr_view_command("gh pr view 42"));
        assert!(is_gh_pr_view_command("gh pr view"));
        assert!(is_gh_pr_view_command("gh pr view --json additions"));
        assert!(!is_gh_pr_view_command("gh pr create"));
        assert!(!is_gh_pr_view_command("gh issue view 42"));
        assert!(!is_gh_pr_view_command("git status"));
    }

    #[test]
    fn test_extract_context_gh_pr_view_stats() {
        let cwd = PathBuf::from("/test/repos/mi6");
        let output = "additions:\t148\ndeletions:\t7";
        let update = extract_context("gh pr view 497", Some(output), &cwd);
        assert_eq!(update.git_additions, Some(148));
        assert_eq!(update.git_deletions, Some(7));
        assert_eq!(update.github_pr, Some(497));
    }
}
