use anyhow::{Context, Result};
use serde::Deserialize;
use std::process::Command;

use crate::cli::{Args, DiffRange};
use crate::diff::{DiffLine, FileDiff, FileStatus, LineKind, parse_unified};

/// Fetch the "new" (post-change) version of a file for the given diff range.
///
/// Fold expansion reads lines by `new_lineno`, so we always return the side
/// that matches the positions displayed in the diff. Each `DiffRange` variant
/// maps to the specific artifact — working tree, index, a commit, or a remote
/// blob — that the diff actually came from, so the inserted context lines are
/// guaranteed to match the rest of the diff rather than whatever happens to
/// be on disk.
pub fn load_file_content(range: &DiffRange, path: &str) -> Result<String> {
    match range {
        DiffRange::Working | DiffRange::Uncommitted | DiffRange::UncommittedAgainst { .. } => {
            read_working_tree(path)
        }
        DiffRange::Staged | DiffRange::StagedAgainst { .. } => show_index(path),
        DiffRange::Range { target, .. } => show_at_ref(target, path),
        DiffRange::MergeBase { target, .. } => match target.as_str() {
            "working" | "." => read_working_tree(path),
            "staged" => show_index(path),
            other => show_at_ref(other, path),
        },
        DiffRange::PullRequest { url, head_sha, .. } => {
            let sha = head_sha.as_deref().ok_or_else(|| {
                anyhow::anyhow!(
                    "cannot expand folds: PR head commit SHA was not resolved at startup"
                )
            })?;
            fetch_pr_file(url, sha, path)
        }
        DiffRange::Stdin => std::fs::read_to_string(path)
            .context("cannot expand folds in stdin mode (file not available locally)"),
    }
}

fn read_working_tree(path: &str) -> Result<String> {
    std::fs::read_to_string(path).context("failed to read file from working tree")
}

fn show_index(path: &str) -> Result<String> {
    let out = Command::new("git")
        .args(["-c", "core.quotepath=false", "show", &format!(":{path}")])
        .output()
        .context("failed to run git show for staged file")?;
    if !out.status.success() {
        anyhow::bail!(
            "git show :{path} failed: {}",
            String::from_utf8_lossy(&out.stderr).trim()
        );
    }
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}

fn show_at_ref(reference: &str, path: &str) -> Result<String> {
    let out = Command::new("git")
        .args([
            "-c",
            "core.quotepath=false",
            "show",
            &format!("{reference}:{path}"),
        ])
        .output()
        .context("failed to run git show")?;
    if !out.status.success() {
        anyhow::bail!(
            "git show {reference}:{path} failed: {}",
            String::from_utf8_lossy(&out.stderr).trim()
        );
    }
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}

fn fetch_pr_file(pr_url: &str, sha: &str, path: &str) -> Result<String> {
    // Prefer a local object when the reviewer already has the PR head fetched
    // (e.g. via `gh pr checkout`). That avoids a network roundtrip per expand.
    if commit_exists_locally(sha)
        && let Ok(content) = show_at_ref(sha, path)
    {
        return Ok(content);
    }

    let (host, owner, repo, _) =
        parse_pr_url(pr_url).ok_or_else(|| anyhow::anyhow!("could not parse PR URL: {pr_url}"))?;

    let endpoint = format!(
        "repos/{owner}/{repo}/contents/{}?ref={sha}",
        url_encode_path(path)
    );
    let raw = run_gh_api(&host, &endpoint, &["Accept: application/vnd.github.v3.raw"])?;
    Ok(String::from_utf8_lossy(&raw).into_owned())
}

fn commit_exists_locally(sha: &str) -> bool {
    Command::new("git")
        .args(["cat-file", "-e", &format!("{sha}^{{commit}}")])
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

/// Percent-encode a filesystem path for GitHub's `contents` endpoint.
/// Path separators (`/`) stay literal so the URL still addresses nested paths.
fn url_encode_path(path: &str) -> String {
    let mut out = String::with_capacity(path.len());
    for byte in path.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b'-' | b'.' | b'_' | b'~' => {
                out.push(byte as char)
            }
            _ => out.push_str(&format!("%{:02X}", byte)),
        }
    }
    out
}

pub fn load_diff_simple(range: &DiffRange) -> Result<Vec<FileDiff>> {
    let output = run_git_diff(range, None)?;
    Ok(parse_unified(&output))
}

pub fn load_diff(range: &DiffRange, args: &Args) -> Result<Vec<FileDiff>> {
    let output = run_git_diff(range, args.context)?;
    let mut files = parse_unified(&output);

    if args.include_untracked
        && matches!(
            range,
            DiffRange::Working | DiffRange::Uncommitted | DiffRange::UncommittedAgainst { .. }
        )
    {
        let untracked = list_untracked_files()?;
        for path in untracked {
            let content = match std::fs::read_to_string(&path) {
                Ok(c) => c,
                Err(_) => continue,
            };
            let lines: Vec<DiffLine> = content
                .lines()
                .enumerate()
                .map(|(i, text)| {
                    DiffLine::body(LineKind::Added, text.to_string(), None, Some(i as u32 + 1))
                })
                .collect();
            let added = lines.len();
            let mut file = FileDiff {
                path,
                old_path: None,
                status: FileStatus::Added,
                added,
                removed: 0,
                lines,
                auto_collapsed: false,
                viewed: false,
            };
            crate::diff::apply_auto_collapse(&mut file);
            files.push(file);
        }
    }

    Ok(files)
}

pub fn load_pr_diff(url: &str) -> Result<String> {
    let out = Command::new("gh")
        .args(["pr", "diff", url])
        .output()
        .context("failed to run `gh pr diff`. Is `gh` CLI installed?")?;

    if !out.status.success() {
        let stderr = String::from_utf8_lossy(&out.stderr);
        anyhow::bail!("gh pr diff failed: {}", stderr.trim());
    }

    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}

/// Parse PR URL to extract owner, repo, and PR number.
/// Supports: `https://github.com/owner/repo/pull/123`
/// Also supports GitHub Enterprise: `https://github.example.com/owner/repo/pull/123`
pub fn parse_pr_url(url: &str) -> Option<(String, String, String, String)> {
    // Strip trailing slashes and fragments
    let url = url.split('#').next().unwrap_or(url).trim_end_matches('/');
    let parts: Vec<&str> = url.split('/').collect();
    // Find "pull" in the URL path
    for i in 0..parts.len() {
        if parts[i] == "pull" && i + 1 < parts.len() && i >= 2 {
            let number = parts[i + 1].to_string();
            let repo = parts[i - 1].to_string();
            let owner = parts[i - 2].to_string();
            // Extract host (everything before owner/repo/pull/number)
            let host = if i >= 4 {
                // https://host/owner/repo/pull/number
                parts[2].to_string() // e.g., "github.com" or "github.example.com"
            } else {
                "github.com".to_string()
            };
            return Some((host, owner, repo, number));
        }
    }
    None
}

/// Resolve a PR's head commit SHA so fold expansion can fetch blobs at the
/// PR's commit rather than the reviewer's local working tree.
pub fn load_pr_head_sha(url: &str) -> Result<String> {
    #[derive(Deserialize)]
    struct Raw {
        head: RawRef,
    }
    #[derive(Deserialize)]
    struct RawRef {
        sha: String,
    }

    let (host, owner, repo, number) =
        parse_pr_url(url).ok_or_else(|| anyhow::anyhow!("could not parse PR URL: {url}"))?;
    let endpoint = format!("repos/{owner}/{repo}/pulls/{number}");
    let raw = run_gh_api(&host, &endpoint, &[])?;
    let parsed: Raw = serde_json::from_slice(&raw).context("failed to parse PR meta response")?;
    Ok(parsed.head.sha)
}

/// Run `gh api <endpoint>` with optional `-H` headers, routing to the right
/// GitHub host. Returns raw stdout bytes so callers can choose how to decode.
fn run_gh_api(host: &str, endpoint: &str, headers: &[&str]) -> Result<Vec<u8>> {
    let mut cmd = Command::new("gh");
    cmd.arg("api");
    for h in headers {
        cmd.args(["-H", h]);
    }
    cmd.arg(endpoint);
    if host != "github.com" {
        cmd.args(["--hostname", host]);
    }
    let out = cmd.output().context("failed to run `gh api`")?;
    if !out.status.success() {
        let stderr = String::from_utf8_lossy(&out.stderr);
        anyhow::bail!("gh api {endpoint} failed: {}", stderr.trim());
    }
    Ok(out.stdout)
}

pub fn load_pr_comments(url: &str) -> Result<Vec<PrComment>> {
    let (host, owner, repo, number) =
        parse_pr_url(url).ok_or_else(|| anyhow::anyhow!("could not parse PR URL: {url}"))?;
    let endpoint = format!("repos/{owner}/{repo}/pulls/{number}/comments");
    let raw_bytes = run_gh_api(&host, &endpoint, &[])?;

    let raw: Vec<RawPrComment> = serde_json::from_slice(&raw_bytes)
        .context("failed to parse `gh api` response as PR review comments")?;

    let mut top: Vec<PrComment> = Vec::new();
    let mut reply_map: std::collections::HashMap<u64, Vec<String>> =
        std::collections::HashMap::new();
    for c in &raw {
        if let Some(parent) = c.in_reply_to_id {
            reply_map.entry(parent).or_default().push(c.body.clone());
        }
    }
    for c in raw {
        if c.in_reply_to_id.is_none() {
            let replies = reply_map.remove(&c.id).unwrap_or_default();
            top.push(PrComment {
                path: c.path,
                line: c.line,
                body: c.body,
                replies,
            });
        }
    }
    Ok(top)
}

#[derive(Debug)]
pub struct PrComment {
    pub path: String,
    pub line: Option<u32>,
    pub body: String,
    pub replies: Vec<String>,
}

#[derive(Deserialize)]
struct RawPrComment {
    id: u64,
    path: String,
    line: Option<u32>,
    body: String,
    in_reply_to_id: Option<u64>,
}

fn resolve_merge_base(base: &str, target: &str) -> Result<String> {
    let out = Command::new("git")
        .args(["merge-base", base, target])
        .output()
        .context("failed to run `git merge-base`")?;

    if !out.status.success() {
        let stderr = String::from_utf8_lossy(&out.stderr);
        anyhow::bail!("git merge-base failed: {}", stderr.trim());
    }

    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}

/// Returns entries as `"<ref>\t<summary>"`; summary may be empty.
pub fn list_revisions() -> Vec<String> {
    let mut out: Vec<String> = Vec::new();

    let refs = Command::new("git")
        .args([
            "for-each-ref",
            "--sort=-committerdate",
            "--format=%(refname:short)\t%(contents:subject)",
            "refs/heads",
            "refs/tags",
            "refs/remotes",
        ])
        .output();
    if let Ok(o) = refs {
        for line in String::from_utf8_lossy(&o.stdout).lines() {
            if line.is_empty() {
                continue;
            }
            out.push(line.to_string());
        }
    }

    let log = Command::new("git")
        .args(["log", "--pretty=format:%h\t%s", "-n", "50"])
        .output();
    if let Ok(o) = log {
        for line in String::from_utf8_lossy(&o.stdout).lines() {
            if line.is_empty() {
                continue;
            }
            out.push(line.to_string());
        }
    }
    out
}

fn list_untracked_files() -> Result<Vec<String>> {
    let out = Command::new("git")
        .args([
            "-c",
            "core.quotepath=false",
            "ls-files",
            "--others",
            "--exclude-standard",
        ])
        .output()
        .context("failed to run `git ls-files`")?;

    Ok(String::from_utf8_lossy(&out.stdout)
        .lines()
        .filter(|l| !l.is_empty())
        .map(|l| l.to_string())
        .collect())
}

fn run_git_diff(range: &DiffRange, context_lines: Option<u32>) -> Result<String> {
    let context_arg = context_lines.map(|n| format!("-U{n}"));

    let run = |args: Vec<&str>| -> Result<String> {
        let mut cmd = Command::new("git");
        cmd.arg("-c")
            .arg("core.quotepath=false")
            .arg("--no-pager")
            .arg("diff")
            .arg("--no-color");
        if let Some(a) = &context_arg {
            cmd.arg(a);
        }
        for a in args {
            cmd.arg(a);
        }
        let out = cmd
            .output()
            .context("failed to spawn `git diff`. is git installed and in PATH?")?;
        if !out.status.success() {
            let stderr = String::from_utf8_lossy(&out.stderr);
            anyhow::bail!("git diff failed: {}", stderr.trim());
        }
        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
    };

    match range {
        DiffRange::Working => run(vec![]),
        DiffRange::Staged => run(vec!["--cached"]),
        DiffRange::StagedAgainst { base } => run(vec!["--cached", base]),
        DiffRange::Uncommitted => run(vec!["HEAD"]),
        DiffRange::UncommittedAgainst { base } => run(vec![base]),
        DiffRange::Range { base, target } => run(vec![&format!("{base}..{target}")]),
        DiffRange::MergeBase { base, target } => {
            let mb_target = merge_base_target_ref(target);
            let mb = resolve_merge_base(base, &mb_target)?;
            // For special targets, run the "uncommitted/staged against <mb>" form.
            match target.as_str() {
                "working" => run(vec![]),
                "staged" => run(vec!["--cached", &mb]),
                "." => run(vec![&mb]),
                _ => run(vec![&format!("{mb}..{target}")]),
            }
        }
        DiffRange::Stdin | DiffRange::PullRequest { .. } => {
            anyhow::bail!("run_git_diff should not be called for Stdin/PR mode");
        }
    }
}

fn merge_base_target_ref(target: &str) -> String {
    match target {
        "." | "staged" | "working" => "HEAD".to_string(),
        other => other.to_string(),
    }
}

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

    #[test]
    fn parse_pr_url_github_com() {
        let (host, owner, repo, num) =
            parse_pr_url("https://github.com/owner/repo/pull/42").unwrap();
        assert_eq!(host, "github.com");
        assert_eq!(owner, "owner");
        assert_eq!(repo, "repo");
        assert_eq!(num, "42");
    }

    #[test]
    fn parse_pr_url_trailing_slash_and_fragment() {
        let (host, owner, repo, num) =
            parse_pr_url("https://github.com/owner/repo/pull/42/#issue-123").unwrap();
        assert_eq!(host, "github.com");
        assert_eq!(owner, "owner");
        assert_eq!(repo, "repo");
        assert_eq!(num, "42");
    }

    #[test]
    fn parse_pr_url_enterprise() {
        let (host, owner, repo, num) =
            parse_pr_url("https://github.example.com/a/b/pull/7").unwrap();
        assert_eq!(host, "github.example.com");
        assert_eq!(owner, "a");
        assert_eq!(repo, "b");
        assert_eq!(num, "7");
    }

    #[test]
    fn parse_pr_url_rejects_non_pr_urls() {
        // `pull` is not present in the path → no match.
        assert!(parse_pr_url("https://github.com/owner/repo/commits/main").is_none());
        // No owner/repo before `pull`.
        assert!(parse_pr_url("pull/42").is_none());
        // Not a URL at all.
        assert!(parse_pr_url("").is_none());
        assert!(parse_pr_url("not-a-url").is_none());
    }

    #[test]
    fn merge_base_target_ref_special_values() {
        assert_eq!(merge_base_target_ref("."), "HEAD");
        assert_eq!(merge_base_target_ref("staged"), "HEAD");
        assert_eq!(merge_base_target_ref("working"), "HEAD");
        assert_eq!(merge_base_target_ref("main"), "main");
        assert_eq!(merge_base_target_ref("feat/x"), "feat/x");
    }

    #[test]
    fn url_encode_path_preserves_slashes_and_unreserved() {
        assert_eq!(url_encode_path("src/main.rs"), "src/main.rs");
        assert_eq!(url_encode_path("src/with space.rs"), "src/with%20space.rs");
        // `?`, `#`, `%` are not unreserved and must be encoded so they don't
        // collide with the query string we append (`?ref=...`).
        assert_eq!(url_encode_path("q?x.md"), "q%3Fx.md");
        assert_eq!(url_encode_path("a#b"), "a%23b");
        assert_eq!(url_encode_path("50%off.md"), "50%25off.md");
    }

    #[test]
    fn url_encode_path_percent_encodes_non_ascii() {
        // UTF-8 bytes for "日" = E6 97 A5.
        assert_eq!(url_encode_path("日.md"), "%E6%97%A5.md");
    }
}
