use hmd_core::{HmdBlock, HmdDocument, HmdNode};
use hmd_patch::{AppendBlock, HmdPatch, PatchOperation};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};

pub const INTENT_VERSION: &str = "0.1";
pub const PATCH_VERSION: &str = "0.1";

pub type PatchIntentParams = Map<String, Value>;

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PatchIntent {
    pub intent_version: String,
    pub action: String,
    pub target: String,
    #[serde(default)]
    pub context: PatchIntentContext,
    #[serde(default)]
    pub params: PatchIntentParams,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PatchIntentContext {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub document_profile: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source_hash: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub decision_target: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub created_by: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InteractionError {
    pub kind: InteractionErrorKind,
    pub action: Option<String>,
    pub target: Option<String>,
    pub message: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InteractionErrorKind {
    InvalidIntent,
    UnsupportedAction,
    MissingTarget,
    AmbiguousTarget,
    StaleSource,
    ProfileMismatch,
    Conflict,
    AlreadyApplied,
}

impl std::fmt::Display for InteractionError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.write_str(&self.message)?;
        match (&self.action, &self.target) {
            (Some(action), Some(target)) => {
                write!(formatter, " (action: {action}, target: {target})")
            }
            (Some(action), None) => write!(formatter, " (action: {action})"),
            (None, Some(target)) => write!(formatter, " (target: {target})"),
            (None, None) => Ok(()),
        }
    }
}

impl std::error::Error for InteractionError {}

pub fn create_patch(source: &str, intent: &PatchIntent) -> Result<HmdPatch, InteractionError> {
    if intent.intent_version != INTENT_VERSION {
        return Err(intent_error(
            InteractionErrorKind::InvalidIntent,
            intent,
            format!("unsupported intent version '{}'", intent.intent_version),
        ));
    }

    let actual_hash = source_hash(source);
    if let Some(expected_hash) = &intent.context.source_hash {
        if expected_hash != &actual_hash {
            return Err(intent_error(
                InteractionErrorKind::StaleSource,
                intent,
                format!("stale source for target '{}'", intent.target),
            ));
        }
    }

    let document = hmd_parse::parse_document(source);
    if let Some(expected_profile) = &intent.context.document_profile {
        if expected_profile != &document.profile {
            return Err(intent_error(
                InteractionErrorKind::ProfileMismatch,
                intent,
                format!(
                    "profile mismatch: expected '{}', got '{}'",
                    expected_profile, document.profile
                ),
            ));
        }
    }

    let operations = match intent.action.as_str() {
        "decision.select_option" => decision_select_option(&document, intent)?,
        "meta.set" => meta_set(&document, intent)?,
        "body.replace" => body_replace(&document, intent)?,
        _ => {
            return Err(intent_error(
                InteractionErrorKind::UnsupportedAction,
                intent,
                format!("unsupported action '{}'", intent.action),
            ));
        }
    };

    Ok(HmdPatch {
        patch_version: PATCH_VERSION.to_string(),
        target_hash: Some(actual_hash),
        operations,
        author: intent.context.created_by.clone(),
        created: None,
    })
}

pub fn create_patch_from_json(
    source: &str,
    intent_json: &str,
) -> Result<HmdPatch, InteractionError> {
    let intent =
        serde_json::from_str::<PatchIntent>(intent_json).map_err(|error| InteractionError {
            kind: InteractionErrorKind::InvalidIntent,
            action: None,
            target: None,
            message: format!("invalid intent JSON: {error}"),
        })?;
    create_patch(source, &intent)
}

pub fn apply_intent(source: &str, intent: &PatchIntent) -> Result<String, InteractionError> {
    let patch = create_patch(source, intent)?;
    hmd_patch::apply_patch(source, &patch).map_err(|error| InteractionError {
        kind: InteractionErrorKind::Conflict,
        action: Some(intent.action.clone()),
        target: Some(intent.target.clone()),
        message: format!(
            "failed to apply action '{}' to target '{}': {}",
            intent.action, intent.target, error
        ),
    })
}

pub fn apply_intent_json(source: &str, intent_json: &str) -> Result<String, InteractionError> {
    let intent =
        serde_json::from_str::<PatchIntent>(intent_json).map_err(|error| InteractionError {
            kind: InteractionErrorKind::InvalidIntent,
            action: None,
            target: None,
            message: format!("invalid intent JSON: {error}"),
        })?;
    apply_intent(source, &intent)
}

pub fn source_hash(source: &str) -> String {
    hmd_patch::source_hash(source)
}

fn decision_select_option(
    document: &HmdDocument,
    intent: &PatchIntent,
) -> Result<Vec<PatchOperation>, InteractionError> {
    let option_id = target_id(&intent.target).ok_or_else(|| {
        intent_error(
            InteractionErrorKind::InvalidIntent,
            intent,
            "decision.select_option target must use /blocks/<option-id>",
        )
    })?;
    let option = unique_block(document, &intent.target, intent)?;
    if option.block_type != "option" {
        return Err(intent_error(
            InteractionErrorKind::InvalidIntent,
            intent,
            format!(
                "decision.select_option target '{}' is a '{}' block",
                intent.target, option.block_type
            ),
        ));
    }

    let decision_target = intent.context.decision_target.clone().ok_or_else(|| {
        intent_error(
            InteractionErrorKind::MissingTarget,
            intent,
            "decision.select_option requires context.decisionTarget",
        )
    })?;
    let decision = unique_block(document, &decision_target, intent)?;
    if decision.block_type != "decision" {
        return Err(intent_error(
            InteractionErrorKind::InvalidIntent,
            intent,
            format!(
                "context.decisionTarget '{}' is a '{}' block",
                decision_target, decision.block_type
            ),
        ));
    }
    if !block_contains_target(decision, &intent.target) {
        return Err(intent_error(
            InteractionErrorKind::MissingTarget,
            intent,
            format!(
                "option target '{}' is not inside decision '{}'",
                intent.target, decision_target
            ),
        ));
    }

    let choice_id = string_param(intent, "choiceId").unwrap_or_else(|| {
        format!(
            "CH-{}-{}",
            target_id(&decision_target).unwrap_or("decision"),
            option_id
        )
    });
    let choice_status =
        string_param(intent, "choiceStatus").unwrap_or_else(|| "selected".to_string());
    let markdown = string_param(intent, "markdown").unwrap_or_else(|| {
        let label = option
            .meta
            .get("label")
            .and_then(Value::as_str)
            .unwrap_or(option_id);
        format!("Selected {label}.")
    });

    if let Some(existing) = block_by_id(document, &choice_id) {
        if existing.block_type != "choice" {
            return Err(intent_error(
                InteractionErrorKind::Conflict,
                intent,
                format!("choice id '{choice_id}' is already used by a non-choice block"),
            ));
        }
        if choice_matches(existing, option_id, &choice_status, &markdown) {
            return Ok(Vec::new());
        }
        return Err(intent_error(
            InteractionErrorKind::Conflict,
            intent,
            format!("choice id '{choice_id}' already exists with different content"),
        ));
    }

    let mut meta = Map::new();
    meta.insert("id".to_string(), Value::String(choice_id));
    meta.insert("option".to_string(), Value::String(option_id.to_string()));
    meta.insert("status".to_string(), Value::String(choice_status));

    Ok(vec![PatchOperation::AppendBlock {
        target: decision_target,
        block: AppendBlock {
            block_type: "choice".to_string(),
            meta,
            markdown,
        },
        reason: Some("decision.select_option".to_string()),
        label: Some(format!("Select option {option_id}")),
        created_by: intent.context.created_by.clone(),
    }])
}

fn meta_set(
    document: &HmdDocument,
    intent: &PatchIntent,
) -> Result<Vec<PatchOperation>, InteractionError> {
    unique_block(document, &intent.target, intent)?;
    let field = string_param(intent, "field").ok_or_else(|| {
        intent_error(
            InteractionErrorKind::InvalidIntent,
            intent,
            "meta.set requires params.field",
        )
    })?;
    let value = intent.params.get("value").cloned().ok_or_else(|| {
        intent_error(
            InteractionErrorKind::InvalidIntent,
            intent,
            "meta.set requires params.value",
        )
    })?;

    Ok(vec![PatchOperation::SetMeta {
        target: intent.target.clone(),
        field,
        value,
        reason: Some("meta.set".to_string()),
        label: None,
        created_by: intent.context.created_by.clone(),
    }])
}

fn body_replace(
    document: &HmdDocument,
    intent: &PatchIntent,
) -> Result<Vec<PatchOperation>, InteractionError> {
    unique_block(document, &intent.target, intent)?;
    let markdown = string_param(intent, "markdown").ok_or_else(|| {
        intent_error(
            InteractionErrorKind::InvalidIntent,
            intent,
            "body.replace requires params.markdown",
        )
    })?;

    Ok(vec![PatchOperation::ReplaceBody {
        target: intent.target.clone(),
        markdown,
        reason: Some("body.replace".to_string()),
        label: None,
        created_by: intent.context.created_by.clone(),
    }])
}

fn unique_block<'a>(
    document: &'a HmdDocument,
    target: &str,
    intent: &PatchIntent,
) -> Result<&'a HmdBlock, InteractionError> {
    let Some(id) = target_id(target) else {
        return Err(intent_error(
            InteractionErrorKind::InvalidIntent,
            intent,
            format!("target '{target}' must use /blocks/<id>"),
        ));
    };

    if document
        .references
        .duplicates
        .iter()
        .any(|record| record.id == id)
    {
        return Err(intent_error(
            InteractionErrorKind::AmbiguousTarget,
            intent,
            format!("target '{target}' is ambiguous"),
        ));
    }

    block_by_id(document, id).ok_or_else(|| {
        intent_error(
            InteractionErrorKind::MissingTarget,
            intent,
            format!("target '{target}' was not found"),
        )
    })
}

fn block_by_id<'a>(document: &'a HmdDocument, id: &str) -> Option<&'a HmdBlock> {
    find_block_by_id(&document.children, id)
}

fn find_block_by_id<'a>(nodes: &'a [HmdNode], id: &str) -> Option<&'a HmdBlock> {
    for node in nodes {
        if let HmdNode::HmdBlock(block) = node {
            if block.id.as_deref() == Some(id) {
                return Some(block);
            }
            if let Some(found) = find_block_by_id(&block.children, id) {
                return Some(found);
            }
        }
    }
    None
}

fn block_contains_target(block: &HmdBlock, target: &str) -> bool {
    let Some(id) = target_id(target) else {
        return false;
    };
    find_block_by_id(&block.children, id).is_some()
}

fn choice_matches(block: &HmdBlock, option: &str, status: &str, markdown: &str) -> bool {
    block.meta.get("option").and_then(Value::as_str) == Some(option)
        && block.meta.get("status").and_then(Value::as_str) == Some(status)
        && block_markdown(block).trim() == markdown.trim()
}

fn block_markdown(block: &HmdBlock) -> String {
    block
        .children
        .iter()
        .filter_map(|node| match node {
            HmdNode::Markdown(markdown) => Some(markdown.text.as_str()),
            _ => None,
        })
        .collect::<Vec<_>>()
        .join("")
}

fn target_id(target: &str) -> Option<&str> {
    let id = target.strip_prefix("/blocks/")?;
    (!id.is_empty() && !id.contains('/')).then_some(id)
}

fn string_param(intent: &PatchIntent, key: &str) -> Option<String> {
    intent
        .params
        .get(key)
        .and_then(Value::as_str)
        .map(ToString::to_string)
}

fn intent_error(
    kind: InteractionErrorKind,
    intent: &PatchIntent,
    message: impl Into<String>,
) -> InteractionError {
    InteractionError {
        kind,
        action: Some(intent.action.clone()),
        target: Some(intent.target.clone()),
        message: message.into(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::{Path, PathBuf};

    #[test]
    fn decision_select_option_generates_append_choice_patch() {
        let source = fixture_source();
        let intent = select_rust_intent(&source);

        let patch = create_patch(&source, &intent).unwrap();

        assert_eq!(patch.patch_version, "0.1");
        assert_eq!(patch.operations.len(), 1);
        match &patch.operations[0] {
            PatchOperation::AppendBlock { target, block, .. } => {
                assert_eq!(target, "/blocks/D-runtime");
                assert_eq!(block.block_type, "choice");
                assert_eq!(
                    block.meta.get("id").and_then(Value::as_str),
                    Some("CH-D-runtime-rust")
                );
                assert_eq!(
                    block.meta.get("option").and_then(Value::as_str),
                    Some("rust")
                );
                assert_eq!(
                    block.meta.get("status").and_then(Value::as_str),
                    Some("selected")
                );
                assert_eq!(block.markdown, "Selected Rust.");
            }
            operation => panic!("unexpected operation: {operation:?}"),
        }
    }

    #[test]
    fn decision_select_option_includes_target_hash() {
        let source = fixture_source();
        let intent = select_rust_intent(&source);

        let patch = create_patch(&source, &intent).unwrap();

        assert_eq!(patch.target_hash, Some(source_hash(&source)));
    }

    #[test]
    fn decision_select_option_is_idempotent_or_reports_already_applied() {
        let source = fixture_source();
        let intent = select_rust_intent(&source);
        let patch = create_patch(&source, &intent).unwrap();
        let patched = hmd_patch::apply_patch(&source, &patch).unwrap();
        let mut intent = intent;
        intent.context.source_hash = Some(source_hash(&patched));

        let second_patch = create_patch(&patched, &intent).unwrap();

        assert!(second_patch.operations.is_empty());
    }

    #[test]
    fn decision_select_option_fails_on_missing_decision_parent() {
        let source = fixture_source();
        let mut intent = select_rust_intent(&source);
        intent.context.decision_target = Some("/blocks/nope".to_string());

        let error = create_patch(&source, &intent).unwrap_err();

        assert_eq!(error.kind, InteractionErrorKind::MissingTarget);
        assert!(error.message.contains("was not found"));
    }

    #[test]
    fn meta_set_generates_set_meta_patch() {
        let source = fixture_source();
        let intent = PatchIntent {
            intent_version: INTENT_VERSION.to_string(),
            action: "meta.set".to_string(),
            target: "/blocks/D-runtime".to_string(),
            context: PatchIntentContext {
                document_profile: Some("decision@0.1".to_string()),
                source_hash: Some(source_hash(&source)),
                decision_target: None,
                created_by: None,
            },
            params: Map::from_iter([
                ("field".to_string(), Value::String("status".to_string())),
                ("value".to_string(), Value::String("accepted".to_string())),
            ]),
        };

        let patch = create_patch(&source, &intent).unwrap();

        match &patch.operations[0] {
            PatchOperation::SetMeta {
                target,
                field,
                value,
                ..
            } => {
                assert_eq!(target, "/blocks/D-runtime");
                assert_eq!(field, "status");
                assert_eq!(value, "accepted");
            }
            operation => panic!("unexpected operation: {operation:?}"),
        }
    }

    #[test]
    fn body_replace_generates_replace_body_patch() {
        let source = fixture_source();
        let intent = PatchIntent {
            intent_version: INTENT_VERSION.to_string(),
            action: "body.replace".to_string(),
            target: "/blocks/rust".to_string(),
            context: PatchIntentContext {
                document_profile: Some("decision@0.1".to_string()),
                source_hash: Some(source_hash(&source)),
                decision_target: None,
                created_by: None,
            },
            params: Map::from_iter([(
                "markdown".to_string(),
                Value::String("Updated option body.".to_string()),
            )]),
        };

        let patch = create_patch(&source, &intent).unwrap();

        match &patch.operations[0] {
            PatchOperation::ReplaceBody {
                target, markdown, ..
            } => {
                assert_eq!(target, "/blocks/rust");
                assert_eq!(markdown, "Updated option body.");
            }
            operation => panic!("unexpected operation: {operation:?}"),
        }
    }

    #[test]
    fn profile_mismatch_is_rejected() {
        let source = fixture_source();
        let mut intent = select_rust_intent(&source);
        intent.context.document_profile = Some("todo@0.1".to_string());

        let error = create_patch(&source, &intent).unwrap_err();

        assert_eq!(error.kind, InteractionErrorKind::ProfileMismatch);
    }

    #[test]
    fn unsupported_action_is_rejected() {
        let source = fixture_source();
        let mut intent = select_rust_intent(&source);
        intent.action = "decision.unknown".to_string();

        let error = create_patch(&source, &intent).unwrap_err();

        assert_eq!(error.kind, InteractionErrorKind::UnsupportedAction);
        assert!(error.message.contains("unsupported action"));
    }

    #[test]
    fn invalid_intent_version_is_rejected() {
        let source = fixture_source();
        let mut intent = select_rust_intent(&source);
        intent.intent_version = "9.9".to_string();

        let error = create_patch(&source, &intent).unwrap_err();

        assert_eq!(error.kind, InteractionErrorKind::InvalidIntent);
        assert!(error.message.contains("unsupported intent version"));
    }

    #[test]
    fn ambiguous_target_is_rejected_for_duplicate_ids() {
        let source = fs::read_to_string(repo_path("fixtures/invalid/duplicate-id.hmd")).unwrap();
        let intent = PatchIntent {
            intent_version: INTENT_VERSION.to_string(),
            action: "meta.set".to_string(),
            target: "/blocks/T-dup".to_string(),
            context: PatchIntentContext {
                document_profile: Some("todo@0.1".to_string()),
                source_hash: Some(source_hash(&source)),
                decision_target: None,
                created_by: None,
            },
            params: Map::from_iter([
                ("field".to_string(), Value::String("status".to_string())),
                ("value".to_string(), Value::String("done".to_string())),
            ]),
        };

        let error = create_patch(&source, &intent).unwrap_err();

        assert_eq!(error.kind, InteractionErrorKind::AmbiguousTarget);
        assert!(error.message.contains("ambiguous"));
    }

    #[test]
    fn intent_json_roundtrips_through_serde() {
        let source = fixture_source();
        let intent = select_rust_intent(&source);

        let json = serde_json::to_string_pretty(&intent).unwrap();
        let parsed: PatchIntent = serde_json::from_str(&json).unwrap();

        assert_eq!(parsed, intent);
    }

    #[test]
    fn stale_source_is_rejected_before_patch_creation() {
        let source = fixture_source();
        let mut intent = select_rust_intent(&source);
        intent.context.source_hash = Some("sha256-stale".to_string());

        let error = create_patch(&source, &intent).unwrap_err();

        assert_eq!(error.kind, InteractionErrorKind::StaleSource);
    }

    #[test]
    fn interaction_error_display_includes_action_and_target() {
        let source = fixture_source();
        let mut intent = select_rust_intent(&source);
        intent.context.source_hash = Some("sha256-stale".to_string());

        let error = create_patch(&source, &intent).unwrap_err().to_string();

        assert!(error.contains("action: decision.select_option"));
        assert!(error.contains("target: /blocks/rust"));
    }

    fn select_rust_intent(source: &str) -> PatchIntent {
        PatchIntent {
            intent_version: INTENT_VERSION.to_string(),
            action: "decision.select_option".to_string(),
            target: "/blocks/rust".to_string(),
            context: PatchIntentContext {
                document_profile: Some("decision@0.1".to_string()),
                source_hash: Some(source_hash(source)),
                decision_target: Some("/blocks/D-runtime".to_string()),
                created_by: Some("hmd-test".to_string()),
            },
            params: Map::from_iter([
                (
                    "choiceId".to_string(),
                    Value::String("CH-D-runtime-rust".to_string()),
                ),
                (
                    "choiceStatus".to_string(),
                    Value::String("selected".to_string()),
                ),
                (
                    "markdown".to_string(),
                    Value::String("Selected Rust.".to_string()),
                ),
            ]),
        }
    }

    fn fixture_source() -> String {
        fs::read_to_string(repo_path("fixtures/valid/decision-basic.hmd")).unwrap()
    }

    fn repo_path(path: impl AsRef<Path>) -> PathBuf {
        Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("../..")
            .join(path)
    }
}
