//! The reconcile pipeline (plan steps 3-5) plus the `init` and `validate`
//! entry points the runner dispatches to.
//!
//! For each enabled policy: invoke it, lower each erased requirement through
//! its adapter, group engine requirements by engine id, and reconcile each
//! engine against the file it owns.

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::Command;

use aqc_file_engine_core::{Finding, Provenance, Severity};
use shakrs_core::EngineRequirementOutput;
use shakrs_json_parser::scaffold_default;

use crate::runtime::{broker, config, fs, waivers};
use crate::types::report;
use crate::types::{
    AbortReason, ConfigError, InitWrites, PendingFileWrite, PendingFileWrites, ReconcileResult,
    ReconcileState, ReconciledFile, Registry, Report, ResolvedConfig, RunnerFindingResult,
    RunnerFindings, RustfmtConfigNamesByDirectory, Summary, UsedAdapterIds,
};

impl core::fmt::Display for AbortReason {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::Config(err) => write!(f, "{err}"),
            Self::Policy { policy, detail } => {
                write!(f, "policy `{policy}` rejected its params: {detail}")
            }
            Self::Broker(detail) | Self::Adapter(detail) => write!(f, "{detail}"),
            Self::Scaffold(detail) => write!(f, "could not scaffold shakrs.json: {detail}"),
            Self::UnknownAdapter(id) => {
                write!(f, "requirements routed to unregistered adapter `{id}`")
            }
        }
    }
}

/// Run policies -> adapters -> engines, returning one reconcile result per
/// engine that received requirements.
///
/// # Errors
///
/// Returns [`AbortReason::Policy`] if a policy rejects its params, or
/// [`AbortReason::Broker`] if reading a managed file fails.
fn reconcile(
    registry: &Registry,
    workspace_root: &Path,
    config: &ResolvedConfig,
) -> ReconcileResult {
    let mut engine_reqs: BTreeMap<&'static str, EngineRequirementOutput> = BTreeMap::new();
    let mut used_adapters: UsedAdapterIds = UsedAdapterIds::new();

    for (policy_id, policy) in &registry.policies {
        let Some(params) = config.enabled.get(*policy_id) else {
            continue;
        };
        let emitted = policy.invoke(params).map_err(|err| AbortReason::Policy {
            policy: (*policy_id).to_owned(),
            detail: err.to_string(),
        })?;
        let provenance = Provenance {
            policy: (*policy_id).to_owned(),
        };
        for requirement in emitted {
            let adapter_id = requirement.adapter_id();
            let Some(adapter) = registry.adapters.get(adapter_id) else {
                return Err(AbortReason::UnknownAdapter(adapter_id.to_owned()));
            };
            let _ = used_adapters.insert(adapter_id);
            let lowered = adapter
                .lower(provenance.clone(), requirement)
                .map_err(|err| AbortReason::Adapter(format!("{err:?}")))?;
            for (engine_provenance, engine_requirement) in lowered {
                engine_reqs
                    .entry(engine_requirement.engine_id())
                    .or_default()
                    .push((engine_provenance, engine_requirement));
            }
        }
    }

    // Step 5: reconcile each engine against the file it owns.
    let mut files: Vec<ReconciledFile> = Vec::new();
    for (engine_id, reqs) in &engine_reqs {
        let file = broker::reconcile_engine(registry, workspace_root, engine_id, reqs)
            .map_err(|err| AbortReason::Broker(err.to_string()))?;
        files.push(file);
    }

    Ok(ReconcileState {
        files,
        used_adapters,
    })
}

/// Run validation commands for adapters that participated in this run.
fn run_validation_commands(
    registry: &Registry,
    workspace_root: &Path,
    used_adapters: &UsedAdapterIds,
) -> Vec<report::ValidationCommandFailure> {
    let mut failures = Vec::new();
    for adapter_id in used_adapters {
        let Some(adapter) = registry.adapters.get(adapter_id) else {
            continue;
        };
        for command in adapter.validation_commands(workspace_root) {
            let output = Command::new("sh")
                .args(["-c", command.command.as_str()])
                .current_dir(workspace_root)
                .output();
            match output {
                Ok(output) if output.status.success() => {}
                Ok(output) => failures.push(report::ValidationCommandFailure {
                    adapter_id: command.adapter_id.to_owned(),
                    command_id: command.command_id.to_owned(),
                    command: command.command,
                    exit_code: output.status.code(),
                    stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
                    stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
                }),
                Err(err) => failures.push(report::ValidationCommandFailure {
                    adapter_id: command.adapter_id.to_owned(),
                    command_id: command.command_id.to_owned(),
                    command: command.command,
                    exit_code: None,
                    stdout: String::new(),
                    stderr: err.to_string(),
                }),
            }
        }
    }
    failures
}

/// Map an engine `Finding` to its report (wire) form, carrying its `key`,
/// `subject`, `current`, `expected`, severity, and contributing policies.
///
/// `subject` is the engine's file name; `key` is the engine's in-file key the
/// finding is about (e.g. `[workspace.lints.clippy].unwrap_used`), or a tag
/// for file-wide findings (`parse`, `internal`). This identity is produced by
/// the engine and surfaced unchanged; it is exactly the `(key, subject)` pair
/// a waiver matches on. The runner constructs no key vocabulary of its own.
#[expect(
    clippy::too_many_lines,
    reason = "One arm per Finding variant; the exhaustive lowering reads best in one place."
)]
fn to_report_finding(subject: &str, finding: &Finding) -> report::Finding {
    let policies = |attribution: &[Provenance]| -> Vec<String> {
        attribution
            .iter()
            .map(|p| p.policy.clone())
            .collect::<BTreeSet<_>>()
            .into_iter()
            .collect()
    };
    let base = |key: String, severity: Severity, message: String| report::Finding {
        key,
        subject: subject.to_owned(),
        selector: None,
        severity: severity_str(severity).to_owned(),
        message,
        current: None,
        expected: None,
        policies: Vec::new(),
        waivable: true,
    };
    match finding {
        Finding::Mismatch {
            key,
            current,
            expected,
            message,
            severity,
            attribution,
        } => report::Finding {
            current: current.clone(),
            expected: Some(expected.clone()),
            policies: policies(attribution),
            ..base(key.clone(), *severity, message.clone())
        },
        Finding::UnwritableRequiredKey {
            key,
            expected,
            attribution,
        } => report::Finding {
            expected: Some(expected.clone()),
            policies: policies(attribution),
            waivable: false,
            ..base(
                key.clone(),
                Severity::Error,
                "key is required but the file role forbids writing it".to_owned(),
            )
        },
        Finding::InvalidRequirements {
            key,
            message,
            contributors,
        } => report::Finding {
            policies: contributors
                .iter()
                .map(|(policy, _)| policy.clone())
                .collect(),
            waivable: false,
            ..base(key.clone(), Severity::Error, message.clone())
        },
        Finding::ParseError { message, severity } => report::Finding {
            waivable: false,
            ..base("parse".to_owned(), *severity, message.clone())
        },
        Finding::ConflictingRequirements {
            subject: _,
            key,
            contributors,
            reason,
        } => {
            let detail = contributors
                .iter()
                .map(|(policy, value)| format!("{policy}={value}"))
                .collect::<Vec<_>>()
                .join(", ");
            report::Finding {
                policies: contributors
                    .iter()
                    .map(|(policy, _)| policy.clone())
                    .collect(),
                waivable: false,
                ..base(key.clone(), Severity::Error, format!("{reason} ({detail})"))
            }
        }
        Finding::InternalError { message } => report::Finding {
            waivable: false,
            ..base("internal".to_owned(), Severity::Error, message.clone())
        },
    }
}

/// Lower a [`Severity`] to its report string.
const fn severity_str(severity: Severity) -> &'static str {
    match severity {
        Severity::Error => "error",
        Severity::Warning => "warning",
        Severity::Info => "info",
    }
}

/// The file name (used as a finding's `subject`) for a reconciled file.
fn subject_of(file: &ReconciledFile) -> String {
    file.path.file_name().map_or_else(
        || file.path.display().to_string(),
        |name| name.to_string_lossy().into_owned(),
    )
}

/// `validate workspace`: reconcile, apply waivers, build the JSON report.
///
/// # Errors
///
/// Returns [`AbortReason`] for any config, policy, or broker failure. The
/// caller maps an abort to exit code 2.
pub fn validate_workspace(
    registry: &Registry,
    workspace_root: &Path,
) -> Result<Report, AbortReason> {
    let config = config::load_and_resolve(workspace_root, registry).map_err(AbortReason::Config)?;
    let state = reconcile(registry, workspace_root, &config)?;

    let mut findings: Vec<report::Finding> = Vec::new();
    for file in &state.files {
        let subject = subject_of(file);
        for finding in &file.findings {
            findings.push(to_report_finding(&subject, finding));
        }
    }
    findings.extend(rustfmt_file_tree_findings(workspace_root, &config)?);
    let applied = waivers::apply(findings, config.waivers);
    let has_error = applied.kept.iter().any(|f| f.severity.as_str() == "error");
    let validation_command_failures = if has_error {
        Vec::new()
    } else {
        run_validation_commands(registry, workspace_root, &state.used_adapters)
    };
    // Severity-aware: a run fails only on an error-severity finding. Warnings
    // (including unused-waiver warnings) and info are surfaced but do not fail.
    let exit_code = i32::from(has_error || !validation_command_failures.is_empty());
    let summary = Summary {
        findings: applied.kept.len(),
        waived: applied.waived.len(),
        exit_code,
    };

    Ok(Report {
        version: 1,
        command: "validate".to_owned(),
        status: "ok".to_owned(),
        workspace: workspace_root.display().to_string(),
        findings: applied.kept,
        waived: applied.waived,
        written: Vec::new(),
        skipped: Vec::new(),
        scaffolded: false,
        validation_command_failures,
        reason: None,
        summary,
    })
}

/// `init workspace`: scaffold `shakrs.json` if absent, then reconcile and write
/// each managed file (refusing any file with hard-failure findings).
///
/// Waivers do not affect `init`: a waiver suppresses a `validate` finding, while
/// `init` writes the policy-compliant value. Keeping a deviation means not
/// running `init` on that file, not writing a waiver.
///
/// # Errors
///
/// Returns [`AbortReason`] for a non-absence config error, a policy params
/// error, or a broker read/write failure.
pub fn init_workspace(
    registry: &Registry,
    workspace_root: &Path,
) -> Result<InitWrites, AbortReason> {
    let mut scaffolded = false;
    let config = match config::load_and_resolve(workspace_root, registry) {
        Ok(config) => config,
        Err(ConfigError::Missing) => {
            let bytes = scaffold_default().map_err(|err| AbortReason::Scaffold(err.to_string()))?;
            let path = workspace_root.join("shakrs.json");
            fs::write(&path, &bytes).map_err(|err| AbortReason::Scaffold(err.to_string()))?;
            scaffolded = true;
            config::load_and_resolve(workspace_root, registry).map_err(AbortReason::Config)?
        }
        Err(other) => return Err(AbortReason::Config(other)),
    };

    let state = reconcile(registry, workspace_root, &config)?;

    // Phase 1: partition. A file with any hard-failure finding is skipped; a
    // file with real bytes and no hard failure is staged for an all-or-nothing
    // write. Every finding is lowered so `init` can explain a refusal.
    let mut outcome = InitWrites {
        scaffolded,
        ..InitWrites::default()
    };
    let mut writes: PendingFileWrites = Vec::new();
    for file in &state.files {
        if file.findings.iter().any(broker::is_hard_failure) {
            // A file the engine could not produce cleanly: skip it and surface
            // the findings that PREVENTED the write, so init explains a refusal.
            let subject = subject_of(file);
            for finding in &file.findings {
                outcome.findings.push(to_report_finding(&subject, finding));
            }
            outcome
                .skipped
                .push(render_workspace_path(workspace_root, &file.path));
            continue;
        }
        if file.expected_bytes.is_empty() {
            // Synthetic adapter-findings entry with no hard finding and no real
            // file: nothing to write, nothing to report.
            continue;
        }
        // Writable: any Mismatch here is exactly the drift init corrects by
        // writing, not a problem to report.
        writes.push((file.path.clone(), file.expected_bytes.clone()));
    }
    let file_tree_findings =
        rustfmt_file_tree_findings_with_extra_paths(workspace_root, &config, &writes)?;
    outcome.findings.extend(file_tree_findings);

    if outcome
        .findings
        .iter()
        .any(|finding| finding.severity == "error")
    {
        for (path, _) in writes {
            let rendered = render_workspace_path(workspace_root, &path);
            if !outcome.skipped.contains(&rendered) {
                outcome.skipped.push(rendered);
            }
        }
        return Ok(outcome);
    }

    outcome.written.extend(
        writes
            .iter()
            .map(|(path, _)| render_workspace_path(workspace_root, path)),
    );

    // Phase 2: commit the clean set atomically. Either every staged file lands
    // or none does — no sibling is left half-migrated.
    fs::write_all(&writes).map_err(|err| AbortReason::Broker(err.to_string()))?;
    outcome.validation_command_failures =
        run_validation_commands(registry, workspace_root, &state.used_adapters);

    Ok(outcome)
}

fn render_workspace_path(workspace_root: &Path, path: &Path) -> String {
    path.strip_prefix(workspace_root).map_or_else(
        |_| path.display().to_string(),
        |rel| rel.display().to_string(),
    )
}

/// File-tree checks owned by the runner, not the Rustfmt TOML engine.
fn rustfmt_file_tree_findings(
    workspace_root: &Path,
    config: &ResolvedConfig,
) -> RunnerFindingResult {
    rustfmt_file_tree_findings_with_extra_paths(workspace_root, config, &[])
}

/// Report Rustfmt file-tree findings, including paths init is about to write.
fn rustfmt_file_tree_findings_with_extra_paths(
    workspace_root: &Path,
    config: &ResolvedConfig,
    extra_paths: &[PendingFileWrite],
) -> RunnerFindingResult {
    if !config.enabled.contains_key("shakrs-rustfmt-policy") {
        return Ok(Vec::new());
    }
    let mut paths = fs::rustfmt_config_paths(workspace_root).map_err(|err| {
        AbortReason::Broker(format!(
            "could not scan Rustfmt config files under {}: {err}",
            workspace_root.display()
        ))
    })?;
    paths.extend(extra_paths.iter().map(|(path, _)| path.clone()));
    paths.sort();
    paths.dedup();
    Ok(build_rustfmt_file_tree_findings(workspace_root, &paths))
}

/// Convert discovered Rustfmt config paths into runner report findings.
fn build_rustfmt_file_tree_findings(workspace_root: &Path, paths: &[PathBuf]) -> RunnerFindings {
    let mut findings = Vec::new();
    let mut dirs: RustfmtConfigNamesByDirectory<'_> = BTreeMap::new();
    for path in paths {
        let rel = relative_path(workspace_root, path);
        let parent = path
            .parent()
            .map(|dir| relative_path(workspace_root, dir))
            .filter(|dir| !dir.is_empty())
            .unwrap_or_else(|| ".".to_owned());
        if let Some(name) = path.file_name().and_then(|name| name.to_str()) {
            let _ = dirs.entry(parent).or_default().insert(name);
        }
        match rel.as_str() {
            "rustfmt.toml" => {}
            ".rustfmt.toml" => findings.push(file_tree_finding(
                ".rustfmt.toml",
                "root-dot-rustfmt-toml",
                "Root `.rustfmt.toml` is forbidden. Keep `rustfmt.toml` at the workspace root.",
            )),
            _ if rel.ends_with("/rustfmt.toml") => findings.push(file_tree_finding(
                &rel,
                "nested-rustfmt-toml",
                "Nested `rustfmt.toml` is forbidden. Keep Rustfmt policy at the workspace root.",
            )),
            _ if rel.ends_with("/.rustfmt.toml") => findings.push(file_tree_finding(
                &rel,
                "nested-dot-rustfmt-toml",
                "Nested `.rustfmt.toml` is forbidden. Keep Rustfmt policy at the workspace root.",
            )),
            _ => {}
        }
    }
    for (dir, names) in dirs {
        if !(names.contains("rustfmt.toml") && names.contains(".rustfmt.toml")) {
            continue;
        }
        let subject = if dir == "." {
            "rustfmt.toml".to_owned()
        } else {
            format!("{dir}/rustfmt.toml")
        };
        findings.push(file_tree_finding(
            &subject,
            "rustfmt-file-conflict",
            "Both `rustfmt.toml` and `.rustfmt.toml` exist in the same directory. Delete `.rustfmt.toml`.",
        ));
    }
    findings
}

/// Render a filesystem path relative to the workspace root with `/` separators.
fn relative_path(root: &Path, path: &Path) -> String {
    path.strip_prefix(root)
        .unwrap_or(path)
        .to_string_lossy()
        .replace('\\', "/")
}

/// Build one Rustfmt file-tree report finding.
fn file_tree_finding(subject: &str, key: &str, message: &str) -> report::Finding {
    report::Finding {
        key: key.to_owned(),
        subject: subject.to_owned(),
        selector: None,
        severity: "error".to_owned(),
        message: message.to_owned(),
        current: Some("present".to_owned()),
        expected: Some("absent".to_owned()),
        policies: vec!["shakrs-rustfmt-policy".to_owned()],
        waivable: true,
    }
}

/// Build an aborted report document.
#[must_use]
pub fn abort_report(command: &str, workspace: &Path, reason: &str) -> Report {
    let finding = report::Finding {
        key: "abort".to_owned(),
        subject: "shakrs.json".to_owned(),
        selector: None,
        severity: "error".to_owned(),
        message: reason.to_owned(),
        current: None,
        expected: None,
        policies: Vec::new(),
        waivable: false,
    };
    Report {
        version: 1,
        command: command.to_owned(),
        status: "aborted".to_owned(),
        workspace: workspace.display().to_string(),
        findings: vec![finding],
        waived: Vec::new(),
        written: Vec::new(),
        skipped: Vec::new(),
        scaffolded: false,
        validation_command_failures: Vec::new(),
        reason: Some(reason.to_owned()),
        summary: Summary {
            findings: 1,
            waived: 0,
            exit_code: 2,
        },
    }
}
