//! Live rule-engine diagnostics for harn-lsp.
//!
//! The normal Harn-language diagnostics still come from the parser,
//! typechecker, and harn-lint. This module adds the language-agnostic rule
//! engine path: load configured TOML rules, run rules that target the opened
//! document's language, and expose their fixes through the same LSP
//! `repair_id`/`safety` envelope as native Harn repairs.

use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use harn_hostlib::ast::Language;
use harn_rules::{CompiledRule, Rule, Safety, Severity};
use serde::Deserialize;
use serde_json::Value;
use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Range, TextEdit, Url};

use crate::helpers::offset_to_position;

const RULE_DIAGNOSTIC_CODE: &str = "HARN-RUL-001";
const RULE_CONFIG_CODE: &str = "HARN-RUL-CONFIG";
const RULE_SOURCE: &str = "harn-rules";

#[derive(Clone)]
pub(crate) struct RuleWorkspace {
    root: Option<PathBuf>,
    enabled: bool,
    specs: Arc<Vec<RuleSpec>>,
    load_errors: Arc<Vec<RuleLoadError>>,
}

impl Default for RuleWorkspace {
    fn default() -> Self {
        Self {
            root: None,
            enabled: true,
            specs: Arc::new(Vec::new()),
            load_errors: Arc::new(Vec::new()),
        }
    }
}

impl RuleWorkspace {
    pub(crate) fn from_initialize(params: &tower_lsp::lsp_types::InitializeParams) -> Self {
        let root = workspace_root(params);
        let settings = RuleSettings::from_value(params.initialization_options.as_ref());
        Self::load(root, settings)
    }

    pub(crate) fn reconfigure(&mut self, value: Option<&Value>) {
        let root = self.root.clone();
        let settings = RuleSettings::from_value(value);
        *self = Self::load(root, settings);
    }

    #[cfg(test)]
    pub(crate) fn from_root(root: impl Into<PathBuf>) -> Self {
        Self::load(Some(root.into()), RuleSettings::default())
    }

    pub(crate) fn diagnostics_for_document(
        &self,
        uri: &Url,
        language_id: &str,
        source: &str,
    ) -> Vec<RuleDiagnostic> {
        if !self.enabled {
            return Vec::new();
        }

        let language = document_language(uri, language_id);
        let mut diagnostics = self
            .load_errors
            .iter()
            .map(RuleDiagnostic::from_load_error)
            .collect::<Vec<_>>();

        let Some(language) = language else {
            return diagnostics;
        };

        for spec in self.specs.iter().filter(|spec| spec.language == language) {
            diagnostics.extend(spec.diagnostics(source));
        }

        diagnostics
    }

    fn load(root: Option<PathBuf>, settings: RuleSettings) -> Self {
        if !settings.enabled {
            return Self {
                root,
                enabled: false,
                specs: Arc::new(Vec::new()),
                load_errors: Arc::new(Vec::new()),
            };
        }

        let mut specs = Vec::new();
        let mut errors = Vec::new();
        let Some(root_path) = root.as_deref() else {
            return Self {
                root,
                enabled: true,
                specs: Arc::new(specs),
                load_errors: Arc::new(errors),
            };
        };

        let mut seen_dirs = HashSet::new();
        for dir in project_rule_dirs(root_path).into_iter().chain(
            settings
                .rule_dirs
                .iter()
                .map(|dir| resolve_path(root_path, dir)),
        ) {
            if seen_dirs.insert(dir.clone()) {
                load_rule_dir(&dir, &mut specs, &mut errors);
            }
        }

        for pack in &settings.rule_packs {
            match resolve_rule_pack(root_path, pack) {
                Some(paths) => {
                    for dir in paths {
                        if seen_dirs.insert(dir.clone()) {
                            load_rule_dir(&dir, &mut specs, &mut errors);
                        }
                    }
                }
                None => errors.push(RuleLoadError {
                    path: root_path.to_path_buf(),
                    message: format!("rule pack `{pack}` is not installed or is not a directory"),
                }),
            }
        }

        Self {
            root,
            enabled: true,
            specs: Arc::new(specs),
            load_errors: Arc::new(errors),
        }
    }
}

#[derive(Clone)]
pub(crate) struct RuleDiagnostic {
    pub(crate) diagnostic: Diagnostic,
    pub(crate) edit: Option<TextEdit>,
    pub(crate) repair_id: Option<String>,
    pub(crate) title: String,
}

impl RuleDiagnostic {
    fn from_load_error(error: &RuleLoadError) -> Self {
        Self {
            diagnostic: Diagnostic {
                range: Range::default(),
                severity: Some(DiagnosticSeverity::ERROR),
                source: Some(RULE_SOURCE.to_string()),
                code: Some(NumberOrString::String(RULE_CONFIG_CODE.to_string())),
                message: format!(
                    "Rule pack load failed for `{}`: {}",
                    error.path.display(),
                    error.message
                ),
                ..Default::default()
            },
            edit: None,
            repair_id: None,
            title: "Rule pack load failed".to_string(),
        }
    }

    fn from_rule(rule: &RuleSpec, diag: harn_rules::Diagnostic, source: &str) -> Self {
        let range = rule_span_to_range(&diag.span, source);
        let repair_id = diag.fix.as_ref().map(|_| {
            format!(
                "rules/{}/{}-{}",
                diag.rule_id, diag.span.start_byte, diag.span.end_byte
            )
        });
        let safety = rule.safety.as_str();
        let title = if diag.fix.is_some() {
            format!("Apply rule fix `{}`", diag.rule_id)
        } else {
            format!("Inspect rule `{}`", diag.rule_id)
        };
        let data = repair_id.as_ref().map(|id| {
            serde_json::json!({
                "code": RULE_DIAGNOSTIC_CODE,
                "rule_id": diag.rule_id,
                "repair_id": id,
                "safety": safety,
                "repair": {
                    "id": id,
                    "summary": title,
                    "safety": safety,
                },
            })
        });
        let message = if diag.message.is_empty() {
            format!("[{}] rule matched", diag.rule_id)
        } else {
            format!("[{}] {}", diag.rule_id, diag.message)
        };
        Self {
            diagnostic: Diagnostic {
                range,
                severity: Some(severity_to_lsp(diag.severity)),
                source: Some(RULE_SOURCE.to_string()),
                code: Some(NumberOrString::String(RULE_DIAGNOSTIC_CODE.to_string())),
                message,
                data,
                ..Default::default()
            },
            edit: diag.fix.map(|replacement| TextEdit {
                range,
                new_text: replacement,
            }),
            repair_id,
            title,
        }
    }
}

#[derive(Clone)]
struct RuleSpec {
    path: PathBuf,
    language: Language,
    rule: Rule,
    safety: Safety,
}

impl RuleSpec {
    fn diagnostics(&self, source: &str) -> Vec<RuleDiagnostic> {
        let compiled = match CompiledRule::compile(&self.rule) {
            Ok(compiled) => compiled,
            Err(error) => {
                return vec![RuleDiagnostic::from_load_error(&RuleLoadError {
                    path: self.path.clone(),
                    message: error.to_string(),
                })];
            }
        };
        match compiled.diagnostics(source) {
            Ok(items) => items
                .into_iter()
                .map(|diagnostic| RuleDiagnostic::from_rule(self, diagnostic, source))
                .collect(),
            Err(error) => vec![RuleDiagnostic::from_load_error(&RuleLoadError {
                path: self.path.clone(),
                message: error.to_string(),
            })],
        }
    }
}

#[derive(Clone)]
struct RuleLoadError {
    path: PathBuf,
    message: String,
}

struct RuleSettings {
    enabled: bool,
    rule_dirs: Vec<String>,
    rule_packs: Vec<String>,
}

impl Default for RuleSettings {
    fn default() -> Self {
        Self {
            enabled: true,
            rule_dirs: Vec::new(),
            rule_packs: Vec::new(),
        }
    }
}

impl RuleSettings {
    fn from_value(value: Option<&Value>) -> Self {
        let Some(value) = value else {
            return Self {
                enabled: true,
                ..Default::default()
            };
        };
        let harn = value.get("harn").unwrap_or(value);
        let rules = harn
            .get("rules")
            .or_else(|| harn.get("ruleEngine"))
            .unwrap_or(harn);
        Self {
            enabled: rules
                .get("enabled")
                .and_then(Value::as_bool)
                .unwrap_or(true),
            rule_dirs: string_array(rules, &["ruleDirs", "rule_dirs", "rule-dirs"]),
            rule_packs: string_array(rules, &["rulePacks", "rule_packs", "rule-packs"]),
        }
    }
}

fn string_array(value: &Value, keys: &[&str]) -> Vec<String> {
    keys.iter()
        .find_map(|key| value.get(*key))
        .and_then(Value::as_array)
        .map(|items| {
            items
                .iter()
                .filter_map(Value::as_str)
                .map(str::trim)
                .filter(|item| !item.is_empty())
                .map(ToOwned::to_owned)
                .collect()
        })
        .unwrap_or_default()
}

fn workspace_root(params: &tower_lsp::lsp_types::InitializeParams) -> Option<PathBuf> {
    params
        .workspace_folders
        .as_ref()
        .and_then(|folders| folders.first())
        .and_then(|folder| folder.uri.to_file_path().ok())
        .or_else(|| {
            params
                .root_uri
                .as_ref()
                .and_then(|uri| uri.to_file_path().ok())
        })
}

fn document_language(uri: &Url, language_id: &str) -> Option<Language> {
    let normalized = match language_id {
        "typescriptreact" => "tsx",
        "javascriptreact" => "jsx",
        other => other,
    };
    let path = uri.to_file_path().ok();
    path.as_deref()
        .and_then(|path| {
            Language::detect(path, Some(normalized)).or_else(|| Language::detect(path, None))
        })
        .or_else(|| Language::from_name(normalized))
}

fn resolve_path(root: &Path, raw: &str) -> PathBuf {
    let path = Path::new(raw);
    if path.is_absolute() {
        path.to_path_buf()
    } else {
        root.join(path)
    }
}

#[derive(Default, Deserialize)]
struct MinimalManifest {
    #[serde(default)]
    rules: MinimalRules,
}

#[derive(Default, Deserialize)]
struct MinimalRules {
    #[serde(default, alias = "ruleDirs", alias = "rule-dirs")]
    rule_dirs: Vec<String>,
}

fn project_rule_dirs(root: &Path) -> Vec<PathBuf> {
    let Some((manifest, dir)) = find_nearest_manifest(root) else {
        return Vec::new();
    };
    manifest
        .rules
        .rule_dirs
        .iter()
        .map(|rel| dir.join(rel))
        .collect()
}

fn find_nearest_manifest(start: &Path) -> Option<(MinimalManifest, PathBuf)> {
    let mut dir = if start.is_file() {
        start.parent()?.to_path_buf()
    } else {
        start.to_path_buf()
    };
    loop {
        let path = dir.join("harn.toml");
        if path.is_file() {
            let source = std::fs::read_to_string(&path).ok()?;
            let manifest = toml::from_str::<MinimalManifest>(&source).ok()?;
            return Some((manifest, dir));
        }
        if !dir.pop() {
            return None;
        }
    }
}

fn load_rule_dir(dir: &Path, specs: &mut Vec<RuleSpec>, errors: &mut Vec<RuleLoadError>) {
    let entries = match std::fs::read_dir(dir) {
        Ok(entries) => entries,
        Err(error) => {
            errors.push(RuleLoadError {
                path: dir.to_path_buf(),
                message: format!("read `{}`: {error}", dir.display()),
            });
            return;
        }
    };
    let mut files: Vec<_> = entries
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("toml"))
        .collect();
    files.sort();
    for file in files {
        load_rule_file(&file, specs, errors);
    }
}

fn load_rule_file(path: &Path, specs: &mut Vec<RuleSpec>, errors: &mut Vec<RuleLoadError>) {
    let source = match std::fs::read_to_string(path) {
        Ok(source) => source,
        Err(error) => {
            errors.push(RuleLoadError {
                path: path.to_path_buf(),
                message: format!("read `{}`: {error}", path.display()),
            });
            return;
        }
    };
    let rule = match Rule::from_toml_str(&source) {
        Ok(rule) => rule,
        Err(error) => {
            errors.push(RuleLoadError {
                path: path.to_path_buf(),
                message: format!("parse `{}`: {error}", path.display()),
            });
            return;
        }
    };
    let Some(language) = Language::from_name(&rule.language) else {
        errors.push(RuleLoadError {
            path: path.to_path_buf(),
            message: format!(
                "rule `{}` uses unknown language `{}`",
                rule.id, rule.language
            ),
        });
        return;
    };
    specs.push(RuleSpec {
        path: path.to_path_buf(),
        language,
        safety: rule.safety,
        rule,
    });
}

fn resolve_rule_pack(root: &Path, pack: &str) -> Option<Vec<PathBuf>> {
    let local = resolve_path(root, pack);
    if local.is_dir() {
        return Some(pack_rule_dirs(&local));
    }

    let package_dir = root.join(".harn").join("packages").join(pack);
    if package_dir.is_dir() {
        return Some(pack_rule_dirs(&package_dir));
    }

    let locked = lockfile_package_dir(root, pack)?;
    locked.is_dir().then(|| pack_rule_dirs(&locked))
}

fn pack_rule_dirs(dir: &Path) -> Vec<PathBuf> {
    let manifest_path = dir.join("harn.toml");
    if manifest_path.is_file() {
        if let Ok(source) = std::fs::read_to_string(&manifest_path) {
            if let Ok(manifest) = toml::from_str::<MinimalManifest>(&source) {
                if !manifest.rules.rule_dirs.is_empty() {
                    return manifest
                        .rules
                        .rule_dirs
                        .iter()
                        .map(|rel| dir.join(rel))
                        .collect();
                }
            }
        }
    }
    vec![dir.to_path_buf()]
}

#[derive(Deserialize)]
struct MinimalLockFile {
    #[serde(default, rename = "package")]
    packages: Vec<MinimalLockEntry>,
}

#[derive(Deserialize)]
struct MinimalLockEntry {
    name: String,
    #[serde(default)]
    registry: Option<MinimalRegistry>,
}

#[derive(Deserialize)]
struct MinimalRegistry {
    name: String,
}

fn lockfile_package_dir(root: &Path, pack: &str) -> Option<PathBuf> {
    let lock_path = root.join("harn.lock");
    let source = std::fs::read_to_string(lock_path).ok()?;
    let lock = toml::from_str::<MinimalLockFile>(&source).ok()?;
    let entry = lock.packages.iter().find(|entry| {
        entry.name == pack
            || entry
                .registry
                .as_ref()
                .is_some_and(|registry| registry.name == pack)
    })?;
    Some(root.join(".harn").join("packages").join(&entry.name))
}

fn severity_to_lsp(severity: Severity) -> DiagnosticSeverity {
    match severity {
        Severity::Info => DiagnosticSeverity::INFORMATION,
        Severity::Warning => DiagnosticSeverity::WARNING,
        Severity::Error => DiagnosticSeverity::ERROR,
    }
}

fn rule_span_to_range(span: &harn_rules::Span, source: &str) -> Range {
    Range {
        start: offset_to_position(source, span.start_byte),
        end: offset_to_position(
            source,
            span.end_byte.max(span.start_byte + 1).min(source.len()),
        ),
    }
}

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

    fn write(path: &Path, content: &str) {
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(path, content).unwrap();
    }

    #[test]
    fn project_rule_dir_publishes_diagnostic_with_repair_data() {
        let temp = tempfile::tempdir().unwrap();
        write(
            &temp.path().join("harn.toml"),
            "[rules]\nruleDirs = [\"rules\"]\n",
        );
        write(
            &temp.path().join("rules/no-debugger.toml"),
            r#"
id = "no-debugger"
language = "typescript"
message = "remove debugger statements"
severity = "warning"
safety = "behavior-preserving"
fix = ""

[rule]
regex = "debugger;"
"#,
        );

        let workspace = RuleWorkspace::from_root(temp.path());
        let uri = Url::from_file_path(temp.path().join("src/main.ts")).unwrap();
        let diagnostics =
            workspace.diagnostics_for_document(&uri, "typescript", "function f() { debugger; }\n");

        assert_eq!(diagnostics.len(), 1);
        let diagnostic = &diagnostics[0].diagnostic;
        assert_eq!(diagnostic.source.as_deref(), Some(RULE_SOURCE));
        assert_eq!(
            diagnostic.message,
            "[no-debugger] remove debugger statements"
        );
        let data = diagnostic.data.as_ref().expect("repair data");
        assert_eq!(
            data.get("repair_id").and_then(Value::as_str),
            Some("rules/no-debugger/15-24")
        );
        assert_eq!(
            data.get("safety").and_then(Value::as_str),
            Some("behavior-preserving")
        );
        assert_eq!(diagnostics[0].edit.as_ref().unwrap().new_text, "");
    }

    #[test]
    fn document_language_falls_back_to_file_extension() {
        let temp = tempfile::tempdir().unwrap();
        write(
            &temp.path().join("harn.toml"),
            "[rules]\nruleDirs = [\"rules\"]\n",
        );
        write(
            &temp.path().join("rules/no-debugger.toml"),
            r#"
id = "no-debugger"
language = "typescript"
message = "remove debugger statements"
severity = "warning"
safety = "behavior-preserving"
fix = ""

[rule]
regex = "debugger;"
"#,
        );

        let workspace = RuleWorkspace::from_root(temp.path());
        let uri = Url::from_file_path(temp.path().join("src/main.ts")).unwrap();
        let diagnostics = workspace.diagnostics_for_document(
            &uri,
            "harn-rule-engine",
            "function f() { debugger; }\n",
        );

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(
            diagnostics[0].diagnostic.message,
            "[no-debugger] remove debugger statements"
        );
    }
}
