use crate::{config::Config, git, git_util, ticket, ticket_fmt, worktree};
use anyhow::Result;
use chrono::{DateTime, NaiveDate, Utc};
use std::path::{Path, PathBuf};

const KNOWN_TEMP_FILES: &[&str] = &[
    "pr-body.md",
    "body.md",
    "ac.txt",
    ".apm-worker.pid",
    ".apm-worker.log",
];

pub struct CleanCandidate {
    pub ticket_id: String,
    pub ticket_title: String,
    pub branch: String,
    pub worktree: Option<PathBuf>,
    pub reason: String,
    pub local_branch_exists: bool,
    pub branch_merged: bool,
    /// Authoritative: is the branch present on origin right now? Computed
    /// via a single `git ls-remote` at the start of candidate enumeration.
    /// Used so remote deletion only attempts pushes that can succeed,
    /// avoiding noisy "remote ref does not exist" errors.
    pub remote_branch_exists: bool,
    /// Ticket's `updated_at` from frontmatter; None if absent. Used by
    /// `--older-than` to filter candidates by age.
    pub updated_at: Option<DateTime<Utc>>,
}

pub struct DirtyWorktree {
    pub ticket_id: String,
    pub ticket_title: String,
    pub branch: String,
    pub path: PathBuf,
    pub local_branch_exists: bool,
    pub known_temp: Vec<PathBuf>,
    pub other_untracked: Vec<PathBuf>,
    pub modified_tracked: Vec<PathBuf>,
}

pub fn diagnose_worktree(
    path: &Path,
    ticket_id: &str,
    ticket_title: &str,
    branch: &str,
    local_branch_exists: bool,
    agent_dirs: &[String],
) -> Result<DirtyWorktree> {
    let stdout = git_util::run(path, &["status", "--porcelain"])?;

    let mut known_temp = Vec::new();
    let mut other_untracked = Vec::new();
    let mut modified_tracked = Vec::new();

    for line in stdout.lines() {
        if line.len() < 3 {
            continue;
        }
        let xy = &line[..2];
        let file = line[3..].trim();
        let filename = std::path::Path::new(file)
            .file_name()
            .map(|n| n.to_string_lossy().into_owned())
            .unwrap_or_default();
        let top_dir = file.split('/').next().unwrap_or("");

        if xy == "??" {
            if KNOWN_TEMP_FILES.contains(&filename.as_str())
                || agent_dirs.iter().any(|d| d.trim_end_matches('/') == top_dir)
            {
                known_temp.push(PathBuf::from(file));
            } else {
                other_untracked.push(PathBuf::from(file));
            }
        } else {
            modified_tracked.push(PathBuf::from(file));
        }
    }

    Ok(DirtyWorktree {
        ticket_id: ticket_id.to_string(),
        ticket_title: ticket_title.to_string(),
        branch: branch.to_string(),
        path: path.to_path_buf(),
        local_branch_exists,
        known_temp,
        other_untracked,
        modified_tracked,
    })
}

pub fn remove_untracked(wt_path: &Path, files: &[PathBuf]) -> Result<()> {
    for file in files {
        let full_path = wt_path.join(file);
        if full_path.is_dir() {
            std::fs::remove_dir_all(&full_path)?;
        } else if full_path.exists() {
            std::fs::remove_file(&full_path)?;
        }
    }
    Ok(())
}

pub struct RemoveOutput {
    pub warnings: Vec<String>,
}

pub fn candidates(root: &Path, config: &Config, force: bool, untracked: bool, dry_run: bool) -> Result<(Vec<CleanCandidate>, Vec<DirtyWorktree>, Vec<String>)> {
    let mut warnings: Vec<String> = Vec::new();
    let terminal_states = config.terminal_state_ids();

    let default_branch = &config.project.default_branch;
    let tickets = ticket::load_all_from_git(root, &config.tickets.dir)?;
    let merged = git::merged_into_main(root, default_branch)?;
    let merged_set: std::collections::HashSet<&str> = merged.iter().map(|s| s.as_str()).collect();
    let remote_branches = git_util::list_remote_ticket_branches(root);

    let mut result = Vec::new();
    let mut dirty_result = Vec::new();

    for t in &tickets {
        if !terminal_states.contains(t.frontmatter.state.as_str()) {
            continue;
        }

        let branch = t
            .frontmatter
            .branch
            .clone()
            .or_else(|| ticket_fmt::branch_name_from_path(&t.path))
            .unwrap_or_else(|| format!("ticket/{}", t.frontmatter.id));

        let id = t.frontmatter.id.clone();
        let branch_state = &t.frontmatter.state;

        let is_merged = merged_set.contains(branch.as_str());

        let local_tip = git::branch_tip(root, &branch);
        let is_ancestor = if let Some(ref tip) = local_tip {
            git::is_ancestor(root, tip, default_branch)
        } else {
            true
        };

        // The branch state is authoritative — we already filtered to terminal
        // states above. If the branch says the ticket is done, clean proceeds
        // regardless of what main says (or whether the ticket exists on main).

        let wt_path = worktree::find_worktree_for_branch(root, &branch);

        // Check worktree cleanliness before the tip-divergence guard so that
        // a clean worktree on a closed ticket is not blocked by stale refs.
        let wt_clean = if let Some(ref path) = wt_path {
            !git_util::is_worktree_dirty(path)
        } else {
            true
        };

        if !force {
            let remote_tip = git::remote_branch_tip(root, &branch);
            if let (Some(ref lt), Some(ref rt)) = (&local_tip, &remote_tip) {
                if lt != rt && !wt_clean {
                    warnings.push(format!(
                        "warning: {branch} local tip differs from origin/{branch} — skipping"
                    ));
                    continue;
                }
            }
        }

        if let Some(ref path) = wt_path {
            if !wt_clean {
                let lbe = git_util::local_branch_exists(root, &branch);
                let diagnosis =
                    diagnose_worktree(path, &id, &t.frontmatter.title, &branch, lbe, &config.worktrees.agent_dirs)?;
                if diagnosis.modified_tracked.is_empty() {
                    if force {
                        // Force mode: git worktree remove --force handles remaining files.
                        result.push(CleanCandidate {
                            ticket_id: id,
                            ticket_title: t.frontmatter.title.clone(),
                            branch: branch.clone(),
                            worktree: wt_path,
                            reason: branch_state.clone(),
                            local_branch_exists: lbe,
                            branch_merged: is_merged && is_ancestor,
                            remote_branch_exists: remote_branches.contains(&branch),
                            updated_at: t.frontmatter.updated_at,
                        });
                    } else if untracked || diagnosis.other_untracked.is_empty() {
                        // Auto-remove: known_temp always; other_untracked if --untracked.
                        // Skip actual file removal in dry-run mode.
                        if !dry_run {
                            remove_untracked(path, &diagnosis.known_temp)?;
                            if untracked {
                                remove_untracked(path, &diagnosis.other_untracked)?;
                            }
                        }
                        result.push(CleanCandidate {
                            ticket_id: id,
                            ticket_title: t.frontmatter.title.clone(),
                            branch: branch.clone(),
                            worktree: wt_path,
                            reason: branch_state.clone(),
                            local_branch_exists: lbe,
                            branch_merged: is_merged && is_ancestor,
                            remote_branch_exists: remote_branches.contains(&branch),
                            updated_at: t.frontmatter.updated_at,
                        });
                    } else {
                        dirty_result.push(diagnosis);
                    }
                } else {
                    dirty_result.push(diagnosis);
                }
                continue;
            }
        }

        let local_branch_exists = git_util::local_branch_exists(root, &branch);

        if wt_path.is_none() && !local_branch_exists {
            continue;
        }

        result.push(CleanCandidate {
            ticket_id: id,
            ticket_title: t.frontmatter.title.clone(),
            branch: branch.clone(),
            worktree: wt_path,
            reason: branch_state.clone(),
            local_branch_exists,
            branch_merged: is_merged && is_ancestor,
            remote_branch_exists: remote_branches.contains(&branch),
            updated_at: t.frontmatter.updated_at,
        });
    }

    Ok((result, dirty_result, warnings))
}

/// Find ticket branches that exist on origin but have no local head.
/// For each, look up the ticket file on the default branch (in
/// `tickets/` first, then `archive_dir/`) and accept it as a candidate
/// if its state is terminal. These are pure remote-deletion candidates:
/// no worktree, no local branch.
///
/// `skip` is the set of branches already returned by `candidates()` so
/// we don't double-process branches that have a local head.
pub fn remote_only_candidates(
    root: &Path,
    config: &Config,
    skip: &std::collections::HashSet<String>,
) -> Result<Vec<CleanCandidate>> {
    let terminal_states = config.terminal_state_ids();
    let default_branch = &config.project.default_branch;
    let remote_branches = git_util::list_remote_ticket_branches(root);
    let primary_dir = config.tickets.dir.to_string_lossy().to_string();
    let archive_dir = config.tickets.archive_dir.as_ref().map(|p| p.to_string_lossy().to_string());

    let mut result = Vec::new();
    for branch in remote_branches {
        if skip.contains(&branch) {
            continue;
        }
        let suffix = branch.trim_start_matches("ticket/");
        let primary_path = format!("{primary_dir}/{suffix}.md");
        let (content, content_path) = match git::read_from_branch(root, default_branch, &primary_path) {
            Ok(c) => (c, primary_path),
            Err(_) => match &archive_dir {
                Some(archive) => {
                    let archive_path = format!("{archive}/{suffix}.md");
                    match git::read_from_branch(root, default_branch, &archive_path) {
                        Ok(c) => (c, archive_path),
                        Err(_) => continue,
                    }
                }
                None => continue,
            },
        };
        let dummy = root.join(&content_path);
        let ticket = match ticket_fmt::Ticket::parse(&dummy, &content) {
            Ok(t) => t,
            Err(_) => continue,
        };
        if !terminal_states.contains(ticket.frontmatter.state.as_str()) {
            continue;
        }
        result.push(CleanCandidate {
            ticket_id: ticket.frontmatter.id.clone(),
            ticket_title: ticket.frontmatter.title.clone(),
            branch: branch.clone(),
            worktree: None,
            reason: ticket.frontmatter.state.clone(),
            local_branch_exists: false,
            branch_merged: true,
            remote_branch_exists: true,
            updated_at: ticket.frontmatter.updated_at,
        });
    }
    Ok(result)
}

pub fn remove(root: &Path, candidate: &CleanCandidate, force: bool, remove_branches: bool) -> Result<RemoveOutput> {
    let mut warnings: Vec<String> = Vec::new();

    if let Some(ref path) = candidate.worktree {
        if path.exists() {
            worktree::remove_worktree(root, path, force)?;
        } else {
            // Path no longer on disk — prune dangling registry entry only.
            // `--expire now` overrides git's default grace period (a few
            // months) and prunes every stale entry immediately, not just
            // this candidate's. Acceptable because APM expects to own the
            // worktrees directory; unrelated stale entries from manual
            // workflows will also be cleaned.
            crate::git_util::run(root, &["worktree", "prune", "--expire", "now"])?;
        }
    }

    if remove_branches {
        // All candidates are already filtered to terminal states (the
        // supervisor's verdict that the work is done), so we don't gate
        // branch deletion on merge-into-main. The branch's commits live
        // either in main, in an epic branch (preserved separately), or
        // in the reflog if neither — the supervisor's `closed` decision
        // is the authority.
        let _ = force; // accepted for API stability; no longer gates deletion
        if candidate.local_branch_exists {
            git_util::delete_local_branch(root, &candidate.branch, &mut warnings);
            // Prune the remote tracking ref so sync_local_ticket_refs does not
            // recreate the local branch on the next apm sync.
            git_util::prune_remote_tracking(root, &candidate.branch);
        }
        // Remote deletion: only attempt when origin actually has the
        // branch (authoritative ls-remote check at candidate-collection
        // time). Avoids noisy "remote ref does not exist" failures that
        // happen routinely when GitHub auto-deletes branches on merge.
        //
        // On success, also prune the local remote-tracking ref. `git push
        // origin --delete` updates the remote but leaves the local
        // refs/remotes/origin/<branch> ref behind; without this prune the
        // stale ref would keep surfacing the ticket in `apm list`.
        if candidate.remote_branch_exists {
            match git_util::delete_remote_branch(root, &candidate.branch) {
                Ok(()) => git_util::prune_remote_tracking(root, &candidate.branch),
                Err(e) => warnings.push(format!(
                    "warning: could not delete remote branch {}: {e}",
                    candidate.branch
                )),
            }
        }
    }

    Ok(RemoveOutput { warnings })
}

/// Parse an --older-than threshold into a UTC DateTime.
/// Accepts "Nd" (N days ago) or "YYYY-MM-DD" (ISO date).
pub fn parse_older_than(s: &str) -> anyhow::Result<DateTime<Utc>> {
    if let Some(days_str) = s.strip_suffix('d') {
        let days: i64 = days_str
            .parse()
            .map_err(|_| anyhow::anyhow!("--older-than: invalid days value {:?}", s))?;
        return Ok(Utc::now() - chrono::Duration::days(days));
    }
    if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
        return Ok(date.and_hms_opt(0, 0, 0).unwrap().and_utc());
    }
    anyhow::bail!(
        "--older-than: unrecognised format {:?}; use \"30d\" or \"YYYY-MM-DD\"",
        s
    )
}

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

    #[test]
    fn parse_older_than_days() {
        let threshold = parse_older_than("30d").unwrap();
        let expected = Utc::now() - chrono::Duration::days(30);
        // Allow a few seconds of skew between the two Utc::now() calls.
        assert!((threshold - expected).num_seconds().abs() < 5);
    }

    #[test]
    fn parse_older_than_iso_date() {
        let threshold = parse_older_than("2026-01-01").unwrap();
        assert_eq!(threshold.format("%Y-%m-%d").to_string(), "2026-01-01");
    }

    #[test]
    fn parse_older_than_invalid_rejects() {
        assert!(parse_older_than("notadate").is_err());
        assert!(parse_older_than("30").is_err());
        assert!(parse_older_than("").is_err());
    }

    #[test]
    fn parse_older_than_zero_days() {
        let threshold = parse_older_than("0d").unwrap();
        let now = Utc::now();
        assert!((threshold - now).num_seconds().abs() < 5);
    }
}
