//! WORLD-6 (W-P3) — Stage 2: pairing. Checks directed, semantically-motivated
//! claim pairs for logical compatibility: premise→mechanism (does the mechanism
//! enforce the premise?), mechanism→consequence (does it follow?), and
//! consequence↔consequence (mutually compatible?). Eliminations are not paired
//! here — they drive the Stage 3 entailment scan.
//!
//! Expensive (O(pairs) LLM calls), so it is **explicit-only** — never run
//! silently on a background refresh. The cost cap informs, never blocks.

use anyhow::Result;

use crate::config::Config;

use super::llm::{extract_json_array, utopia_llm_call};
use super::store::UtopiaStore;
use super::{ClaimType, FindingDomain, FindingType, UtopiaClaim, UtopiaFinding};

const PAIR_SYSTEM: &str = "You are evaluating logical consistency in a fictional world design. \
Two claims have been made about the same fictional society. Evaluate whether they are logically \
compatible.\n\
IMPORTANT CONSTRAINTS:\n\
- Evaluate LOGICAL AND SYSTEMIC compatibility only.\n\
- Do NOT evaluate moral, spiritual, theological, or psychological compatibility.\n\
- Do NOT ask whether the society is just, humane, or sustainable.\n\
- Do NOT use your knowledge of real historical societies to judge plausibility.\n\
- Evaluate only: can both claims be true in the same fictional world without logical \
contradiction?\n\
Respond ONLY with JSON: {\"compatible\":true|false,\"reasoning\":\"one or two sentences\"}";

/// A directed pair to check, and the finding type an incompatibility produces.
pub(super) struct Pair<'a> {
    pub a: &'a UtopiaClaim,
    pub b: &'a UtopiaClaim,
    pub on_incompatible: FindingType,
}

/// Select the semantically-motivated pairs (RFC §7.3): premise×mechanism,
/// mechanism×consequence, and each unordered consequence×consequence. Pure.
pub(super) fn select_pairs(claims: &[UtopiaClaim]) -> Vec<Pair<'_>> {
    let by = |t: ClaimType| claims.iter().filter(move |c| c.claim_type == t).collect::<Vec<_>>();
    let premises = by(ClaimType::Premise);
    let mechanisms = by(ClaimType::Mechanism);
    let consequences = by(ClaimType::Consequence);

    let mut pairs = Vec::new();
    for p in &premises {
        for m in &mechanisms {
            pairs.push(Pair { a: p, b: m, on_incompatible: FindingType::ChainBreak });
        }
    }
    for m in &mechanisms {
        for c in &consequences {
            pairs.push(Pair { a: m, b: c, on_incompatible: FindingType::ConsequenceGap });
        }
    }
    for i in 0..consequences.len() {
        for j in (i + 1)..consequences.len() {
            pairs.push(Pair {
                a: consequences[i],
                b: consequences[j],
                on_incompatible: FindingType::InternalConflict,
            });
        }
    }
    pairs
}

/// Build the compatibility user prompt for one pair.
pub(super) fn build_pair_prompt(a: &UtopiaClaim, b: &UtopiaClaim) -> String {
    format!(
        "Claim A (type: {}):\n{}\n\nClaim B (type: {}):\n{}",
        a.claim_type.as_code(),
        a.claim_text,
        b.claim_type.as_code(),
        b.claim_text
    )
}

/// Parse the compatibility verdict. Tolerant; defaults to compatible (no
/// finding) when the response is unparseable, so a flaky model never invents a
/// contradiction.
pub(super) fn parse_compatibility(raw: &str) -> (bool, String) {
    let json = {
        match (raw.find('{'), raw.rfind('}')) {
            (Some(a), Some(b)) if b > a => &raw[a..=b],
            _ => raw.trim(),
        }
    };
    let v: serde_json::Value = serde_json::from_str(json).unwrap_or(serde_json::Value::Null);
    let compatible = v.get("compatible").and_then(|x| x.as_bool()).unwrap_or(true);
    let reasoning = v
        .get("reasoning")
        .and_then(|x| x.as_str())
        .unwrap_or("")
        .to_string();
    (compatible, reasoning)
}

/// Human description for an incompatible pair.
pub(super) fn finding_description(
    ft: FindingType,
    a: &UtopiaClaim,
    b: &UtopiaClaim,
    reasoning: &str,
) -> String {
    let head = match ft {
        FindingType::ChainBreak => format!(
            "CHAIN BREAK — mechanism \"{}\" may not enforce premise \"{}\"",
            b.claim_text, a.claim_text
        ),
        FindingType::ConsequenceGap => format!(
            "CONSEQUENCE GAP — consequence \"{}\" may not follow from mechanism \"{}\"",
            b.claim_text, a.claim_text
        ),
        FindingType::InternalConflict => format!(
            "INTERNAL CONFLICT — \"{}\" conflicts with \"{}\"",
            a.claim_text, b.claim_text
        ),
        FindingType::EntailmentViolation => "ENTAILMENT".to_string(),
    };
    if reasoning.is_empty() {
        head
    } else {
        format!("{head} — {reasoning}")
    }
}

fn deterministic_id(group: &str, a: &str, b: &str) -> String {
    use std::hash::{Hash, Hasher};
    let mut h = std::collections::hash_map::DefaultHasher::new();
    group.hash(&mut h);
    a.hash(&mut h);
    b.hash(&mut h);
    format!("chain-{:016x}", h.finish())
}

/// Number of pairs Stage 2 would check for a group (for the cost-warning note).
pub(crate) fn pair_count(claims: &[UtopiaClaim]) -> usize {
    select_pairs(claims).len()
}

/// Run Stage 2 for one group: check every selected pair, persist the pair
/// verdict, and emit a finding per incompatibility. Clears the group's prior
/// systemic chain findings first (domain-scoped, so the Factual cross-reference
/// from Stage 1 survives). Returns the number of findings raised.
pub(crate) fn run_stage2_group(
    store: &UtopiaStore,
    cfg: &Config,
    book_slug: &str,
    group: &str,
) -> Result<usize> {
    let claims = store.claims_for_group(book_slug, group)?;
    store.clear_group_findings_by_domain(book_slug, group, FindingDomain::Systemic)?;
    let now = chrono::Utc::now().to_rfc3339();
    let mut findings = 0;
    for pair in select_pairs(&claims) {
        let user = build_pair_prompt(pair.a, pair.b);
        let raw = utopia_llm_call(cfg, PAIR_SYSTEM, &user)?;
        let (compatible, reasoning) = parse_compatibility(&raw);
        store.upsert_pair(
            book_slug,
            group,
            &pair.a.claim_id,
            &pair.b.claim_id,
            compatible,
            &reasoning,
            &now,
        )?;
        if !compatible {
            let f = UtopiaFinding {
                finding_id: deterministic_id(group, &pair.a.claim_id, &pair.b.claim_id),
                premise_group: group.to_string(),
                finding_type: pair.on_incompatible,
                finding_domain: FindingDomain::Systemic,
                description: finding_description(pair.on_incompatible, pair.a, pair.b, &reasoning),
                evidence: Some(format!("{} ↔ {}", pair.a.claim_id, pair.b.claim_id)),
                chapter_ord: None,
                para_id: Some(pair.a.source_para_id.clone()),
                suppressed: false,
                grounded_by_research: false,
            };
            store.upsert_finding(book_slug, &f, &now, None)?;
            findings += 1;
        }
    }
    store.set_stage2_complete(book_slug, group, &now)?;
    Ok(findings)
}

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

    fn claim(id: &str, ct: ClaimType, text: &str) -> UtopiaClaim {
        UtopiaClaim {
            claim_id: id.into(),
            premise_group: "g".into(),
            claim_type: ct,
            claim_text: text.into(),
            source_para_id: format!("para-{id}"),
        }
    }

    #[test]
    fn pair_selection_types_and_counts() {
        let claims = vec![
            claim("p1", ClaimType::Premise, "P"),
            claim("m1", ClaimType::Mechanism, "M1"),
            claim("m2", ClaimType::Mechanism, "M2"),
            claim("c1", ClaimType::Consequence, "C1"),
            claim("c2", ClaimType::Consequence, "C2"),
            claim("e1", ClaimType::Elimination, "E"), // not paired
        ];
        let pairs = select_pairs(&claims);
        // premise×mechanism = 1×2 = 2; mechanism×consequence = 2×2 = 4;
        // consequence×consequence = C(2,2) = 1. Total 7. Eliminations excluded.
        assert_eq!(pairs.len(), 7);
        let chain = pairs.iter().filter(|p| p.on_incompatible == FindingType::ChainBreak).count();
        let gap = pairs.iter().filter(|p| p.on_incompatible == FindingType::ConsequenceGap).count();
        let conflict =
            pairs.iter().filter(|p| p.on_incompatible == FindingType::InternalConflict).count();
        assert_eq!((chain, gap, conflict), (2, 4, 1));
        assert_eq!(pair_count(&claims), 7);
    }

    #[test]
    fn no_pairs_touch_eliminations() {
        let claims = vec![
            claim("p1", ClaimType::Premise, "P"),
            claim("e1", ClaimType::Elimination, "E"),
        ];
        assert!(select_pairs(&claims).is_empty());
    }

    #[test]
    fn parse_compatibility_forms() {
        assert_eq!(parse_compatibility("{\"compatible\":false,\"reasoning\":\"clash\"}"),
                   (false, "clash".to_string()));
        assert_eq!(parse_compatibility("yes: {\"compatible\": true, \"reasoning\":\"ok\"}").0, true);
        // Unparseable → compatible (no false-positive finding).
        assert_eq!(parse_compatibility("garbage").0, true);
    }

    #[test]
    fn finding_description_per_type() {
        let a = claim("a", ClaimType::Premise, "truth is decreed");
        let b = claim("b", ClaimType::Mechanism, "rewrite records");
        let d = finding_description(FindingType::ChainBreak, &a, &b, "doc revision != belief");
        assert!(d.contains("CHAIN BREAK"));
        assert!(d.contains("rewrite records"));
        assert!(d.contains("doc revision"));
    }

    #[test]
    fn run_stage2_persists_findings_and_completes() {
        // Drive run_stage2_group with no LLM by pre-seeding... not possible
        // (it calls the LLM). Instead verify the deterministic id is stable.
        let id1 = deterministic_id("g", "a", "b");
        let id2 = deterministic_id("g", "a", "b");
        assert_eq!(id1, id2);
        assert_ne!(id1, deterministic_id("g", "a", "c"));
    }
}
