use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use inquire::Select;

use crate::config::Config;
use crate::fsx;
use crate::merge::{self, MergeStrategy};
use crate::project;
use crate::registry::{packages, InstallMethod, Package, Scope};
use crate::state::{BlockRecord, MachineInstall, MachineState, ProjectIntent, ProjectState};
use crate::tools::Tool;
use crate::ui::style;

use super::{exec, permissions as perms};

/// `aimgr sync` entry point.
pub fn run_verb(all: bool, global: bool, force: bool) -> Result<()> {
    let cwd = std::env::current_dir().context("getting current directory")?;

    if global {
        bootstrap_global()?;
        if !all {
            return Ok(());
        }
    }

    if all {
        let mut machine = MachineState::load()?;
        let removed = machine.prune_projects();
        for r in &removed {
            eprintln!(
                "{} pruned missing project: {}",
                style::warn_mark(),
                r.display()
            );
        }
        machine.save()?;

        let projects = machine.projects.clone();
        for root in &projects {
            println!("{} {}", style::step_mark(), style::bold(root.display()));
            reconcile_project(root, force)?;
        }
        return Ok(());
    }

    if global {
        return Ok(());
    }

    if let Some(root) = project::discover_root(&cwd) {
        return reconcile_project(&root, force);
    }

    pick_project_flow(force)
}

fn pick_project_flow(force: bool) -> Result<()> {
    let machine = MachineState::load()?;
    if machine.projects.is_empty() {
        bail!(
            "not in a project and no managed projects on record \
             — try `aimgr init` first"
        );
    }
    let choices: Vec<String> = machine
        .projects
        .iter()
        .map(|p| p.display().to_string())
        .collect();
    let picked = Select::new("Pick a managed project to sync:", choices).prompt()?;
    reconcile_project(Path::new(&picked), force)
}

/// Install every tool + package declared in the global config. Project-scope
/// packages are skipped (they only make sense inside a project's intent).
fn bootstrap_global() -> Result<()> {
    let mut machine = MachineState::load()?;
    let mut cfg = Config::load()?;

    let tool_names = cfg.install.tools.clone();
    let package_names = cfg.install.packages.clone();

    if tool_names.is_empty() && package_names.is_empty() {
        println!(
            "nothing declared in {}",
            crate::config::config_file()?.display()
        );
        return Ok(());
    }

    for name in &tool_names {
        let Some(resolved) = crate::registry::tools::resolve(name)? else {
            eprintln!(
                "{} tool `{name}` not in registry — skipping",
                style::warn_mark()
            );
            continue;
        };
        let was_recorded = machine.tools.contains_key(name);
        let reclassified = machine
            .tools
            .get_mut(name)
            .and_then(|rec| reclassify_if_preexisting(&resolved.tool.install, rec));
        exec::ensure_tool_installed(&resolved.tool, &mut machine, &mut cfg, None, false)?;
        report_status(
            name,
            was_recorded,
            reclassified.as_deref(),
            machine.tools.get(name).map(|m| m.method.as_str()),
        );
    }

    for name in &package_names {
        let Some(resolved) = packages::resolve(name)? else {
            eprintln!(
                "{} package `{name}` not in registry — skipping",
                style::warn_mark()
            );
            continue;
        };
        if resolved.package.scope == Scope::Project {
            eprintln!(
                "{} package `{name}` is project-scope — skipping global install",
                style::warn_mark()
            );
            continue;
        }
        let was_recorded = machine.packages.contains_key(name);
        let reclassified = machine
            .packages
            .get_mut(name)
            .and_then(|rec| reclassify_if_preexisting(&resolved.package.install, rec));
        exec::ensure_package_installed(&resolved.package, &mut machine, &mut cfg, None, false)?;
        report_status(
            name,
            was_recorded,
            reclassified.as_deref(),
            machine.packages.get(name).map(|m| m.method.as_str()),
        );
        if let Some(rec) = machine.packages.get(name) {
            if let Err(e) = perms::replay(&rec.permissions) {
                eprintln!(
                    "{} replaying permissions for `{name}`: {e}",
                    style::warn_mark()
                );
            }
        }
    }

    machine.save()?;
    cfg.save()?;
    Ok(())
}

/// Swap a `"preexisting"` method for the registry's default install method id
/// so `aimgr up` has something to fall back on. Keeps `installed_at` intact.
/// Returns the new method id if a swap happened.
fn reclassify_if_preexisting(
    install_methods: &[InstallMethod],
    rec: &mut MachineInstall,
) -> Option<String> {
    if rec.method != "preexisting" {
        return None;
    }
    let picked = install_methods.first()?;
    rec.method = picked.id.clone();
    Some(picked.id.clone())
}

fn report_status(name: &str, was_recorded: bool, reclassified: Option<&str>, method: Option<&str>) {
    if let Some(new_method) = reclassified {
        println!("  {} {name} reclassified → {new_method}", style::ok_mark());
        return;
    }
    if was_recorded {
        match method {
            Some("preexisting") => println!(
                "  {} {name} preexisting — no install method in registry, `up` can't update",
                style::warn_mark()
            ),
            Some(m) => println!("  {} {name} tracked ({m})", style::ok_mark()),
            None => println!("  {} {name} tracked", style::ok_mark()),
        }
        return;
    }
    match method {
        Some("preexisting") => println!("  {} {name} already present", style::ok_mark()),
        _ => println!("  {} {name} installed", style::ok_mark()),
    }
}

/// Reconcile `.aimgr/aimgr.toml` (intent) against `.aimgr/state.toml` (reality) for one project.
/// Installs missing packages' binaries via machine state; writes/removes instruction blocks;
/// prompts on drift unless `force = true`.
pub fn reconcile_project(project_root: &Path, force: bool) -> Result<()> {
    let intent = ProjectIntent::load(project_root)?;
    let mut state = ProjectState::load(project_root)?;
    let mut machine = MachineState::load()?;
    let mut cfg = Config::load()?;

    let active_tools = resolve_tools(&intent.tools);

    // Add/update packages in intent.
    for name in &intent.packages {
        let Some(resolved) = packages::resolve(name)? else {
            eprintln!(
                "{} package `{name}` not in registry — skipping",
                style::warn_mark()
            );
            continue;
        };
        let package = &resolved.package;

        if package.scope != Scope::Project {
            exec::ensure_package_installed(package, &mut machine, &mut cfg, None, false)?;
        }

        if package.scope != Scope::Global {
            let blocks = render_blocks(project_root, package, &active_tools, &state, force)?;
            state.record(name, blocks);
        }
    }

    // Remove packages in state but not in intent.
    let stale: Vec<String> = state
        .packages
        .keys()
        .filter(|k| !intent.packages.contains(k))
        .cloned()
        .collect();
    for name in stale {
        if let Some(removed) = state.packages.remove(&name) {
            for rec in &removed.blocks {
                unwire_block(project_root, &name, rec)?;
            }
            if let Err(e) = perms::unapply(&removed.permissions) {
                eprintln!(
                    "{} unapplying permissions for `{name}`: {e}",
                    style::warn_mark()
                );
            }
        }
    }

    // Replay any recorded permission grants for project packages still in intent.
    for name in &intent.packages {
        if let Some(pkg_state) = state.packages.get(name) {
            if let Err(e) = perms::replay(&pkg_state.permissions) {
                eprintln!(
                    "{} replaying permissions for `{name}`: {e}",
                    style::warn_mark()
                );
            }
        }
    }

    machine.register_project(project_root);
    state.save(project_root)?;
    machine.save()?;
    cfg.save()?;
    Ok(())
}

fn resolve_tools(declared: &[Tool]) -> Vec<Tool> {
    if declared.is_empty() {
        vec![Tool::Claude]
    } else {
        declared.to_vec()
    }
}

fn render_blocks(
    project_root: &Path,
    package: &Package,
    active: &[Tool],
    prior_state: &ProjectState,
    force: bool,
) -> Result<Vec<BlockRecord>> {
    let package_tools = package.applicable_tools(active);
    let mut records = Vec::new();

    for block in &package.instructions {
        let block_tools: Vec<Tool> = if block.tools.is_empty() {
            package_tools.clone()
        } else {
            block
                .tools
                .iter()
                .copied()
                .filter(|t| package_tools.contains(t))
                .collect()
        };

        for tool in block_tools {
            let rel = PathBuf::from(tool.instructions_path());
            let dest = project_root.join(&rel);
            let current = fs::read_to_string(&dest).unwrap_or_default();
            let prior = find_prior_block(prior_state, &package.name, &rel).cloned();

            let observed_block = merge::markdown::extract_block(&current, &package.name);
            let observed_body = merge::markdown::extract_body(&current, &package.name);
            let (observed_managed, observed_custom) = match observed_body {
                Some(body) => merge::markdown::split_body(body, &package.name),
                None => (String::new(), None),
            };

            let prior_managed: Option<String> = prior.as_ref().and_then(|p| {
                let body = merge::markdown::extract_body(&p.pristine, &package.name)?;
                let (mg, _) = merge::markdown::split_body(body, &package.name);
                Some(mg)
            });

            // Default: new managed body, preserving any existing custom region silently.
            let mut body: String = merge::markdown::compose_body(
                &block.content,
                observed_custom
                    .as_ref()
                    .map(|(pos, content)| (*pos, content.as_str())),
                &package.name,
            );

            // Drift only counts when the managed portion itself diverged from prior
            // — edits confined to an existing custom region are preserved without prompting.
            let desired_managed = block.content.trim_matches('\n');
            let managed_drift = match (prior_managed.as_deref(), observed_block.is_some()) {
                (Some(prior_mg), true) => {
                    observed_managed != prior_mg && observed_managed != desired_managed
                }
                _ => false,
            };

            if managed_drift && !force {
                match prompt_drift(&package.name, &dest, &observed_managed, desired_managed)? {
                    DriftChoice::Overwrite => {
                        body = block.content.clone();
                    }
                    DriftChoice::Skip => {
                        records.push(prior.clone().unwrap());
                        continue;
                    }
                    DriftChoice::PrependNew => {
                        let promoted = match observed_custom.as_ref() {
                            Some((_, existing)) => {
                                format!("{existing}\n\n{observed_managed}")
                            }
                            None => observed_managed.clone(),
                        };
                        body = merge::markdown::compose_body(
                            &block.content,
                            Some((merge::markdown::CustomPosition::Bottom, &promoted)),
                            &package.name,
                        );
                    }
                    DriftChoice::AppendNew => {
                        let promoted = match observed_custom.as_ref() {
                            Some((_, existing)) => {
                                format!("{observed_managed}\n\n{existing}")
                            }
                            None => observed_managed.clone(),
                        };
                        body = merge::markdown::compose_body(
                            &block.content,
                            Some((merge::markdown::CustomPosition::Top, &promoted)),
                            &package.name,
                        );
                    }
                    DriftChoice::Abort => {
                        bail!("sync aborted due to drift in {}", dest.display())
                    }
                }
            }

            let merged =
                merge::markdown::merge(&current, &body, MergeStrategy::Append, &package.name);
            fsx::atomic_write(&dest, &merged)?;
            records.push(BlockRecord {
                tool,
                path: rel,
                pristine: merge::markdown::render_block(&package.name, &body),
            });
        }
    }
    Ok(records)
}

fn find_prior_block<'a>(
    state: &'a ProjectState,
    name: &str,
    rel: &Path,
) -> Option<&'a BlockRecord> {
    state
        .packages
        .get(name)?
        .blocks
        .iter()
        .find(|b| b.path == rel)
}

enum DriftChoice {
    Overwrite,
    Skip,
    PrependNew,
    AppendNew,
    Abort,
}

fn prompt_drift(
    name: &str,
    path: &Path,
    observed_body: &str,
    desired_body: &str,
) -> Result<DriftChoice> {
    const OVERWRITE: &str = "overwrite (replace with new)";
    const DIFF: &str = "show diff";
    const KEEP: &str = "keep mine…";
    const ABORT: &str = "abort";
    const SKIP: &str = "skip (don't touch the file)";
    const PREPEND: &str = "prepend new content above mine";
    const APPEND: &str = "append new content below mine";

    loop {
        let choice = Select::new(
            &format!(
                "`{name}` block in {} was hand-edited. What now?",
                path.display()
            ),
            vec![OVERWRITE, DIFF, KEEP, ABORT],
        )
        .prompt()?;
        match choice {
            OVERWRITE => return Ok(DriftChoice::Overwrite),
            DIFF => print_diff(observed_body, desired_body),
            KEEP => {
                let sub = Select::new(
                    "How do you want to keep your edits?",
                    vec![SKIP, PREPEND, APPEND],
                )
                .prompt()?;
                return Ok(match sub {
                    PREPEND => DriftChoice::PrependNew,
                    APPEND => DriftChoice::AppendNew,
                    _ => DriftChoice::Skip,
                });
            }
            _ => return Ok(DriftChoice::Abort),
        }
    }
}

fn print_diff(observed: &str, desired: &str) {
    use similar::{ChangeTag, TextDiff};
    let diff = TextDiff::from_lines(observed, desired);
    println!("\n{}", style::dim("--- yours"));
    println!("{}", style::dim("+++ new"));
    for change in diff.iter_all_changes() {
        let text = change.value();
        let trimmed = text.strip_suffix('\n').unwrap_or(text);
        let line = match change.tag() {
            ChangeTag::Delete => style::removed(format!("-{trimmed}")),
            ChangeTag::Insert => style::added(format!("+{trimmed}")),
            ChangeTag::Equal => format!(" {trimmed}"),
        };
        println!("{line}");
    }
    println!();
}

pub(super) fn unwire_block(project_root: &Path, name: &str, rec: &BlockRecord) -> Result<()> {
    let path = project_root.join(&rec.path);
    if !path.exists() {
        return Ok(());
    }
    let current =
        fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
    let updated = merge::markdown::remove(&current, name);
    if updated.is_empty() {
        fs::remove_file(&path).ok();
    } else {
        fsx::atomic_write(&path, &updated)?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::registry::packages::InstructionsBlock;
    use crate::registry::Prereq;
    use std::collections::BTreeMap;
    use tempfile::tempdir;

    fn fake_package(name: &str) -> Package {
        Package {
            name: name.into(),
            description: None,
            homepage: None,
            tools: vec![Tool::Claude],
            scope: Scope::Project,
            depends_on: Vec::new(),
            artifacts: Vec::new(),
            verify: None,
            prereq: Vec::<Prereq>::new(),
            install: Vec::new(),
            update: None,
            uninstall: None,
            instructions: vec![InstructionsBlock {
                content: "use me".into(),
                tools: Vec::new(),
            }],
            permissions: Vec::new(),
        }
    }

    fn install_method(id: &str, command: &str) -> InstallMethod {
        InstallMethod {
            id: id.into(),
            label: None,
            command: command.into(),
            requires: Vec::new(),
            interactive: false,
        }
    }

    fn machine_install(method: &str) -> MachineInstall {
        MachineInstall {
            installed_at: "t0".into(),
            method: method.into(),
            version: None,
            permissions: Vec::new(),
        }
    }

    #[test]
    fn reclassify_swaps_preexisting_for_first_install_method() {
        let methods = vec![
            install_method("curl", "curl … | sh"),
            install_method("go", "go install …"),
        ];
        let mut rec = machine_install("preexisting");
        let picked = reclassify_if_preexisting(&methods, &mut rec);
        assert_eq!(picked.as_deref(), Some("curl"));
        assert_eq!(rec.method, "curl");
        assert_eq!(rec.installed_at, "t0");
    }

    #[test]
    fn reclassify_leaves_real_method_alone() {
        let methods = vec![install_method("curl", "curl … | sh")];
        let mut rec = machine_install("npx");
        let picked = reclassify_if_preexisting(&methods, &mut rec);
        assert!(picked.is_none());
        assert_eq!(rec.method, "npx");
    }

    #[test]
    fn reclassify_noop_when_registry_has_no_methods() {
        let mut rec = machine_install("preexisting");
        let picked = reclassify_if_preexisting(&[], &mut rec);
        assert!(picked.is_none());
        assert_eq!(rec.method, "preexisting");
    }

    #[test]
    fn render_blocks_writes_pristine_block_and_records_it() {
        let tmp = tempdir().unwrap();
        let root = tmp.path();
        let package = fake_package("demo");
        let state = ProjectState::default();
        let records = render_blocks(root, &package, &[Tool::Claude], &state, false).unwrap();
        assert_eq!(records.len(), 1);
        let written = fs::read_to_string(root.join("CLAUDE.md")).unwrap();
        assert!(written.contains("<!-- BEGIN aimgr: demo -->"));
        assert!(records[0].pristine.contains("use me"));
    }

    #[test]
    fn pristine_rewrite_is_silent_when_block_matches_prior() {
        let tmp = tempdir().unwrap();
        let root = tmp.path();
        let package = fake_package("demo");

        let first = render_blocks(
            root,
            &package,
            &[Tool::Claude],
            &ProjectState::default(),
            false,
        )
        .unwrap();
        let mut state = ProjectState::default();
        state.packages.insert(
            "demo".into(),
            crate::state::ProjectPackage {
                installed_at: "t".into(),
                blocks: first.clone(),
                permissions: Vec::new(),
            },
        );

        // No force, no drift (file unchanged) → should succeed without prompt.
        let again = render_blocks(root, &package, &[Tool::Claude], &state, false).unwrap();
        assert_eq!(again[0].pristine, first[0].pristine);
    }

    #[test]
    fn unwire_removes_block_and_deletes_empty_file() {
        let tmp = tempdir().unwrap();
        let root = tmp.path();
        let package = fake_package("demo");
        render_blocks(
            root,
            &package,
            &[Tool::Claude],
            &ProjectState::default(),
            false,
        )
        .unwrap();
        let rec = BlockRecord {
            tool: Tool::Claude,
            path: PathBuf::from("CLAUDE.md"),
            pristine: String::new(),
        };
        unwire_block(root, "demo", &rec).unwrap();
        assert!(!root.join("CLAUDE.md").exists());
    }

    // Silence unused-import warnings in this tests module if any.
    #[allow(dead_code)]
    fn _unused_btreemap() -> BTreeMap<(), ()> {
        BTreeMap::new()
    }
}
