use std::collections::BTreeSet;
use std::path::Path;
use std::process::Command;

use serde_json::{Map, Value};

use crate::core::{string_list, OrchError, OrchResult};
use crate::model::{LeaseRecord, StagePlan};
use crate::specs::path_in_scope;

#[derive(Debug, Clone)]
struct GitStatusRecord {
    path: String,
    orig_path: Option<String>,
    kind: String,
    index: String,
    worktree: String,
    staged: bool,
    unstaged: bool,
    untracked: bool,
    ignored: bool,
    conflict: bool,
    score: Option<String>,
}

impl GitStatusRecord {
    fn paths(&self) -> Vec<String> {
        let mut paths = Vec::new();
        if let Some(orig_path) = &self.orig_path {
            paths.push(orig_path.clone());
        }
        if !paths.contains(&self.path) {
            paths.push(self.path.clone());
        }
        paths
    }

    fn visible_paths(&self) -> Vec<String> {
        self.paths()
            .into_iter()
            .filter(|path| is_visible_path(path))
            .collect()
    }

    fn primary_visible_path(&self) -> Option<&str> {
        is_visible_path(&self.path).then_some(self.path.as_str())
    }

    fn to_value(&self) -> Value {
        let mut map = Map::new();
        map.insert("path".to_string(), Value::String(self.path.clone()));
        if let Some(orig_path) = &self.orig_path {
            map.insert("orig_path".to_string(), Value::String(orig_path.clone()));
        }
        map.insert("kind".to_string(), Value::String(self.kind.clone()));
        map.insert("index".to_string(), Value::String(self.index.clone()));
        map.insert("worktree".to_string(), Value::String(self.worktree.clone()));
        map.insert("staged".to_string(), Value::Bool(self.staged));
        map.insert("unstaged".to_string(), Value::Bool(self.unstaged));
        if self.untracked {
            map.insert("untracked".to_string(), Value::Bool(true));
        }
        if self.ignored {
            map.insert("ignored".to_string(), Value::Bool(true));
        }
        if self.conflict {
            map.insert("conflict".to_string(), Value::Bool(true));
        }
        if let Some(score) = &self.score {
            map.insert("score".to_string(), Value::String(score.clone()));
        }
        map.insert("paths".to_string(), string_array(self.paths()));
        Value::Object(map)
    }
}

fn git(root: &Path, args: &[&str], check: bool) -> OrchResult<Vec<u8>> {
    let output = Command::new("git")
        .args(args)
        .current_dir(root)
        .output()
        .map_err(OrchError::from)?;
    if check && !output.status.success() {
        return Err(OrchError::new("git command failed")
            .detail(
                "args",
                Value::Array(
                    args.iter()
                        .map(|arg| Value::String((*arg).to_string()))
                        .collect(),
                ),
            )
            .detail(
                "stderr",
                String::from_utf8_lossy(&output.stderr).trim().to_string(),
            ));
    }
    Ok(output.stdout)
}

fn git_owned(root: &Path, args: &[String], check: bool) -> OrchResult<Vec<u8>> {
    let refs: Vec<&str> = args.iter().map(String::as_str).collect();
    git(root, &refs, check)
}

fn git_text(root: &Path, args: &[&str], check: bool) -> OrchResult<String> {
    Ok(String::from_utf8_lossy(&git(root, args, check)?)
        .trim()
        .to_string())
}

pub(crate) fn current_branch(root: &Path) -> OrchResult<Option<String>> {
    if !git_available(root) {
        return Ok(None);
    }
    let branch = git_text(root, &["rev-parse", "--abbrev-ref", "HEAD"], true)?;
    Ok((!branch.is_empty() && branch != "HEAD").then_some(branch))
}

pub(crate) fn head_commit(root: &Path) -> OrchResult<Option<String>> {
    if !git_available(root) {
        return Ok(None);
    }
    Ok(Some(git_text(root, &["rev-parse", "HEAD"], true)?))
}

fn git_available(root: &Path) -> bool {
    git(root, &["rev-parse", "--show-toplevel"], true).is_ok()
}

fn split_z(data: &[u8]) -> Vec<String> {
    data.split(|byte| *byte == 0)
        .filter(|item| !item.is_empty())
        .map(|item| String::from_utf8_lossy(item).to_string())
        .collect()
}

pub(crate) fn git_status_data(root: &Path) -> OrchResult<Map<String, Value>> {
    if !git_available(root) {
        let mut map = Map::new();
        map.insert("git".to_string(), Value::Bool(false));
        return Ok(map);
    }

    let branch = git_text(root, &["rev-parse", "--abbrev-ref", "HEAD"], true)?;
    let head = git_text(root, &["rev-parse", "HEAD"], true)?;
    let records = git_status_records(root)?;
    let changed = changed_object_from_records(&records);

    let mut map = Map::new();
    map.insert("git".to_string(), Value::Bool(true));
    map.insert("branch".to_string(), Value::String(branch));
    map.insert("head".to_string(), Value::String(head));
    map.insert("clean".to_string(), Value::Bool(records.is_empty()));
    insert_records_if_non_empty(&mut map, "records", &records);
    if changed.as_object().is_some_and(|items| !items.is_empty()) {
        map.insert("changed".to_string(), changed);
    }
    Ok(map)
}

fn git_status_records(root: &Path) -> OrchResult<Vec<GitStatusRecord>> {
    let output = git(
        root,
        &[
            "status",
            "--porcelain=v2",
            "-z",
            "--untracked-files=all",
            "--renames",
        ],
        true,
    )?;
    Ok(parse_porcelain_v2_z(&output)
        .into_iter()
        .filter(|record| !record.visible_paths().is_empty())
        .collect())
}

fn changed_object_from_records(records: &[GitStatusRecord]) -> Value {
    let mut modified = BTreeSet::new();
    let mut staged = BTreeSet::new();
    let mut untracked = BTreeSet::new();

    for record in records {
        let Some(path) = record.primary_visible_path() else {
            continue;
        };
        if record.untracked {
            untracked.insert(path.to_string());
        } else {
            if record.unstaged {
                modified.insert(path.to_string());
            }
            if record.staged {
                staged.insert(path.to_string());
            }
        }
    }

    let mut changed = Map::new();
    insert_array_if_non_empty(&mut changed, "modified", modified.into_iter().collect());
    insert_array_if_non_empty(&mut changed, "staged", staged.into_iter().collect());
    insert_array_if_non_empty(&mut changed, "untracked", untracked.into_iter().collect());
    Value::Object(changed)
}

fn parse_porcelain_v2_z(data: &[u8]) -> Vec<GitStatusRecord> {
    let chunks = split_z(data);
    let mut records = Vec::new();
    let mut index = 0;

    while index < chunks.len() {
        let Some(entry) = porcelain_record_text(&chunks[index]) else {
            index += 1;
            continue;
        };

        match entry.as_bytes().first().copied() {
            Some(b'1') => {
                if let Some(record) = parse_ordinary_record(entry) {
                    records.push(record);
                }
            }
            Some(b'2') => {
                let orig_path = chunks.get(index + 1).cloned().unwrap_or_default();
                if let Some(record) = parse_rename_record(entry, orig_path) {
                    records.push(record);
                    index += 1;
                }
            }
            Some(b'u') => {
                if let Some(record) = parse_unmerged_record(entry) {
                    records.push(record);
                }
            }
            Some(b'?') => {
                if let Some(record) = parse_simple_record(entry, "untracked", true) {
                    records.push(record);
                }
            }
            Some(b'!') => {
                if let Some(record) = parse_simple_record(entry, "ignored", false) {
                    records.push(record);
                }
            }
            Some(b'#') | None | Some(_) => {}
        }

        index += 1;
    }

    records.sort_by_key(|record| record.paths());
    records
}

fn porcelain_record_text(chunk: &str) -> Option<&str> {
    if is_porcelain_record_start(chunk) {
        return Some(chunk);
    }

    chunk
        .split('\n')
        .map(|line| line.trim_start_matches('\r'))
        .find(|line| is_porcelain_record_start(line))
}

fn is_porcelain_record_start(value: &str) -> bool {
    matches!(
        value.as_bytes(),
        [b'1' | b'2' | b'u' | b'?' | b'!' | b'#', b' ', ..]
    )
}

fn parse_ordinary_record(entry: &str) -> Option<GitStatusRecord> {
    let parts: Vec<&str> = entry.splitn(9, ' ').collect();
    let xy = *parts.get(1)?;
    let path = (*parts.get(8)?).to_string();
    let (index, worktree) = xy_codes(xy);
    Some(GitStatusRecord {
        kind: ordinary_kind(index, worktree).to_string(),
        path,
        orig_path: None,
        index: index.to_string(),
        worktree: worktree.to_string(),
        staged: is_staged_code(index),
        unstaged: is_unstaged_code(worktree),
        untracked: false,
        ignored: false,
        conflict: false,
        score: None,
    })
}

fn parse_rename_record(entry: &str, orig_path: String) -> Option<GitStatusRecord> {
    let parts: Vec<&str> = entry.splitn(10, ' ').collect();
    let xy = *parts.get(1)?;
    let score = (*parts.get(8)?).to_string();
    let path = (*parts.get(9)?).to_string();
    let (index, worktree) = xy_codes(xy);
    Some(GitStatusRecord {
        kind: match score.as_bytes().first().copied() {
            Some(b'C') => "copied",
            _ => "renamed",
        }
        .to_string(),
        path,
        orig_path: (!orig_path.is_empty()).then_some(orig_path),
        index: index.to_string(),
        worktree: worktree.to_string(),
        staged: is_staged_code(index),
        unstaged: is_unstaged_code(worktree),
        untracked: false,
        ignored: false,
        conflict: false,
        score: Some(score),
    })
}

fn parse_unmerged_record(entry: &str) -> Option<GitStatusRecord> {
    let parts: Vec<&str> = entry.splitn(11, ' ').collect();
    let xy = *parts.get(1)?;
    let path = (*parts.get(10)?).to_string();
    let (index, worktree) = xy_codes(xy);
    Some(GitStatusRecord {
        kind: "unmerged".to_string(),
        path,
        orig_path: None,
        index: index.to_string(),
        worktree: worktree.to_string(),
        staged: true,
        unstaged: true,
        untracked: false,
        ignored: false,
        conflict: true,
        score: None,
    })
}

fn parse_simple_record(entry: &str, kind: &str, untracked: bool) -> Option<GitStatusRecord> {
    let path = entry.get(2..)?.to_string();
    Some(GitStatusRecord {
        path,
        orig_path: None,
        kind: kind.to_string(),
        index: ".".to_string(),
        worktree: if untracked { "?" } else { "!" }.to_string(),
        staged: false,
        unstaged: untracked,
        untracked,
        ignored: !untracked,
        conflict: false,
        score: None,
    })
}

fn xy_codes(xy: &str) -> (char, char) {
    let mut chars = xy.chars();
    (chars.next().unwrap_or('.'), chars.next().unwrap_or('.'))
}

fn ordinary_kind(index: char, worktree: char) -> &'static str {
    if index == 'D' || worktree == 'D' {
        "deleted"
    } else if index == 'A' || worktree == 'A' {
        "added"
    } else if index == 'T' || worktree == 'T' {
        "typechange"
    } else {
        "modified"
    }
}

fn is_staged_code(value: char) -> bool {
    !matches!(value, '.' | '?' | '!')
}

fn is_unstaged_code(value: char) -> bool {
    !matches!(value, '.' | '!')
}

fn is_visible_path(path: &str) -> bool {
    !path.starts_with(".orchid/")
}

fn string_array(items: Vec<String>) -> Value {
    Value::Array(items.into_iter().map(Value::String).collect())
}

fn insert_array_if_non_empty(map: &mut Map<String, Value>, key: &str, items: Vec<String>) {
    if !items.is_empty() {
        map.insert(key.to_string(), string_array(items));
    }
}

fn records_array(records: &[GitStatusRecord]) -> Value {
    Value::Array(records.iter().map(GitStatusRecord::to_value).collect())
}

fn insert_records_if_non_empty(
    map: &mut Map<String, Value>,
    key: &str,
    records: &[GitStatusRecord],
) {
    if !records.is_empty() {
        map.insert(key.to_string(), records_array(records));
    }
}

fn insert_records_value_if_non_empty(
    map: &mut Map<String, Value>,
    key: &str,
    records: &[GitStatusRecord],
) {
    if !records.is_empty() {
        map.insert(key.to_string(), records_array(records));
    }
}

pub(crate) fn changed_paths_value(status: &Map<String, Value>) -> Value {
    string_array(changed_paths(status))
}

pub(crate) fn status_records_value(status: &Map<String, Value>) -> Value {
    status
        .get("records")
        .cloned()
        .unwrap_or_else(|| Value::Array(Vec::new()))
}

pub(crate) fn changed_paths(status: &Map<String, Value>) -> Vec<String> {
    let mut paths = BTreeSet::new();
    if let Some(records) = status.get("records").and_then(Value::as_array) {
        for record in records {
            for path in string_list(record.get("paths")) {
                paths.insert(path);
            }
        }
        return paths.into_iter().collect();
    }

    if let Some(changed) = status.get("changed").and_then(Value::as_object) {
        for key in ["modified", "staged", "untracked"] {
            for path in string_list(changed.get(key)) {
                paths.insert(path);
            }
        }
    }
    paths.into_iter().collect()
}

fn git_status_records_from_status(status: &Map<String, Value>) -> Vec<GitStatusRecord> {
    status
        .get("records")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(git_status_record_from_value)
        .collect()
}

fn git_status_record_from_value(value: &Value) -> Option<GitStatusRecord> {
    let object = value.as_object()?;
    let path = object.get("path")?.as_str()?.to_string();
    Some(GitStatusRecord {
        path,
        orig_path: object
            .get("orig_path")
            .and_then(Value::as_str)
            .map(str::to_string),
        kind: object.get("kind")?.as_str()?.to_string(),
        index: object
            .get("index")
            .and_then(Value::as_str)
            .unwrap_or(".")
            .to_string(),
        worktree: object
            .get("worktree")
            .and_then(Value::as_str)
            .unwrap_or(".")
            .to_string(),
        staged: object
            .get("staged")
            .and_then(Value::as_bool)
            .unwrap_or(false),
        unstaged: object
            .get("unstaged")
            .and_then(Value::as_bool)
            .unwrap_or(false),
        untracked: object
            .get("untracked")
            .and_then(Value::as_bool)
            .unwrap_or(false),
        ignored: object
            .get("ignored")
            .and_then(Value::as_bool)
            .unwrap_or(false),
        conflict: object
            .get("conflict")
            .and_then(Value::as_bool)
            .unwrap_or(false),
        score: object
            .get("score")
            .and_then(Value::as_str)
            .map(str::to_string),
    })
}

pub(crate) fn visible_changed_paths(root: &Path) -> OrchResult<Vec<String>> {
    Ok(changed_paths(&git_status_data(root)?))
}

pub(crate) fn changed_protected_paths(
    root: &Path,
    protected_surfaces: &[String],
) -> OrchResult<Vec<String>> {
    let mut changed: Vec<String> = visible_changed_paths(root)?
        .into_iter()
        .filter(|path| path_in_scope(path, protected_surfaces))
        .collect();
    changed.sort();
    changed.dedup();
    Ok(changed)
}

pub(crate) fn stage_goal_candidates(root: &Path) -> OrchResult<Vec<String>> {
    let candidates = visible_changed_paths(root)?;
    if candidates.is_empty() {
        return Ok(candidates);
    }
    let mut args = vec!["add".to_string(), "--".to_string()];
    args.extend(candidates.iter().map(|path| format!(":(literal){path}")));
    git_owned(root, &args, true)?;
    Ok(candidates)
}

pub(crate) fn commit_goal_keep(root: &Path, goal_id: &str, cycle: &str) -> OrchResult<String> {
    git(
        root,
        &["commit", "-m", &format!("goal({goal_id}): keep {cycle}")],
        true,
    )?;
    git_text(root, &["rev-parse", "HEAD"], true)
}

pub(crate) fn reset_hard(root: &Path, commit: &str) -> OrchResult<()> {
    git(root, &["reset", "--hard", commit], true)?;
    Ok(())
}

pub(crate) fn clean_goal_candidates(root: &Path) -> OrchResult<()> {
    git(root, &["clean", "-fd", "-e", ".orchid/"], true)?;
    Ok(())
}

pub(crate) fn touched_for_lease(
    root: &Path,
    lease: &LeaseRecord,
) -> OrchResult<Map<String, Value>> {
    let status = git_status_data(root)?;
    let baseline: BTreeSet<String> = lease.baseline_changed().into_iter().collect();
    let records = git_status_records_from_status(&status);
    let scope = lease.scope();
    let task_path = lease.task_path();

    let mut stage_paths = BTreeSet::new();
    let mut out_of_scope = BTreeSet::new();
    let mut ambiguous = BTreeSet::new();
    let mut changed_records = Vec::new();
    let mut stage_records = Vec::new();
    let mut out_of_scope_records = Vec::new();
    let mut ambiguous_records = Vec::new();

    for record in records {
        let paths = record.visible_paths();
        if paths.is_empty() {
            continue;
        }

        if paths.iter().any(|path| baseline.contains(path)) {
            for path in paths {
                ambiguous.insert(path);
            }
            ambiguous_records.push(record);
            continue;
        }

        changed_records.push(record.clone());
        let disallowed: Vec<String> = paths
            .iter()
            .filter(|path| !path_in_scope(path, &scope) && path.as_str() != task_path)
            .cloned()
            .collect();

        if disallowed.is_empty() {
            for path in paths {
                stage_paths.insert(path);
            }
            stage_records.push(record);
        } else {
            for path in disallowed {
                out_of_scope.insert(path);
            }
            out_of_scope_records.push(record);
        }
    }
    let mut preexisting_dirty: Vec<String> = baseline.into_iter().collect();
    preexisting_dirty.sort();

    let mut map = Map::new();
    map.insert("lease_id".to_string(), lease.id_value());
    map.insert("task".to_string(), lease.task_value());
    insert_records_if_non_empty(&mut map, "records", &changed_records);
    insert_array_if_non_empty(&mut map, "stage", stage_paths.into_iter().collect());
    insert_records_if_non_empty(&mut map, "stage_records", &stage_records);
    let mut blocked_by = Map::new();
    insert_array_if_non_empty(
        &mut blocked_by,
        "out_of_scope",
        out_of_scope.iter().cloned().collect(),
    );
    insert_array_if_non_empty(
        &mut blocked_by,
        "ambiguous",
        ambiguous.iter().cloned().collect(),
    );
    if !blocked_by.is_empty() {
        map.insert("blocked_by".to_string(), Value::Object(blocked_by));
    }
    let mut blocked_by_records = Map::new();
    insert_records_value_if_non_empty(
        &mut blocked_by_records,
        "out_of_scope",
        &out_of_scope_records,
    );
    insert_records_value_if_non_empty(&mut blocked_by_records, "ambiguous", &ambiguous_records);
    if !blocked_by_records.is_empty() {
        map.insert(
            "blocked_by_records".to_string(),
            Value::Object(blocked_by_records),
        );
    }
    insert_array_if_non_empty(&mut map, "preexisting_dirty", preexisting_dirty);
    if !out_of_scope.is_empty() || !ambiguous.is_empty() {
        map.insert("safe_to_stage".to_string(), Value::Bool(false));
    }
    Ok(map)
}

pub(crate) fn stage_plan_for_lease(root: &Path, lease: &LeaseRecord) -> OrchResult<StagePlan> {
    let data = touched_for_lease(root, lease)?;
    let pathspecs: BTreeSet<String> = string_list(data.get("stage"))
        .into_iter()
        .map(|path| format!(":(literal){path}"))
        .collect();

    let mut excluded = Map::new();
    if let Some(blocked_by) = data.get("blocked_by").and_then(Value::as_object) {
        excluded.extend(blocked_by.clone());
    }
    if let Some(preexisting_dirty) = data.get("preexisting_dirty") {
        excluded.insert("preexisting_dirty".to_string(), preexisting_dirty.clone());
    }
    let mut excluded_records = Map::new();
    if let Some(blocked_by_records) = data.get("blocked_by_records").and_then(Value::as_object) {
        excluded_records.extend(blocked_by_records.clone());
    }

    Ok(StagePlan {
        lease_id: lease.id().unwrap_or("").to_string(),
        task: lease.get_str("task").unwrap_or("").to_string(),
        safe_to_stage: data
            .get("safe_to_stage")
            .and_then(Value::as_bool)
            .unwrap_or(true),
        pathspecs: pathspecs.into_iter().collect(),
        records: data
            .get("stage_records")
            .and_then(Value::as_array)
            .cloned()
            .unwrap_or_default(),
        excluded,
        excluded_records,
    })
}

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

    fn record_values(input: &[u8]) -> Vec<Value> {
        parse_porcelain_v2_z(input)
            .iter()
            .map(GitStatusRecord::to_value)
            .collect()
    }

    #[test]
    fn parses_porcelain_v2_ordinary_rename_and_untracked_records() {
        let data = b"1 .M N... 100644 100644 100644 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb src/edit.rs\0\
2 R. N... 100644 100644 100644 cccccccccccccccccccccccccccccccccccccccc cccccccccccccccccccccccccccccccccccccccc R100 src/new.rs\0src/old.rs\0\
? src/untracked.rs\0";

        let records = record_values(data);

        assert_eq!(records.len(), 3);
        assert_eq!(records[0]["path"], "src/edit.rs");
        assert_eq!(records[0]["kind"], "modified");
        assert_eq!(records[0]["worktree"], "M");
        assert_eq!(records[0]["unstaged"], true);
        assert_eq!(records[1]["path"], "src/new.rs");
        assert_eq!(records[1]["orig_path"], "src/old.rs");
        assert_eq!(records[1]["kind"], "renamed");
        assert_eq!(
            records[1]["paths"],
            serde_json::json!(["src/old.rs", "src/new.rs"])
        );
        assert_eq!(records[2]["path"], "src/untracked.rs");
        assert_eq!(records[2]["kind"], "untracked");
        assert_eq!(records[2]["untracked"], true);
    }

    #[test]
    fn parser_tolerates_rtk_clean_and_merged_diagnostic_output() {
        assert!(parse_porcelain_v2_z(b"ok").is_empty());

        let data = b"[rtk] /!\\ No hook installed\n1 D. N... 100644 000000 000000 dddddddddddddddddddddddddddddddddddddddd dddddddddddddddddddddddddddddddddddddddd src/deleted.rs\0";
        let records = record_values(data);

        assert_eq!(records.len(), 1);
        assert_eq!(records[0]["path"], "src/deleted.rs");
        assert_eq!(records[0]["kind"], "deleted");
        assert_eq!(records[0]["index"], "D");
        assert_eq!(records[0]["staged"], true);
    }
}
