//! `arai canonicalize` — extract rules from existing instruction files
//! (`CLAUDE.md`, `.cursorrules`, `AGENTS.md`, ...) and write them as a
//! canonical `arai.toml` matching `docs/rules-file-spec.md`.
//!
//! Lossy-but-honest: prose the parser doesn't recognise as a rule is
//! skipped silently (the parser already discards it); a final summary
//! reports how many rules were extracted from how many files so the
//! user can sanity-check against their expectation.
//!
//! The matcher is unchanged.  `arai.toml` is an output artefact for
//! adoption planning — `arai sync` (#77) and a canonical loader land
//! separately.

use crate::config::Config;
use crate::discovery;
use crate::intent::Severity;
use crate::parser::{self, Triple, KNOWN_TOOLS};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};

#[derive(Debug, Serialize, Deserialize)]
pub struct CanonicalFile {
    pub meta: Meta,
    #[serde(rename = "rule", default, skip_serializing_if = "Vec::is_empty")]
    pub rules: Vec<CanonicalRule>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Meta {
    pub schema_version: u32,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub project: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub extends: Vec<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct CanonicalRule {
    pub id: String,
    pub description: String,
    pub when: When,
    pub then: Then,
    pub severity: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub expires: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct When {
    pub tool: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub path: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub command_pattern: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub content_pattern: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub session_lacks: Vec<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Then {
    pub action: String,
    pub message: String,
}

pub struct Summary {
    pub rules_written: usize,
    pub files_read: usize,
    pub output_path: PathBuf,
}

/// Run the canonicalize flow end-to-end: discover instruction files,
/// extract rules, map each Triple to the canonical schema, write the
/// TOML file.
pub fn run(cfg: &Config, output: &Path, force: bool) -> Result<Summary, String> {
    if output.exists() && !force {
        return Err(format!(
            "{} already exists.  Pass --force to overwrite.",
            output.display()
        ));
    }

    let files = discovery::discover(cfg).map_err(|e| format!("discovery failed: {e}"))?;
    let files_read = files.len();

    let mut triples: Vec<Triple> = Vec::new();
    for f in &files {
        let mut extracted = parser::extract_rules(&f.content, &f.source_type, f.confidence);
        for t in &mut extracted {
            // discovery doesn't always populate source_file on the triple
            // when extract_rules is called by other paths; make sure we
            // record where each came from so the generated description
            // can point back to it.
            if t.source_file.is_empty() {
                t.source_file = f.path.clone();
            }
        }
        triples.extend(extracted);
    }

    let mut canonical = CanonicalFile {
        meta: Meta {
            schema_version: 1,
            project: Some(project_name(cfg)),
            extends: Vec::new(),
        },
        rules: triples.iter().map(triple_to_rule).collect(),
    };
    dedupe_ids(&mut canonical.rules);

    let body =
        toml::to_string_pretty(&canonical).map_err(|e| format!("failed to serialize TOML: {e}"))?;
    let header = output_header(&files);
    let full = format!("{header}{body}");

    std::fs::write(output, full).map_err(|e| format!("write {}: {e}", output.display()))?;

    Ok(Summary {
        rules_written: canonical.rules.len(),
        files_read,
        output_path: output.to_path_buf(),
    })
}

fn project_name(cfg: &Config) -> String {
    cfg.project_root
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or("project")
        .to_string()
}

fn output_header(files: &[discovery::DiscoveredFile]) -> String {
    let mut h = String::new();
    h.push_str("# Generated by `arai canonicalize` from existing instruction files.\n");
    h.push_str("# Hand-editing is expected — review the rules below, refine `when.tool`\n");
    h.push_str("# / `when.path` / `when.command_pattern` to narrow over-broad matches,\n");
    h.push_str("# and remove anything that doesn't reflect a real rule.\n");
    h.push_str("#\n");
    h.push_str("# Schema: docs/rules-file-spec.md\n");
    if !files.is_empty() {
        h.push_str("#\n");
        h.push_str("# Sources:\n");
        for f in files {
            h.push_str(&format!("#   - {} ({})\n", f.path, f.source_type));
        }
    }
    h.push('\n');
    h
}

/// Map a parser `Triple` to a `CanonicalRule`.
///
/// Field inference is best-effort and deliberately conservative:
/// the output is meant to be reviewed and refined by the user, not
/// treated as authoritative.
pub fn triple_to_rule(triple: &Triple) -> CanonicalRule {
    let severity = Severity::from_predicate(&triple.predicate);
    let action = action_from_severity(severity);

    let id = generate_id(&triple.subject, &triple.object);
    let description = description_from(triple);
    let tool = infer_tool(&triple.subject, &triple.object);
    let message = triple.object.clone();

    CanonicalRule {
        id,
        description,
        when: When {
            tool,
            path: Vec::new(),
            command_pattern: None,
            content_pattern: None,
            session_lacks: Vec::new(),
        },
        then: Then {
            action: action.to_string(),
            message,
        },
        severity: severity.as_str().to_string(),
        expires: triple.expires_at.clone(),
    }
}

/// Severity → spec's `then.action`.  `Block` denies, the others advise.
/// `severity = "inform"` paired with `action = "warn"` is the documented
/// soft-nudge pattern from the spec.
fn action_from_severity(s: Severity) -> &'static str {
    match s {
        Severity::Block => "block",
        Severity::Warn | Severity::Inform => "warn",
    }
}

/// Build a stable kebab-case id from the subject and the first
/// distinctive word of the object.  Distinctive = not a stopword,
/// length ≥ 3.  Falls back to `<subject>-rule` if no distinctive
/// object word is available.
fn generate_id(subject: &str, object: &str) -> String {
    let subj_slug = slugify(subject);
    let obj_slug = first_distinctive_word(object)
        .map(|w| slugify(&w))
        .unwrap_or_else(|| "rule".to_string());

    if obj_slug.is_empty() {
        format!("{subj_slug}-rule")
    } else if subj_slug.is_empty() {
        obj_slug
    } else {
        format!("{subj_slug}-{obj_slug}")
    }
}

const STOPWORDS: &[&str] = &[
    "the", "a", "an", "to", "of", "in", "on", "at", "is", "are", "and", "or", "but", "for", "with",
    "without", "from", "into", "as", "by", "that", "this", "these", "those", "be", "do", "not",
    "no", "any", "all", "every", "some", "if", "when", "before", "after", "while", "during", "via",
];

fn first_distinctive_word(s: &str) -> Option<String> {
    s.split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
        .filter(|w| w.len() >= 3)
        .map(|w| w.to_lowercase())
        .find(|w| !STOPWORDS.contains(&w.as_str()))
}

fn slugify(s: &str) -> String {
    let mut out = String::new();
    let mut prev_dash = true;
    for ch in s.chars() {
        if ch.is_ascii_alphanumeric() {
            out.push(ch.to_ascii_lowercase());
            prev_dash = false;
        } else if !prev_dash {
            out.push('-');
            prev_dash = true;
        }
    }
    while out.ends_with('-') {
        out.pop();
    }
    out
}

/// Ensure no two rules share an id.  On collision, append `-2`, `-3`, ...
/// to the second and later occurrences, preserving the first's id.
fn dedupe_ids(rules: &mut [CanonicalRule]) {
    let mut seen: HashSet<String> = HashSet::new();
    for r in rules.iter_mut() {
        if seen.insert(r.id.clone()) {
            continue;
        }
        let base = r.id.clone();
        let mut n = 2usize;
        loop {
            let candidate = format!("{base}-{n}");
            if seen.insert(candidate.clone()) {
                r.id = candidate;
                break;
            }
            n += 1;
        }
    }
}

fn description_from(triple: &Triple) -> String {
    let src = if let Some(line) = triple.line_start {
        format!("{}:{}", short_source(&triple.source_file), line)
    } else {
        short_source(&triple.source_file)
    };
    let predicate = &triple.predicate;
    let subject = &triple.subject;
    // Keep description concise — full prose lives in `then.message`.
    format!("Migrated from {src} — {subject} {predicate}")
}

fn short_source(path: &str) -> String {
    Path::new(path)
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or(path)
        .to_string()
}

/// Infer `when.tool` from the rule's subject (and object as a fallback).
///
/// The mapping is intentionally narrow: only well-known subjects map to
/// specific tools.  Everything else gets `["*"]`, which the spec defines
/// as "all tools" — readable as "I wasn't sure, narrow this yourself".
fn infer_tool(subject: &str, object: &str) -> Vec<String> {
    let subj_lower = subject.to_lowercase();

    // Bash-tool subjects: git, gh, and any KNOWN_TOOLS that is a CLI
    // (cargo, npm, pytest, docker, kubectl, ...).
    if subj_lower.contains("git") || subj_lower.contains("github") {
        return vec!["Bash".to_string()];
    }
    if KNOWN_TOOLS
        .iter()
        .any(|t| subj_lower == *t || subj_lower.contains(t))
    {
        // alembic / migration tooling: writing or editing migration files
        // is the operative shape, not bash invocations.
        if subj_lower.contains("alembic") || subj_lower.contains("migration") {
            return vec!["Write".to_string(), "Edit".to_string()];
        }
        return vec!["Bash".to_string()];
    }

    // Object-based hints — "migration files" / "migrations/" often
    // appears in the object rather than the subject for rules written
    // as "Never hand-write migration files".
    let obj_lower = object.to_lowercase();
    if obj_lower.contains("migration") && obj_lower.contains("file") {
        return vec!["Write".to_string(), "Edit".to_string()];
    }

    vec!["*".to_string()]
}

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

    fn t(subject: &str, predicate: &str, object: &str) -> Triple {
        Triple {
            subject: subject.to_string(),
            predicate: predicate.to_string(),
            object: object.to_string(),
            confidence: 0.9,
            domain: "test".to_string(),
            source_file: "CLAUDE.md".to_string(),
            line_start: Some(42),
            line_end: None,
            layer: Some(1),
            expires_at: None,
            noenrich: false,

            tier: None,

            source_label: None,
        }
    }

    #[test]
    fn git_rule_maps_to_bash_block() {
        let rule = triple_to_rule(&t("git", "never", "force-push to main"));
        assert_eq!(rule.when.tool, vec!["Bash"]);
        assert_eq!(rule.then.action, "block");
        assert_eq!(rule.severity, "block");
        assert_eq!(rule.then.message, "force-push to main");
        assert!(
            rule.id.starts_with("git-"),
            "expected subject-prefixed id, got {}",
            rule.id
        );
    }

    #[test]
    fn alembic_rule_maps_to_write_edit() {
        let rule = triple_to_rule(&t("alembic", "never", "hand-write migration files"));
        assert!(rule.when.tool.contains(&"Write".to_string()));
        assert!(rule.when.tool.contains(&"Edit".to_string()));
        assert_eq!(rule.then.action, "block");
    }

    #[test]
    fn migration_object_without_alembic_subject_still_maps_to_write_edit() {
        // "Never hand-write migration files" — subject extracted by parser
        // is likely the generic verb, not "alembic". Object-based fallback
        // should still produce Write/Edit so the alembic case survives.
        let rule = triple_to_rule(&t("migration", "never", "hand-write migration files"));
        assert!(rule.when.tool.contains(&"Write".to_string()));
    }

    #[test]
    fn soft_predicate_warns_not_blocks() {
        let rule = triple_to_rule(&t("test", "prefers", "running tests before pushing"));
        assert_eq!(rule.then.action, "warn");
        assert_eq!(rule.severity, "inform");
    }

    #[test]
    fn unknown_subject_falls_back_to_wildcard_tool() {
        let rule = triple_to_rule(&t("documentation", "always", "include a why section"));
        assert_eq!(rule.when.tool, vec!["*"]);
    }

    #[test]
    fn ids_are_deduplicated() {
        let mut rules = vec![
            triple_to_rule(&t("git", "never", "push to main")),
            triple_to_rule(&t("git", "never", "push without review")),
            triple_to_rule(&t("git", "never", "push force")),
        ];
        // Force a collision: same subject + same first-distinctive-word
        rules[1].id = rules[0].id.clone();
        rules[2].id = rules[0].id.clone();
        dedupe_ids(&mut rules);
        assert_ne!(rules[0].id, rules[1].id);
        assert_ne!(rules[1].id, rules[2].id);
        assert_ne!(rules[0].id, rules[2].id);
    }

    #[test]
    fn expires_passes_through() {
        let mut tr = t("test", "never", "skip CI");
        tr.expires_at = Some("2026-12-31".to_string());
        let rule = triple_to_rule(&tr);
        assert_eq!(rule.expires.as_deref(), Some("2026-12-31"));
    }

    #[test]
    fn roundtrip_to_toml_parses() {
        let rule = triple_to_rule(&t("git", "never", "force-push to main"));
        let file = CanonicalFile {
            meta: Meta {
                schema_version: 1,
                project: Some("test".to_string()),
                extends: Vec::new(),
            },
            rules: vec![rule],
        };
        let s = toml::to_string_pretty(&file).expect("serialize");
        // Re-parse as untyped TOML to confirm structural validity.
        let v: toml::Value = toml::from_str(&s).expect("parse back");
        let meta = v.get("meta").expect("meta table");
        assert_eq!(
            meta.get("schema_version").and_then(|x| x.as_integer()),
            Some(1)
        );
        let rules = v
            .get("rule")
            .and_then(|r| r.as_array())
            .expect("rule array");
        assert_eq!(rules.len(), 1);
        let r0 = &rules[0];
        assert_eq!(r0.get("severity").and_then(|x| x.as_str()), Some("block"));
    }

    #[test]
    fn id_generation_handles_punctuation() {
        let id = generate_id("Git ops", "force-push to main");
        // expect: "git-ops-force" or similar — kebab, no punctuation
        assert!(id.contains("git"));
        assert!(!id.contains(' '));
        assert!(!id.starts_with('-') && !id.ends_with('-'));
    }
}
