use hmd_core::TomlValueObject;
use serde::Deserialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
use toml_edit::DocumentMut;

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HmdPatch {
    pub patch_version: String,
    pub target_hash: Option<String>,
    pub operations: Vec<PatchOperation>,
    pub author: Option<String>,
    pub created: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum PatchOperation {
    SetMeta {
        target: String,
        field: String,
        value: Value,
    },
    ReplaceBody {
        target: String,
        markdown: String,
    },
    AppendBlock {
        target: String,
        block: AppendBlock,
    },
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppendBlock {
    pub block_type: String,
    #[serde(default)]
    pub meta: TomlValueObject,
    #[serde(default)]
    pub markdown: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PatchError {
    pub message: String,
}

impl std::fmt::Display for PatchError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.write_str(&self.message)
    }
}

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

pub fn apply_patch(source: &str, patch: &HmdPatch) -> Result<String, PatchError> {
    if patch.patch_version != "0.1" {
        return Err(error(format!(
            "unsupported patch version '{}'",
            patch.patch_version
        )));
    }

    if let Some(target_hash) = &patch.target_hash {
        let actual = source_hash(source);
        if target_hash != &actual {
            return Err(error(format!(
                "target hash mismatch: expected {target_hash}, got {actual}"
            )));
        }
    }

    validate_operations(source, &patch.operations)?;

    let mut current = source.to_string();
    for operation in &patch.operations {
        current = apply_operation(&current, operation)?;
    }
    Ok(current)
}

fn validate_operations(source: &str, operations: &[PatchOperation]) -> Result<(), PatchError> {
    let document = hmd_parse::parse_document(source);

    for operation in operations {
        match operation {
            PatchOperation::SetMeta { target, .. } | PatchOperation::ReplaceBody { target, .. } => {
                validate_block_target(&document, target)?;
            }
            PatchOperation::AppendBlock { target, .. } => {
                if target != "/document" {
                    validate_block_target(&document, target)?;
                }
            }
        }
    }

    Ok(())
}

fn validate_block_target(document: &hmd_core::HmdDocument, target: &str) -> Result<(), PatchError> {
    let Some(id) = target.strip_prefix("/blocks/") else {
        return Err(error(format!(
            "patch target '{target}' must use /blocks/<id>"
        )));
    };

    if document
        .references
        .duplicates
        .iter()
        .any(|record| record.id == id)
    {
        return Err(error(format!("patch target '{target}' is ambiguous")));
    }

    if !document.references.ids.contains_key(id) {
        return Err(error(format!("patch target '{target}' was not found")));
    }

    Ok(())
}

fn apply_operation(source: &str, operation: &PatchOperation) -> Result<String, PatchError> {
    match operation {
        PatchOperation::SetMeta {
            target,
            field,
            value,
        } => apply_set_meta(source, target, field, value),
        PatchOperation::ReplaceBody { target, markdown } => {
            apply_replace_body(source, target, markdown)
        }
        PatchOperation::AppendBlock { target, block } => apply_append_block(source, target, block),
    }
}

fn apply_set_meta(
    source: &str,
    target: &str,
    field: &str,
    value: &Value,
) -> Result<String, PatchError> {
    let span = find_target_span(source, target)?;
    let value_source = toml_literal(value)?;
    let mut output = String::new();

    if let Some(meta) = &span.meta {
        let updated = set_meta_field(&source[meta.content_start..meta.content_end], field, value)?;
        output.push_str(&source[..meta.content_start]);
        output.push_str(&updated);
        output.push_str(&source[meta.content_end..]);
    } else {
        output.push_str(&source[..span.opener_end]);
        output.push_str("+++\n");
        output.push_str(field);
        output.push_str(" = ");
        output.push_str(&value_source);
        output.push('\n');
        output.push_str("+++\n");
        output.push_str(&source[span.opener_end..]);
    }

    Ok(output)
}

fn apply_replace_body(source: &str, target: &str, markdown: &str) -> Result<String, PatchError> {
    let span = find_target_span(source, target)?;
    let mut output = String::new();
    output.push_str(&source[..span.body_start]);
    output.push_str(&body_replacement(markdown));
    output.push_str(&source[span.close_start..]);
    Ok(output)
}

fn apply_append_block(
    source: &str,
    target: &str,
    block: &AppendBlock,
) -> Result<String, PatchError> {
    let block_source = block_to_source(block);
    let (insert_at, insertion) = if target == "/document" {
        let prefix = if source.ends_with('\n') { "\n" } else { "\n\n" };
        (source.len(), format!("{prefix}{block_source}"))
    } else {
        let span = find_target_span(source, target)?;
        (span.close_start, format!("\n{block_source}\n"))
    };

    let mut output = String::new();
    output.push_str(&source[..insert_at]);
    output.push_str(&insertion);
    output.push_str(&source[insert_at..]);
    Ok(output)
}

fn find_target_span(source: &str, target: &str) -> Result<BlockSpan, PatchError> {
    let id = target
        .strip_prefix("/blocks/")
        .ok_or_else(|| error(format!("patch target '{target}' must use /blocks/<id>")))?;
    let spans = scan_block_spans(source)?;
    spans
        .into_iter()
        .find(|span| span.id.as_deref() == Some(id))
        .ok_or_else(|| error(format!("patch target '{target}' was not found")))
}

fn set_meta_field(source: &str, field: &str, value: &Value) -> Result<String, PatchError> {
    let value_source = toml_literal(value)?;
    let replacement = format!("{field} = {value_source}\n");
    let mut output = String::new();
    let mut replaced = false;

    for line in split_inclusive_or_once(source) {
        if is_toml_key_line(line, field) {
            output.push_str(&replacement);
            replaced = true;
        } else {
            output.push_str(line);
        }
    }

    if !replaced {
        if !output.is_empty() && !output.ends_with('\n') {
            output.push('\n');
        }
        output.push_str(&replacement);
    }

    output.parse::<DocumentMut>().map_err(|parse_error| {
        error(format!(
            "set_meta produced invalid TOML metadata for field '{field}': {parse_error}"
        ))
    })?;

    Ok(output)
}

fn is_toml_key_line(line: &str, field: &str) -> bool {
    let trimmed = line.trim_start_matches([' ', '\t']);
    let Some(rest) = trimmed.strip_prefix(field) else {
        return false;
    };
    rest.trim_start_matches([' ', '\t']).starts_with('=')
}

fn body_replacement(markdown: &str) -> String {
    let trimmed = markdown.trim_matches('\n');
    if trimmed.is_empty() {
        "\n".to_string()
    } else {
        format!("\n{trimmed}\n\n")
    }
}

fn block_to_source(block: &AppendBlock) -> String {
    let mut output = String::new();
    output.push_str(&format!("::: {}\n", block.block_type));
    output.push_str("+++\n");
    for (key, value) in &block.meta {
        output.push_str(key);
        output.push_str(" = ");
        output.push_str(&toml_literal(value).expect("JSON value serializes to TOML literal"));
        output.push('\n');
    }
    output.push_str("+++\n");
    if !block.markdown.trim().is_empty() {
        output.push('\n');
        output.push_str(block.markdown.trim_matches('\n'));
        output.push_str("\n\n");
    }
    output.push_str(":::\n");
    output
}

fn toml_literal(value: &Value) -> Result<String, PatchError> {
    match value {
        Value::Null => Err(error("null is not a valid TOML metadata value")),
        Value::Bool(value) => Ok(value.to_string()),
        Value::Number(value) => Ok(value.to_string()),
        Value::String(value) => serde_json::to_string(value)
            .map_err(|error| self::error(format!("failed to encode TOML string: {error}"))),
        Value::Array(values) => {
            let values = values
                .iter()
                .map(toml_literal)
                .collect::<Result<Vec<_>, _>>()?;
            Ok(format!("[{}]", values.join(", ")))
        }
        Value::Object(values) => {
            let values = values
                .iter()
                .map(|(key, value)| Ok(format!("{key} = {}", toml_literal(value)?)))
                .collect::<Result<Vec<_>, PatchError>>()?;
            Ok(format!("{{ {} }}", values.join(", ")))
        }
    }
}

#[derive(Debug, Clone)]
struct BlockSpan {
    id: Option<String>,
    opener_end: usize,
    body_start: usize,
    close_start: usize,
    meta: Option<MetaSpan>,
}

#[derive(Debug, Clone)]
struct MetaSpan {
    content_start: usize,
    content_end: usize,
}

#[derive(Debug, Clone)]
struct OpenBlock {
    id: Option<String>,
    fence_length: usize,
    opener_end: usize,
    body_start: usize,
    meta: Option<MetaSpan>,
}

fn scan_block_spans(source: &str) -> Result<Vec<BlockSpan>, PatchError> {
    let lines = collect_lines(source);
    let mut spans = Vec::new();
    let mut stack: Vec<OpenBlock> = Vec::new();
    let mut index = body_start_index(&lines);

    while index < lines.len() {
        let line = &lines[index];

        if let Some(open) = stack.last() {
            if is_closer(line.content, open.fence_length) {
                let open = stack.pop().expect("stack was not empty");
                spans.push(BlockSpan {
                    id: open.id,
                    opener_end: open.opener_end,
                    body_start: open.body_start,
                    close_start: line.start,
                    meta: open.meta,
                });
                index += 1;
                continue;
            }
        }

        if let Some(opener) = parse_opener(line.content) {
            let mut open = OpenBlock {
                id: None,
                fence_length: opener.fence_length,
                opener_end: line.end,
                body_start: line.end,
                meta: None,
            };
            index += 1;

            if lines
                .get(index)
                .is_some_and(|line| is_plus_delimiter(line.content))
            {
                let meta_open = &lines[index];
                let Some(meta_close_index) =
                    lines
                        .iter()
                        .enumerate()
                        .skip(index + 1)
                        .find_map(|(candidate, line)| {
                            is_plus_delimiter(line.content).then_some(candidate)
                        })
                else {
                    return Err(error(format!(
                        "unterminated block metadata at line {}",
                        meta_open.number
                    )));
                };
                let content_start = lines
                    .get(index + 1)
                    .map(|line| line.start)
                    .unwrap_or(meta_open.end);
                let content_end = lines[meta_close_index].start;
                let meta_source = &source[content_start..content_end];
                open.id = parse_meta_id(meta_source);
                open.body_start = lines[meta_close_index].end;
                open.meta = Some(MetaSpan {
                    content_start,
                    content_end,
                });
                index = meta_close_index + 1;
            }

            stack.push(open);
            continue;
        }

        index += 1;
    }

    Ok(spans)
}

fn body_start_index(lines: &[Line<'_>]) -> usize {
    if !lines
        .first()
        .is_some_and(|line| is_plus_delimiter(line.content))
    {
        return 0;
    }

    lines
        .iter()
        .enumerate()
        .skip(1)
        .find_map(|(index, line)| is_plus_delimiter(line.content).then_some(index + 1))
        .unwrap_or(lines.len())
}

fn parse_meta_id(source: &str) -> Option<String> {
    source.parse::<DocumentMut>().ok().and_then(|document| {
        document
            .get("id")
            .and_then(|value| value.as_str())
            .map(str::to_string)
    })
}

#[derive(Debug, Clone, Copy)]
struct Line<'a> {
    content: &'a str,
    start: usize,
    end: usize,
    number: usize,
}

fn collect_lines(source: &str) -> Vec<Line<'_>> {
    let mut lines = Vec::new();
    let mut start = 0;
    let mut number = 1;

    for raw in source.split_inclusive('\n') {
        let end = start + raw.len();
        lines.push(Line {
            content: strip_line_ending(raw),
            start,
            end,
            number,
        });
        start = end;
        number += 1;
    }

    if start < source.len() {
        let raw = &source[start..];
        lines.push(Line {
            content: strip_line_ending(raw),
            start,
            end: source.len(),
            number,
        });
    }

    lines
}

fn strip_line_ending(line: &str) -> &str {
    let without_lf = line.strip_suffix('\n').unwrap_or(line);
    without_lf.strip_suffix('\r').unwrap_or(without_lf)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Opener<'a> {
    fence_length: usize,
    block_type: &'a str,
}

fn parse_opener(content: &str) -> Option<Opener<'_>> {
    let trimmed = trim_horizontal(content);
    let fence_length = trimmed.bytes().take_while(|byte| *byte == b':').count();
    if fence_length < 3 {
        return None;
    }

    let block_type = trim_horizontal(&trimmed[fence_length..]);
    if block_type.is_empty() || block_type.bytes().all(|byte| byte == b':') {
        return None;
    }

    Some(Opener {
        fence_length,
        block_type,
    })
}

fn is_closer(content: &str, opener_length: usize) -> bool {
    let trimmed = trim_horizontal(content);
    let length = trimmed.bytes().take_while(|byte| *byte == b':').count();
    length >= opener_length && length == trimmed.len()
}

fn is_plus_delimiter(content: &str) -> bool {
    trim_horizontal(content) == "+++"
}

fn trim_horizontal(value: &str) -> &str {
    value.trim_matches(|ch| ch == ' ' || ch == '\t')
}

fn split_inclusive_or_once(source: &str) -> Vec<&str> {
    if source.is_empty() {
        Vec::new()
    } else {
        source.split_inclusive('\n').collect()
    }
}

fn source_hash(source: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(source.as_bytes());
    format!("sha256-{}", to_hex(&hasher.finalize()))
}

fn to_hex(bytes: &[u8]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut output = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        output.push(HEX[(byte >> 4) as usize] as char);
        output.push(HEX[(byte & 0x0f) as usize] as char);
    }
    output
}

fn error(message: impl Into<String>) -> PatchError {
    PatchError {
        message: message.into(),
    }
}

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

    #[test]
    fn patches_task_status_todo_to_done() {
        let source = fs::read_to_string(repo_path("fixtures/valid/todo-basic.hmd")).unwrap();
        let patch = serde_json::from_value(serde_json::json!({
            "patchVersion": "0.1",
            "operations": [{
                "op": "set_meta",
                "target": "/blocks/T-parser",
                "field": "status",
                "value": "done"
            }]
        }))
        .unwrap();

        let patched = apply_patch(&source, &patch).unwrap();
        assert!(patched.contains("status = \"done\""));
        assert!(!patched.contains("status = \"todo\""));
    }

    #[test]
    fn appends_decision_choice_block() {
        let source = fs::read_to_string(repo_path("fixtures/valid/decision-basic.hmd")).unwrap();
        let patch = serde_json::from_value(serde_json::json!({
            "patchVersion": "0.1",
            "operations": [{
                "op": "append_block",
                "target": "/blocks/D-runtime",
                "block": {
                    "blockType": "choice",
                    "meta": {
                        "id": "CH-runtime",
                        "option": "rust",
                        "status": "selected"
                    },
                    "markdown": "Selected Rust after reviewing the recommendation."
                }
            }]
        }))
        .unwrap();

        let patched = apply_patch(&source, &patch).unwrap();
        assert!(patched.contains("::: choice"));
        assert!(patched.contains("id = \"CH-runtime\""));
        assert!(patched.contains("Selected Rust after reviewing the recommendation."));
    }

    #[test]
    fn missing_target_fails_without_editing() {
        let source = fs::read_to_string(repo_path("fixtures/valid/todo-basic.hmd")).unwrap();
        let patch = serde_json::from_value(serde_json::json!({
            "patchVersion": "0.1",
            "operations": [{
                "op": "replace_body",
                "target": "/blocks/nope",
                "markdown": "No edit."
            }]
        }))
        .unwrap();

        let error = apply_patch(&source, &patch).unwrap_err();
        assert!(error.message.contains("was not found"));
    }

    #[test]
    fn duplicate_id_target_fails_safely() {
        let source = fs::read_to_string(repo_path("fixtures/invalid/duplicate-id.hmd")).unwrap();
        let patch = serde_json::from_value(serde_json::json!({
            "patchVersion": "0.1",
            "operations": [{
                "op": "set_meta",
                "target": "/blocks/T-dup",
                "field": "status",
                "value": "done"
            }]
        }))
        .unwrap();

        let error = apply_patch(&source, &patch).unwrap_err();
        assert!(error.message.contains("ambiguous"));
    }

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