use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::documents::{
    content as document_content, meta as document_meta, DocumentContentQuery, DocumentMetaQuery,
};
use axum::extract::{Extension, Json, Query, State};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentEditorShellQuery {
    pub document_id: String,
    pub workspace_id: Option<String>,
    pub host: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeShellBlockWire {
    pub id: String,
    pub block_type: String,
    pub parent_id: Option<String>,
    pub depth: u16,
    #[serde(default)]
    pub text: String,
    pub heading_level: Option<u8>,
    pub checked: Option<bool>,
    #[serde(default)]
    pub collapsed: bool,
    #[serde(default)]
    pub editable: bool,
    #[serde(default)]
    pub raw_type: String,
    pub language: Option<String>,
    #[serde(default)]
    pub raw_block: Value,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeTransformRequest {
    pub document_id: String,
    pub workspace_id: Option<String>,
    pub snapshot: RuntimeTransformSnapshotRequest,
    pub command: RuntimeTransformCommandRequest,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeTransformSnapshotRequest {
    pub blocks: Vec<RuntimeShellBlockWire>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case", tag = "action")]
pub enum RuntimeTransformCommandRequest {
    SetBlockType {
        block_id: String,
        block_type: String,
    },
    SplitBlock {
        block_id: String,
        offset: usize,
        new_block_id: String,
    },
    MergeWithPrevious {
        block_id: String,
    },
    IndentBlock {
        block_id: String,
    },
    OutdentBlock {
        block_id: String,
    },
    ToggleHeadingCollapse {
        block_id: String,
    },
}

fn escape_html(input: &str) -> String {
    input
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}

fn escape_inline_json(input: &str) -> String {
    input
        .replace('&', "\\u0026")
        .replace('<', "\\u003c")
        .replace('>', "\\u003e")
}

fn read_non_empty_string(value: &Value, keys: &[&str]) -> Option<String> {
    let map = value.as_object()?;
    for key in keys {
        let candidate = map
            .get(*key)
            .and_then(Value::as_str)
            .map(str::trim)
            .unwrap_or("");
        if !candidate.is_empty() {
            return Some(candidate.to_string());
        }
    }
    None
}

fn read_bool(value: &Value, keys: &[&str], default: bool) -> bool {
    let Some(map) = value.as_object() else {
        return default;
    };
    for key in keys {
        if let Some(flag) = map.get(*key).and_then(Value::as_bool) {
            return flag;
        }
    }
    default
}

fn read_u64(value: &Value, keys: &[&str], default: u64) -> u64 {
    let Some(map) = value.as_object() else {
        return default;
    };
    for key in keys {
        if let Some(number) = map.get(*key).and_then(Value::as_u64) {
            return number;
        }
    }
    default
}

fn read_non_empty_prop_string(value: &Value, key: &str) -> Option<String> {
    value
        .get("props")
        .and_then(Value::as_object)
        .and_then(|props| props.get(key))
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|item| !item.is_empty())
        .map(ToOwned::to_owned)
}

fn read_prop_bool(value: &Value, key: &str) -> Option<bool> {
    value
        .get("props")
        .and_then(Value::as_object)
        .and_then(|props| props.get(key))
        .and_then(Value::as_bool)
}

fn read_prop_u8(value: &Value, key: &str) -> Option<u8> {
    value
        .get("props")
        .and_then(Value::as_object)
        .and_then(|props| props.get(key))
        .and_then(Value::as_u64)
        .and_then(|item| u8::try_from(item).ok())
}

fn get_inline_text(value: &Value) -> String {
    match value {
        Value::String(text) => text.clone(),
        Value::Array(items) => items
            .iter()
            .map(|item| match item {
                Value::String(text) => text.clone(),
                Value::Object(map) => {
                    if let Some(text) = map.get("text").and_then(Value::as_str) {
                        return text.to_string();
                    }
                    if let Some(content) = map.get("content") {
                        return get_inline_text(content);
                    }
                    String::new()
                }
                _ => String::new(),
            })
            .collect::<Vec<String>>()
            .join(""),
        _ => String::new(),
    }
}

fn split_content_root(content_result: &Value) -> (Vec<Value>, Option<Value>) {
    let current = content_result
        .get("content")
        .cloned()
        .unwrap_or(Value::Null);
    if let Some(array) = current.as_array() {
        return (array.clone(), None);
    }
    if let Some(map) = current.as_object() {
        let blocks = map
            .get("blocks")
            .and_then(Value::as_array)
            .cloned()
            .unwrap_or_default();
        let mut wrapper = map.clone();
        wrapper.remove("blocks");
        return (blocks, Some(Value::Object(wrapper)));
    }
    (Vec::new(), None)
}

fn runtime_block_type_for_raw_type(raw_type: &str) -> (&'static str, bool) {
    match raw_type {
        "paragraph" => ("paragraph", true),
        "heading" => ("heading", true),
        "bulletListItem" => ("bullet_list_item", true),
        "numberedListItem" => ("numbered_list_item", true),
        "checkListItem" | "advancedTodo" => ("todo", true),
        "quote" => ("quote", true),
        "divider" => ("divider", true),
        "codeBlock" => ("code_block", true),
        "pageReference" => ("page_reference", false),
        "blockReference" => ("block_reference", false),
        "progress" | "progressBlock" => ("progress_placeholder", false),
        "media" | "mindmap" | "onlineTable" => ("media_placeholder", false),
        _ => ("media_placeholder", false),
    }
}

fn raw_type_for_runtime_block_type(block_type: &str, fallback_raw_type: &str) -> String {
    match block_type {
        "paragraph" => "paragraph".into(),
        "heading" => "heading".into(),
        "bullet_list_item" => "bulletListItem".into(),
        "numbered_list_item" => "numberedListItem".into(),
        "todo" => {
            if fallback_raw_type == "advancedTodo" {
                "advancedTodo".into()
            } else {
                "checkListItem".into()
            }
        }
        "quote" => "quote".into(),
        "divider" => "divider".into(),
        "code_block" => "codeBlock".into(),
        "page_reference" => "pageReference".into(),
        "block_reference" => "blockReference".into(),
        "progress_placeholder" => {
            if fallback_raw_type.trim().is_empty() {
                "progressBlock".into()
            } else {
                fallback_raw_type.to_string()
            }
        }
        "media_placeholder" => {
            if fallback_raw_type.trim().is_empty() {
                "media".into()
            } else {
                fallback_raw_type.to_string()
            }
        }
        _ => {
            if fallback_raw_type.trim().is_empty() {
                "paragraph".into()
            } else {
                fallback_raw_type.to_string()
            }
        }
    }
}

fn append_runtime_shell_blocks(
    raw_blocks: &[Value],
    depth: u16,
    parent_id: Option<&str>,
    output: &mut Vec<RuntimeShellBlockWire>,
) {
    for (index, raw_block) in raw_blocks.iter().enumerate() {
        let id = read_non_empty_string(raw_block, &["id"])
            .unwrap_or_else(|| format!("runtime_block_{}_{}", depth, index + 1));
        let raw_type =
            read_non_empty_string(raw_block, &["type"]).unwrap_or_else(|| "paragraph".into());
        let (block_type, editable) = runtime_block_type_for_raw_type(&raw_type);
        let text = get_inline_text(raw_block.get("content").unwrap_or(&Value::Null));
        let block = RuntimeShellBlockWire {
            id: id.clone(),
            block_type: block_type.into(),
            parent_id: parent_id.map(ToOwned::to_owned),
            depth,
            text: text.clone(),
            heading_level: read_prop_u8(raw_block, "level"),
            checked: read_prop_bool(raw_block, "checked").or_else(|| {
                read_non_empty_prop_string(raw_block, "status")
                    .map(|status| matches!(status.as_str(), "done" | "completed"))
            }),
            collapsed: read_prop_bool(raw_block, "collapsed").unwrap_or(false),
            editable,
            raw_type: raw_type.clone(),
            language: read_non_empty_prop_string(raw_block, "language"),
            raw_block: raw_block.clone(),
        };
        output.push(block);
        let children = raw_block
            .get("children")
            .and_then(Value::as_array)
            .cloned()
            .unwrap_or_default();
        if !children.is_empty() {
            append_runtime_shell_blocks(&children, depth + 1, Some(id.as_str()), output);
        }
    }
}

fn initial_runtime_shell_blocks(
    content_result: &Value,
    document_id: &str,
) -> (Vec<RuntimeShellBlockWire>, Option<Value>) {
    let (raw_blocks, content_wrapper) = split_content_root(content_result);
    let mut blocks = Vec::new();
    append_runtime_shell_blocks(&raw_blocks, 0, None, &mut blocks);
    if blocks.is_empty() {
        blocks.push(RuntimeShellBlockWire {
            id: format!("{document_id}_block_1"),
            block_type: "paragraph".into(),
            parent_id: None,
            depth: 0,
            text: String::new(),
            heading_level: None,
            checked: None,
            collapsed: false,
            editable: true,
            raw_type: "paragraph".into(),
            language: None,
            raw_block: Value::Null,
        });
    }
    (blocks, content_wrapper)
}

fn core_block_type_from_runtime(block_type: &str) -> BlockType {
    match block_type {
        "paragraph" => BlockType::Paragraph,
        "heading" => BlockType::Heading,
        "bullet_list_item" => BlockType::BulletListItem,
        "numbered_list_item" => BlockType::NumberedListItem,
        "todo" => BlockType::Todo,
        "quote" => BlockType::Quote,
        "divider" => BlockType::Divider,
        "code_block" => BlockType::CodeBlock,
        "page_reference" => BlockType::PageReference,
        "block_reference" => BlockType::BlockReference,
        "progress_placeholder" => BlockType::ProgressPlaceholder,
        _ => BlockType::MediaPlaceholder,
    }
}

fn runtime_block_type_from_core(block_type: &BlockType) -> &'static str {
    match block_type {
        BlockType::Paragraph => "paragraph",
        BlockType::Heading => "heading",
        BlockType::BulletListItem => "bullet_list_item",
        BlockType::NumberedListItem => "numbered_list_item",
        BlockType::Todo => "todo",
        BlockType::Quote => "quote",
        BlockType::Divider => "divider",
        BlockType::CodeBlock => "code_block",
        BlockType::PageReference => "page_reference",
        BlockType::BlockReference => "block_reference",
        BlockType::ProgressPlaceholder => "progress_placeholder",
        BlockType::MediaPlaceholder => "media_placeholder",
    }
}

fn runtime_snapshot_to_document(blocks: &[RuntimeShellBlockWire]) -> DocumentModel {
    DocumentModel::new(
        blocks
            .iter()
            .map(|block| {
                let mut item = DocumentBlock::new(
                    block.id.clone(),
                    core_block_type_from_runtime(&block.block_type),
                )
                .with_text(block.text.clone())
                .with_collapsed(block.collapsed);
                item.parent_id = block.parent_id.clone();
                item.indent = block.depth;
                item.heading_level = block.heading_level;
                item.checked = block.checked;
                if let Some(language) = block.language.as_deref() {
                    item.content.language = Some(language.to_string());
                }
                item
            })
            .collect(),
    )
}

fn runtime_snapshot_from_document(
    document: &DocumentModel,
    previous: &[RuntimeShellBlockWire],
) -> Vec<RuntimeShellBlockWire> {
    document
        .blocks()
        .iter()
        .map(|block| {
            let previous_block = previous.iter().find(|item| item.id == block.id);
            let editable = previous_block.map(|item| item.editable).unwrap_or(true);
            let raw_type = previous_block
                .map(|item| {
                    raw_type_for_runtime_block_type(
                        runtime_block_type_from_core(&block.block_type),
                        &item.raw_type,
                    )
                })
                .unwrap_or_else(|| {
                    raw_type_for_runtime_block_type(
                        runtime_block_type_from_core(&block.block_type),
                        "",
                    )
                });
            RuntimeShellBlockWire {
                id: block.id.clone(),
                block_type: runtime_block_type_from_core(&block.block_type).into(),
                parent_id: block.parent_id.clone(),
                depth: block.indent,
                text: block.content.text.clone(),
                heading_level: block.heading_level,
                checked: block.checked,
                collapsed: block.collapsed,
                editable,
                raw_type,
                language: block.content.language.clone(),
                raw_block: Value::Null,
            }
        })
        .collect()
}

fn runtime_editor_command(
    request: RuntimeTransformCommandRequest,
) -> Result<EditorCommand, WebError> {
    match request {
        RuntimeTransformCommandRequest::SetBlockType {
            block_id,
            block_type,
        } => {
            let Some(block_type) = BlockType::from_editor_label(block_type.as_str()) else {
                return Err(WebError::bad_request(format!(
                    "不支持的 blockType: {block_type}"
                )));
            };
            Ok(EditorCommand::SetBlockType {
                block_id,
                block_type,
            })
        }
        RuntimeTransformCommandRequest::SplitBlock {
            block_id,
            offset,
            new_block_id,
        } => Ok(EditorCommand::SplitBlock {
            block_id,
            offset,
            new_block_id,
        }),
        RuntimeTransformCommandRequest::MergeWithPrevious { block_id } => {
            Ok(EditorCommand::MergeWithPrevious { block_id })
        }
        RuntimeTransformCommandRequest::IndentBlock { block_id } => {
            Ok(EditorCommand::IndentBlock { block_id })
        }
        RuntimeTransformCommandRequest::OutdentBlock { block_id } => {
            Ok(EditorCommand::OutdentBlock { block_id })
        }
        RuntimeTransformCommandRequest::ToggleHeadingCollapse { block_id } => {
            Ok(EditorCommand::ToggleHeadingCollapse { block_id })
        }
    }
}

fn build_document_editor_shell_html(
    workspace_id: Option<&str>,
    document_id: &str,
    host: Option<&str>,
    meta_result: &Value,
    content_result: &Value,
) -> String {
    let page_title =
        read_non_empty_string(meta_result, &["title"]).unwrap_or_else(|| "无标题".into());
    let updated_at = read_non_empty_string(meta_result, &["updated_at", "updatedAt"])
        .unwrap_or_else(|| "未知".into());
    let read_only = !read_bool(meta_result, &["can_edit", "canEdit"], true);
    let revision = read_u64(content_result, &["revision"], 0);
    let conflict_detection_key = read_non_empty_string(
        content_result,
        &["conflict_detection_key", "conflictDetectionKey"],
    )
    .unwrap_or_else(|| format!("{document_id}:{revision}"));
    let (blocks, content_wrapper) = initial_runtime_shell_blocks(content_result, document_id);
    let initial_focus_id = blocks
        .first()
        .map(|node| node.id.clone())
        .unwrap_or_else(|| "block-empty".into());
    let app_state = json!({
        "workspaceId": workspace_id,
        "documentId": document_id,
        "host": host,
        "meta": {
            "title": page_title,
            "updatedAt": updated_at,
            "readOnly": read_only,
            "revision": revision,
            "conflictDetectionKey": conflict_detection_key,
        },
        "contentWrapper": content_wrapper,
        "initialFocusId": initial_focus_id,
        "blocks": blocks,
    });
    let app_state_json = serde_json::to_string(&app_state).unwrap_or_else(|_| "{}".into());

    let template = r##"<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>mnote Document Runtime Debug</title>
    <style>
      :root {
        color-scheme: light;
        --bg: #f7f4ee;
        --panel: rgba(255, 252, 247, 0.96);
        --panel-strong: #ffffff;
        --ink: #18222f;
        --muted: #667085;
        --line: rgba(148, 163, 184, 0.22);
        --accent: #0f6c84;
        --accent-soft: rgba(15, 108, 132, 0.1);
        --focus: rgba(37, 99, 235, 0.18);
        --selected: rgba(15, 108, 132, 0.14);
        --hover: rgba(15, 23, 42, 0.05);
      }
      * { box-sizing: border-box; }
      body {
        margin: 0;
        font-family: "Noto Sans CJK SC", "Source Han Sans SC", sans-serif;
        color: var(--ink);
        background:
          radial-gradient(circle at top left, rgba(15, 108, 132, 0.14), transparent 28%),
          linear-gradient(180deg, #fbf8f3 0%, var(--bg) 100%);
      }
      main {
        min-height: 100vh;
        display: grid;
        grid-template-rows: auto auto minmax(0, 1fr);
        gap: 16px;
        padding: 18px;
      }
      .hero,
      .status-card,
      .editor-card {
        background: var(--panel);
        border: 1px solid var(--line);
        border-radius: 22px;
        box-shadow: 0 18px 40px rgba(24, 34, 47, 0.08);
      }
      .hero {
        padding: 18px 20px;
        display: grid;
        gap: 8px;
      }
      .eyebrow {
        font-size: 12px;
        letter-spacing: 0.12em;
        text-transform: uppercase;
        color: var(--accent);
      }
      h1 {
        margin: 0;
        font-size: clamp(24px, 4vw, 34px);
        line-height: 1.05;
      }
      .summary {
        margin: 0;
        color: var(--muted);
        line-height: 1.55;
      }
      .meta-grid {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
        gap: 10px;
      }
      .meta-pill {
        border-radius: 14px;
        padding: 10px 12px;
        background: rgba(255, 255, 255, 0.78);
        border: 1px solid rgba(148, 163, 184, 0.2);
      }
      .meta-pill strong {
        display: block;
        margin-bottom: 4px;
        font-size: 12px;
        color: var(--muted);
      }
      .status-card {
        padding: 14px 16px;
        display: grid;
        gap: 10px;
      }
      .status-line {
        display: flex;
        gap: 12px;
        flex-wrap: wrap;
        align-items: center;
        font-size: 13px;
        color: var(--muted);
      }
      .status-chip {
        display: inline-flex;
        align-items: center;
        gap: 6px;
        padding: 6px 10px;
        border-radius: 999px;
        background: rgba(255, 255, 255, 0.72);
        border: 1px solid rgba(148, 163, 184, 0.18);
      }
      .editor-card {
        display: grid;
        grid-template-columns: minmax(0, 1fr) 300px;
        min-height: 0;
        overflow: hidden;
      }
      .editor-main {
        min-height: 0;
        overflow: auto;
        padding: 16px;
        border-right: 1px solid rgba(148, 163, 184, 0.18);
      }
      .editor-toolbar,
      .editor-slash-menu {
        display: flex;
        gap: 8px;
        flex-wrap: wrap;
        margin-bottom: 12px;
      }
      .editor-command-button {
        border: 1px solid rgba(148, 163, 184, 0.24);
        background: rgba(255, 255, 255, 0.88);
        color: var(--ink);
        border-radius: 999px;
        padding: 8px 12px;
        font-size: 12px;
        line-height: 1;
        cursor: pointer;
      }
      .editor-command-button[disabled] {
        opacity: 0.45;
        cursor: not-allowed;
      }
      .editor-sidebar {
        min-height: 0;
        overflow: auto;
        padding: 16px;
        background: rgba(255, 255, 255, 0.72);
      }
      .editor-list {
        list-style: none;
        margin: 0;
        padding: 0;
        display: grid;
        gap: 8px;
        min-height: 88px;
        align-content: start;
      }
      .editor-row {
        position: relative;
        display: grid;
        grid-template-columns: auto minmax(0, 1fr) auto;
        gap: 10px;
        align-items: center;
        border-radius: 16px;
        border: 1px solid rgba(148, 163, 184, 0.18);
        background: var(--panel-strong);
        padding: 12px 12px 12px calc(12px + var(--depth-indent, 0px));
        transition: background 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
      }
      .editor-row:hover,
      .editor-row[data-hovered="true"] {
        background: var(--hover);
      }
      .editor-row[data-focused="true"] {
        border-color: rgba(37, 99, 235, 0.3);
        background: var(--focus);
        box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.12);
      }
      .editor-row[data-selected="true"] {
        border-color: rgba(15, 108, 132, 0.3);
        background: var(--selected);
      }
      .editor-row[data-active="true"] .editor-row-title {
        color: var(--accent);
      }
      .editor-badge {
        min-width: 26px;
        height: 26px;
        border-radius: 999px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        background: rgba(15, 108, 132, 0.1);
        color: var(--accent);
        font-size: 11px;
        font-weight: 700;
      }
      .editor-link {
        display: grid;
        gap: 4px;
        min-width: 0;
      }
      .editor-input {
        width: 100%;
        min-height: 34px;
        resize: vertical;
        border: 0;
        background: transparent;
        color: var(--ink);
        font: inherit;
        line-height: 1.5;
        padding: 0;
        outline: none;
      }
      .editor-input[readonly] {
        color: var(--muted);
        cursor: default;
      }
      .editor-row-title {
        font-size: 14px;
        font-weight: 600;
        color: var(--ink);
      }
      .editor-row-meta {
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
        font-size: 12px;
        color: var(--muted);
      }
      .editor-row[data-collapsed="true"] .editor-row-title::after {
        content: " (collapsed)";
        color: var(--accent);
        font-size: 12px;
        font-weight: 500;
      }
      .editor-state-tag {
        font-size: 11px;
        color: var(--muted);
        border-radius: 999px;
        background: rgba(15, 23, 42, 0.04);
        padding: 6px 8px;
      }
      .editor-ref-list {
        display: flex;
        gap: 6px;
        flex-wrap: wrap;
        margin-top: 4px;
      }
      .editor-ref-chip {
        display: inline-flex;
        align-items: center;
        border-radius: 999px;
        background: rgba(15, 108, 132, 0.12);
        color: var(--accent);
        font-size: 11px;
        padding: 3px 8px;
      }
      .panel-title {
        margin: 0 0 12px;
        font-size: 14px;
        font-weight: 700;
      }
      .panel-grid {
        display: grid;
        gap: 10px;
      }
      .panel-card {
        border-radius: 16px;
        border: 1px solid rgba(148, 163, 184, 0.18);
        background: rgba(255, 255, 255, 0.82);
        padding: 12px;
      }
      .panel-card strong {
        display: block;
        margin-bottom: 6px;
        font-size: 12px;
        color: var(--muted);
      }
      .panel-log {
        margin: 0;
        white-space: pre-wrap;
        font-size: 12px;
        line-height: 1.55;
        color: #243447;
      }
      .editor-empty {
        border: 1px dashed rgba(148, 163, 184, 0.36);
        border-radius: 18px;
        padding: 24px 18px;
        text-align: center;
        color: var(--muted);
        background: rgba(255, 255, 255, 0.64);
      }
      .visually-hidden {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
        white-space: nowrap;
        border: 0;
      }
      @media (max-width: 920px) {
        .editor-card {
          grid-template-columns: 1fr;
        }
        .editor-main {
          border-right: 0;
          border-bottom: 1px solid rgba(148, 163, 184, 0.18);
        }
      }
    </style>
  </head>
  <body>
    <main>
      <section class="hero">
        <div class="eyebrow">Document Runtime Debug</div>
        <h1 id="block-editor-title">__PAGE_TITLE__</h1>
        <p class="summary">
          这是 mnote-web 的文档 runtime 调试页，只用于事务诊断、IME 排障、保存链回归与 bridge API 观察。
          默认文档页仍应走产品页主链，这里不再作为正式编辑器 surface。
        </p>
        <div class="meta-grid">
          <div class="meta-pill"><strong>Workspace</strong><span>__WORKSPACE_ID__</span></div>
          <div class="meta-pill"><strong>Document</strong><span>__DOCUMENT_ID__</span></div>
          <div class="meta-pill"><strong>Host</strong><span>__HOST__</span></div>
          <div class="meta-pill"><strong>ReadOnly</strong><span>__READ_ONLY__</span></div>
        </div>
      </section>

      <section class="status-card">
        <div class="status-line">
          <span class="status-chip">revision=<strong id="editor-revision">__REVISION__</strong></span>
          <span class="status-chip">updatedAt=<strong id="editor-updated-at">__UPDATED_AT__</strong></span>
          <span class="status-chip">blocks=<strong id="editor-block-count">0</strong></span>
          <span class="status-chip">save=<strong id="editor-save-status">idle</strong></span>
        </div>
        <div class="status-line">
          <span class="status-chip">focus=<strong id="editor-focus-id">-</strong></span>
          <span class="status-chip">selection=<strong id="editor-selection-id">-</strong></span>
          <span class="status-chip">hover=<strong id="editor-hover-id">-</strong></span>
        </div>
      </section>

      <section class="editor-card">
        <div class="editor-main">
          <div class="editor-toolbar" role="toolbar" aria-label="Block editor controls">
            <button type="button" class="editor-command-button" id="editor-command-slash">Slash 菜单</button>
            <button type="button" class="editor-command-button" id="editor-command-toggle-heading">折叠标题</button>
            <button type="button" class="editor-command-button" id="editor-command-indent">缩进</button>
            <button type="button" class="editor-command-button" id="editor-command-outdent">取消缩进</button>
            <button type="button" class="editor-command-button" id="editor-command-page-ref">[[page]]</button>
            <button type="button" class="editor-command-button" id="editor-command-block-ref">((block))</button>
          </div>
          <div class="editor-slash-menu" id="editor-slash-menu" hidden>
            <button type="button" class="editor-command-button" data-slash-action="paragraph">Paragraph</button>
            <button type="button" class="editor-command-button" data-slash-action="heading">Heading</button>
            <button type="button" class="editor-command-button" data-slash-action="todo">Todo</button>
          </div>
          <h2 class="visually-hidden">Block List</h2>
          <ul class="editor-list" id="block-editor-list" role="listbox" aria-label="Block list"></ul>
          <div class="editor-empty" id="block-editor-empty" hidden>当前文档没有可展示的 block。</div>
        </div>
        <aside class="editor-sidebar">
          <h2 class="panel-title">交互状态</h2>
          <div class="panel-grid">
            <div class="panel-card">
              <strong>最小状态机</strong>
              <div>focus / selection / hover / beforeinput / composition / slash / heading collapse / indent 现在直接驱动可输入 runtime，并接入保存链。</div>
            </div>
            <div class="panel-card">
              <strong>当前 block</strong>
              <div id="editor-current-title">尚未聚焦</div>
              <div id="editor-current-meta" style="margin-top: 6px; color: var(--muted); font-size: 12px;">等待交互</div>
            </div>
            <div class="panel-card">
              <strong>最近事件</strong>
              <pre class="panel-log" id="editor-event-log">block_editor_shell.ready</pre>
            </div>
          </div>
        </aside>
      </section>
    </main>

    <script id="block-editor-shell-state" type="application/json">__APP_STATE__</script>
    <script>
      (() => {
        const stateElement = document.getElementById("block-editor-shell-state");
        const listElement = document.getElementById("block-editor-list");
        const emptyElement = document.getElementById("block-editor-empty");
        const focusElement = document.getElementById("editor-focus-id");
        const selectionElement = document.getElementById("editor-selection-id");
        const hoverElement = document.getElementById("editor-hover-id");
        const blockCountElement = document.getElementById("editor-block-count");
        const saveStatusElement = document.getElementById("editor-save-status");
        const revisionElement = document.getElementById("editor-revision");
        const currentTitleElement = document.getElementById("editor-current-title");
        const currentMetaElement = document.getElementById("editor-current-meta");
        const eventLogElement = document.getElementById("editor-event-log");
        const slashButton = document.getElementById("editor-command-slash");
        const toggleHeadingButton = document.getElementById("editor-command-toggle-heading");
        const indentButton = document.getElementById("editor-command-indent");
        const outdentButton = document.getElementById("editor-command-outdent");
        const pageRefButton = document.getElementById("editor-command-page-ref");
        const blockRefButton = document.getElementById("editor-command-block-ref");
        const slashMenuElement = document.getElementById("editor-slash-menu");
        if (!stateElement || !listElement || !emptyElement) {
          return;
        }

        const parseState = () => {
          try {
            return JSON.parse(stateElement.textContent || "{}");
          } catch {
            return {};
          }
        };

        const state = parseState();
        const READ_ONLY = Boolean(state.meta && state.meta.readOnly);
        const meta = {
          revision: Number((state.meta && state.meta.revision) || 0) || 0,
          conflictDetectionKey:
            String((state.meta && state.meta.conflictDetectionKey) || "").trim() ||
            String(state.documentId || "document") + ":0",
        };
        const contentWrapper =
          state.contentWrapper && typeof state.contentWrapper === "object" && !Array.isArray(state.contentWrapper)
            ? state.contentWrapper
            : null;
        const cloneJson = (value) => {
          try {
            return JSON.parse(JSON.stringify(value));
          } catch {
            return null;
          }
        };
        const normalizeBlock = (block) => ({
          id: String(block && block.id ? block.id : "block-missing"),
          blockType: String(block && block.blockType ? block.blockType : "paragraph"),
          parentId: typeof (block && block.parentId) === "string" && block.parentId.trim() ? block.parentId.trim() : null,
          depth: Math.max(0, Number(block && block.depth ? block.depth : 0) || 0),
          text: typeof (block && block.text) === "string" ? block.text : "",
          headingLevel:
            Number(block && block.headingLevel ? block.headingLevel : 0) > 0
              ? Number(block.headingLevel)
              : null,
          checked: typeof (block && block.checked) === "boolean" ? block.checked : null,
          collapsed: Boolean(block && block.collapsed),
          editable: Boolean(block && block.editable),
          rawType: String(block && block.rawType ? block.rawType : ""),
          language: typeof (block && block.language) === "string" && block.language.trim() ? block.language.trim() : null,
          rawBlock:
            block && typeof block.rawBlock === "object" && block.rawBlock !== null ? block.rawBlock : null,
        });
        const blocks = Array.isArray(state.blocks) ? state.blocks.map(normalizeBlock) : [];
        const uiState = {
          focusedId: typeof state.initialFocusId === "string" ? state.initialFocusId : null,
          hoveredId: null,
          selection: null,
          slashMenuOpen: false,
          pendingFocus: null,
          composingId: null,
          didBootLog: false,
        };
        const MAX_INDENT = 3;
        const saveState = {
          dirty: false,
          saving: false,
          timer: 0,
          lastSerialized: "",
        };

        const log = (message) => {
          if (!eventLogElement) return;
          const previous = String(eventLogElement.textContent || "").trim();
          eventLogElement.textContent = previous ? previous + "\n" + message : message;
        };

        const focusedBlock = () => blocks.find((item) => item && item.id === uiState.focusedId) || null;
        const escapeSelector = (value) => {
          if (window.CSS && typeof window.CSS.escape === "function") {
            return window.CSS.escape(value);
          }
          return String(value).replace(/["\\]/g, "\\$&");
        };
        const getTextarea = (blockId) => {
          const selector = `textarea[data-block-input-id="${escapeSelector(blockId)}"]`;
          return listElement.querySelector(selector);
        };
        const visibleBlocks = () => {
          const items = [];
          let hiddenDepth = null;
          for (const block of blocks) {
            if (hiddenDepth !== null) {
              if (Number(block.depth || 0) > hiddenDepth) {
                continue;
              }
              hiddenDepth = null;
            }
            items.push(block);
            if (block.blockType === "heading" && block.collapsed) {
              hiddenDepth = Number(block.depth || 0);
            }
          }
          return items;
        };
        const blockTypeLabel = (block) => {
          switch (block.blockType) {
            case "heading":
              return `Heading${block.headingLevel ? ` h${block.headingLevel}` : ""}`;
            case "todo":
              return "Todo";
            case "bullet_list_item":
              return "Bullet";
            case "numbered_list_item":
              return "Numbered";
            case "quote":
              return "Quote";
            case "code_block":
              return "Code";
            case "divider":
              return "Divider";
            case "page_reference":
              return "[[page]]";
            case "block_reference":
              return "((block))";
            case "media_placeholder":
              return "Media";
            case "progress_placeholder":
              return "Progress";
            default:
              return "Paragraph";
          }
        };
        const runtimeMeta = (block) => {
          const tokens = [
            (block.rawType || block.blockType || "paragraph"),
            `depth=${Number(block.depth || 0)}`,
            `editable=${block.editable === true ? "true" : "false"}`,
          ];
          if (block.headingLevel) tokens.push(`h${block.headingLevel}`);
          if (typeof block.checked === "boolean") tokens.push(`checked=${block.checked ? "true" : "false"}`);
          if (block.collapsed) tokens.push("collapsed=true");
          return tokens.join(" · ");
        };
        const displayTitle = (block) => {
          const text = String(block.text || "").trim();
          if (text) return text;
          return blockTypeLabel(block);
        };

        const focusBlock = (blockId, position) => {
          if (!blockId) return;
          window.requestAnimationFrame(() => {
            const textarea = getTextarea(blockId);
            if (!(textarea instanceof HTMLTextAreaElement)) return;
            textarea.focus();
            const valueLength = textarea.value.length;
            if (position === "start") {
              textarea.setSelectionRange(0, 0);
            } else if (position === "end") {
              textarea.setSelectionRange(valueLength, valueLength);
            } else if (position && typeof position.start === "number" && typeof position.end === "number") {
              textarea.setSelectionRange(position.start, position.end);
            }
          });
        };

        const updateSaveStatus = (label) => {
          if (saveStatusElement) {
            saveStatusElement.textContent = label;
          }
        };

        const syncToolbarState = () => {
          const current = focusedBlock();
          const isHeading = current && current.blockType === "heading";
          const canEditCurrent = Boolean(current && current.editable && !READ_ONLY);
          if (toggleHeadingButton) toggleHeadingButton.disabled = !canEditCurrent || !isHeading;
          if (indentButton) indentButton.disabled = !canEditCurrent || Number(current.depth || 0) >= MAX_INDENT;
          if (outdentButton) outdentButton.disabled = !canEditCurrent || Number(current.depth || 0) <= 0;
          if (pageRefButton) pageRefButton.disabled = !canEditCurrent;
          if (blockRefButton) blockRefButton.disabled = !canEditCurrent;
          if (slashButton) slashButton.disabled = !canEditCurrent;
          if (slashMenuElement) slashMenuElement.hidden = !uiState.slashMenuOpen;
        };

        const updateSummary = () => {
          const current = focusedBlock();
          if (focusElement) focusElement.textContent = uiState.focusedId || "-";
          if (selectionElement) {
            if (uiState.selection && uiState.selection.blockId) {
              selectionElement.textContent =
                `${uiState.selection.blockId}:${uiState.selection.start}-${uiState.selection.end}`;
            } else {
              selectionElement.textContent = "-";
            }
          }
          if (hoverElement) hoverElement.textContent = uiState.hoveredId || "-";
          if (blockCountElement) blockCountElement.textContent = String(visibleBlocks().length);
          if (currentTitleElement) {
            currentTitleElement.textContent = current ? displayTitle(current) : "尚未聚焦";
          }
          if (currentMetaElement) {
            currentMetaElement.textContent = current ? runtimeMeta(current) : "等待交互";
          }
          syncToolbarState();
        };

        const applyRowState = () => {
          listElement.querySelectorAll("[data-block-id]").forEach((node) => {
            const id = node.getAttribute("data-block-id") || "";
            node.setAttribute("data-focused", String(id === uiState.focusedId));
            node.setAttribute(
              "data-selected",
              String(Boolean(uiState.selection && uiState.selection.blockId === id)),
            );
            node.setAttribute("data-hovered", String(id === uiState.hoveredId));
            node.setAttribute(
              "data-active",
              String(id === uiState.focusedId || Boolean(uiState.selection && uiState.selection.blockId === id)),
            );
            const block = blocks.find((item) => item && item.id === id) || null;
            node.setAttribute("data-collapsed", String(Boolean(block && block.collapsed)));
          });
          updateSummary();
        };

        const setFocused = (id, selection) => {
          uiState.focusedId = id;
          if (selection) {
            uiState.selection = selection;
          } else if (!uiState.selection || uiState.selection.blockId !== id) {
            uiState.selection = { blockId: id, start: 0, end: 0 };
          }
          applyRowState();
          log("human_editor.focus:" + id);
        };

        const setHovered = (id) => {
          uiState.hoveredId = id;
          applyRowState();
          if (id) log("human_editor.hover:" + id);
        };

        const updateSelectionFromTextarea = (blockId, textarea) => {
          if (!(textarea instanceof HTMLTextAreaElement)) return;
          uiState.selection = {
            blockId,
            start: Number(textarea.selectionStart || 0),
            end: Number(textarea.selectionEnd || 0),
          };
          applyRowState();
        };

        const buildTransformSnapshot = () => ({
          blocks: blocks.map((block) => ({
            id: block.id,
            blockType: block.blockType,
            parentId: block.parentId,
            depth: block.depth,
            text: block.text,
            headingLevel: block.headingLevel,
            checked: block.checked,
            collapsed: block.collapsed,
            editable: block.editable,
            rawType: block.rawType,
            language: block.language,
          })),
        });

        const applyTransformedBlocks = (nextBlocks) => {
          const rawById = new Map(blocks.map((block) => [block.id, block.rawBlock]));
          const rawTypeById = new Map(blocks.map((block) => [block.id, block.rawType]));
          blocks.splice(
            0,
            blocks.length,
            ...nextBlocks.map((block) => {
              const normalized = normalizeBlock(block);
              normalized.rawBlock = rawById.get(normalized.id) || null;
              if (!normalized.rawType) {
                normalized.rawType = rawTypeById.get(normalized.id) || normalized.rawType;
              }
              return normalized;
            }),
          );
        };

        const buildTextNodes = (text) =>
          text ? [{ type: "text", text }] : [];

        const materializeBlock = (block) => {
          const raw =
            block.rawBlock && typeof block.rawBlock === "object" && !Array.isArray(block.rawBlock)
              ? cloneJson(block.rawBlock) || {}
              : {};
          raw.id = block.id;
          raw.type =
            block.blockType === "paragraph"
              ? "paragraph"
              : block.blockType === "heading"
                ? "heading"
                : block.blockType === "bullet_list_item"
                  ? "bulletListItem"
                  : block.blockType === "numbered_list_item"
                    ? "numberedListItem"
                    : block.blockType === "todo"
                      ? (block.rawType || "checkListItem")
                      : block.blockType === "quote"
                        ? "quote"
                        : block.blockType === "divider"
                          ? "divider"
                          : block.blockType === "code_block"
                            ? "codeBlock"
                            : block.blockType === "page_reference"
                              ? "pageReference"
                              : block.blockType === "block_reference"
                                ? "blockReference"
                                : (block.rawType || "media");
          raw.props =
            raw.props && typeof raw.props === "object" && !Array.isArray(raw.props) ? raw.props : {};
          raw.children = [];
          if (raw.type === "heading") {
            raw.props.level = Number(block.headingLevel || 1);
            raw.props.collapsed = Boolean(block.collapsed);
          }
          if (raw.type === "checkListItem" || raw.type === "advancedTodo") {
            raw.props.checked = Boolean(block.checked);
          }
          if (raw.type === "codeBlock" && block.language) {
            raw.props.language = block.language;
          }
          if (block.editable !== false) {
            raw.content = raw.type === "divider" ? [] : buildTextNodes(String(block.text || ""));
          }
          return raw;
        };

        const buildDocumentPayload = () => {
          const roots = [];
          const stack = [];
          blocks.forEach((block) => {
            const node = materializeBlock(block);
            while (stack.length > 0 && stack[stack.length - 1].depth >= block.depth) {
              stack.pop();
            }
            if (block.depth > 0 && stack.length > 0) {
              stack[stack.length - 1].node.children.push(node);
            } else {
              roots.push(node);
            }
            stack.push({ depth: block.depth, node });
          });
          if (contentWrapper) {
            const wrapper = cloneJson(contentWrapper) || {};
            wrapper.blocks = roots;
            return wrapper;
          }
          return roots;
        };

        const queueSave = (reason, immediate) => {
          if (READ_ONLY) return;
          saveState.dirty = true;
          if (saveState.timer) {
            window.clearTimeout(saveState.timer);
            saveState.timer = 0;
          }
          if (immediate) {
            void saveNow(reason);
            return;
          }
          updateSaveStatus("dirty");
          saveState.timer = window.setTimeout(() => {
            saveState.timer = 0;
            void saveNow(reason);
          }, 450);
        };

        const saveNow = async (reason) => {
          if (READ_ONLY) return;
          const content = buildDocumentPayload();
          const serialized = JSON.stringify(content);
          if (!saveState.dirty && serialized === saveState.lastSerialized) {
            return;
          }
          saveState.saving = true;
          updateSaveStatus("saving");
          try {
            const response = await fetch("/api/documents/save", {
              method: "POST",
              headers: { "content-type": "application/json" },
              body: JSON.stringify({
                documentId: state.documentId,
                workspaceId: state.workspaceId || null,
                revision: meta.revision,
                conflictDetectionKey: meta.conflictDetectionKey,
                content,
                blockCount: blocks.length,
              }),
            });
            const payload = await response.json().catch(() => null);
            if (!response.ok || !payload || payload.ok !== true) {
              const message =
                (payload && payload.error && payload.error.message) ||
                (payload && payload.message) ||
                `save_failed_${response.status}`;
              throw new Error(message);
            }
            const result = payload.result || {};
            meta.revision =
              typeof result.revision === "number" && Number.isInteger(result.revision)
                ? result.revision
                : meta.revision;
            meta.conflictDetectionKey =
              typeof result.conflict_detection_key === "string" && result.conflict_detection_key.trim()
                ? result.conflict_detection_key.trim()
                : typeof result.conflictDetectionKey === "string" && result.conflictDetectionKey.trim()
                  ? result.conflictDetectionKey.trim()
                  : meta.conflictDetectionKey;
            if (revisionElement) {
              revisionElement.textContent = String(meta.revision);
            }
            saveState.lastSerialized = serialized;
            saveState.dirty = false;
            updateSaveStatus("saved");
            log("human_editor.save:" + reason);
          } catch (error) {
            updateSaveStatus("error");
            log(
              "human_editor.save.error:" +
                (error instanceof Error ? error.message : String(error)),
            );
          } finally {
            saveState.saving = false;
          }
        };

        const applyRuntimeCommand = async (command, focusOptions) => {
          if (READ_ONLY) return;
          const response = await fetch("/api/documents/runtime/transform", {
            method: "POST",
            headers: { "content-type": "application/json" },
            body: JSON.stringify({
              documentId: state.documentId,
              workspaceId: state.workspaceId || null,
              snapshot: buildTransformSnapshot(),
              command,
            }),
          });
          const payload = await response.json().catch(() => null);
          if (!response.ok || !payload || payload.ok !== true) {
            const message =
              (payload && payload.error && payload.error.message) ||
              (payload && payload.message) ||
              `transform_failed_${response.status}`;
            throw new Error(message);
          }
          const nextBlocks = Array.isArray(payload.result && payload.result.blocks)
            ? payload.result.blocks
            : [];
          applyTransformedBlocks(nextBlocks);
          uiState.slashMenuOpen = false;
          render();
          applyRowState();
          if (focusOptions && focusOptions.blockId) {
            focusBlock(focusOptions.blockId, focusOptions.position || "end");
          }
          queueSave(focusOptions && focusOptions.reason ? focusOptions.reason : "transform", true);
        };

        const toggleHeadingCollapse = async () => {
          const current = focusedBlock();
          if (!current || current.blockType !== "heading" || READ_ONLY) return;
          log("human_editor.toggle_heading_collapse:" + current.id);
          await applyRuntimeCommand(
            { action: "toggle_heading_collapse", block_id: current.id },
            { blockId: current.id, position: "end", reason: "toggle_heading_collapse" },
          );
        };

        const adjustIndent = async (delta) => {
          const current = focusedBlock();
          if (!current || READ_ONLY) return;
          const action = delta > 0 ? "indent_block" : "outdent_block";
          log("human_editor." + action + ":" + current.id);
          await applyRuntimeCommand(
            { action, block_id: current.id },
            { blockId: current.id, position: "end", reason: action },
          );
        };

        const insertReferenceToken = (kind) => {
          const current = focusedBlock();
          if (!current || !current.editable || READ_ONLY) return;
          const textarea = getTextarea(current.id);
          if (!(textarea instanceof HTMLTextAreaElement)) return;
          const token = kind === "page" ? "[[page]]" : "((block))";
          const start = Number(textarea.selectionStart || 0);
          const end = Number(textarea.selectionEnd || start);
          current.text = current.text.slice(0, start) + token + current.text.slice(end);
          textarea.value = current.text;
          const nextCursor = start + token.length;
          textarea.setSelectionRange(nextCursor, nextCursor);
          updateSelectionFromTextarea(current.id, textarea);
          updateSummary();
          log("human_editor.reference:" + kind + ":" + current.id);
          queueSave("reference", false);
        };

        const applySlashAction = async (action) => {
          const current = focusedBlock();
          if (!current || !current.editable || READ_ONLY) return;
          const blockType = action === "heading" ? "heading" : action === "todo" ? "todo" : "paragraph";
          log("human_editor.slash:" + action + ":" + current.id);
          await applyRuntimeCommand(
            { action: "set_block_type", block_id: current.id, block_type: blockType },
            { blockId: current.id, position: "end", reason: "slash" },
          );
        };

        const render = () => {
          const currentBlocks = visibleBlocks();
          listElement.innerHTML = "";
          if (currentBlocks.length === 0) {
            emptyElement.hidden = false;
            updateSummary();
            log("block_editor_shell.empty");
            return;
          }
          emptyElement.hidden = true;

          currentBlocks.forEach((block, index) => {
            const row = document.createElement("li");
            row.className = "editor-row";
            row.setAttribute("data-block-id", String(block.id || ""));
            row.style.setProperty("--depth-indent", String(Number(block.depth || 0) * 18) + "px");

            const badge = document.createElement("span");
            badge.className = "editor-badge";
            badge.textContent = String(index + 1);

            const body = document.createElement("div");
            body.className = "editor-link";
            const title = document.createElement("span");
            title.className = "editor-row-title";
            title.textContent = blockTypeLabel(block);
            const meta = document.createElement("span");
            meta.className = "editor-row-meta";
            meta.textContent = runtimeMeta(block);
            const textarea = document.createElement("textarea");
            textarea.className = "editor-input";
            textarea.setAttribute("data-block-input-id", String(block.id || ""));
            textarea.value = String(block.text || "");
            textarea.placeholder = displayTitle(block);
            textarea.readOnly = READ_ONLY || block.editable !== true;
            textarea.rows = Math.max(1, String(block.text || "").split("\n").length || 1);
            body.appendChild(title);
            body.appendChild(meta);
            body.appendChild(textarea);

            const tag = document.createElement("span");
            tag.className = "editor-state-tag";
            tag.textContent = [
              block.id ? "block:" + String(block.id) : "node",
              block.blockType ? "type:" + String(block.blockType) : null,
            ]
              .filter(Boolean)
              .join(" · ");

            row.appendChild(badge);
            row.appendChild(body);
            row.appendChild(tag);

            const id = String(block.id || "");
            row.addEventListener("mouseenter", () => setHovered(id));
            row.addEventListener("mouseleave", () => setHovered(null));
            row.addEventListener("click", () => {
              if (textarea.readOnly) {
                setFocused(id);
              }
            });
            textarea.addEventListener("focus", () => {
              setFocused(id, {
                blockId: id,
                start: Number(textarea.selectionStart || 0),
                end: Number(textarea.selectionEnd || 0),
              });
            });
            textarea.addEventListener("select", () => updateSelectionFromTextarea(id, textarea));
            textarea.addEventListener("click", () => updateSelectionFromTextarea(id, textarea));
            textarea.addEventListener("keyup", () => updateSelectionFromTextarea(id, textarea));
            textarea.addEventListener("beforeinput", (event) => {
              updateSelectionFromTextarea(id, textarea);
              log(`human_editor.beforeinput:${id}:${event.inputType || "unknown"}`);
            });
            textarea.addEventListener("compositionstart", () => {
              uiState.composingId = id;
              log("human_editor.compositionstart:" + id);
            });
            textarea.addEventListener("compositionend", () => {
              uiState.composingId = null;
              updateSelectionFromTextarea(id, textarea);
              log("human_editor.compositionend:" + id);
            });
            textarea.addEventListener("input", () => {
              block.text = textarea.value;
              textarea.rows = Math.max(1, textarea.value.split("\n").length || 1);
              updateSelectionFromTextarea(id, textarea);
              updateSummary();
              queueSave("input", false);
            });
            textarea.addEventListener("keydown", async (event) => {
              if (READ_ONLY || textarea.readOnly) {
                return;
              }
              if (event.key === "Tab") {
                event.preventDefault();
                try {
                  await adjustIndent(event.shiftKey ? -1 : 1);
                } catch (error) {
                  log("human_editor.transform.error:" + (error instanceof Error ? error.message : String(error)));
                }
                return;
              }
              if (event.key === "Enter" && !event.shiftKey && !event.metaKey && !event.ctrlKey) {
                event.preventDefault();
                const offset = Number(textarea.selectionStart || 0);
                const newBlockId = `${id}_split_${Date.now().toString(36)}`;
                log("human_editor.split:" + id);
                try {
                  await applyRuntimeCommand(
                    {
                      action: "split_block",
                      block_id: id,
                      offset,
                      new_block_id: newBlockId,
                    },
                    { blockId: newBlockId, position: "start", reason: "split" },
                  );
                } catch (error) {
                  log("human_editor.transform.error:" + (error instanceof Error ? error.message : String(error)));
                }
                return;
              }
              if (event.key === "Backspace") {
                const start = Number(textarea.selectionStart || 0);
                const end = Number(textarea.selectionEnd || 0);
                if (start === 0 && end === 0) {
                  const currentIndex = visibleBlocks().findIndex((item) => item.id === id);
                  const previous = currentIndex > 0 ? visibleBlocks()[currentIndex - 1] : null;
                  if (previous) {
                    event.preventDefault();
                    log("human_editor.merge:" + id);
                    try {
                      await applyRuntimeCommand(
                        { action: "merge_with_previous", block_id: id },
                        { blockId: previous.id, position: "end", reason: "merge" },
                      );
                    } catch (error) {
                      log(
                        "human_editor.transform.error:" +
                          (error instanceof Error ? error.message : String(error)),
                      );
                    }
                  }
                }
              }
            });

            listElement.appendChild(row);
          });

          updateSummary();
          if (!uiState.didBootLog) {
            log("human_editor_runtime.ready");
            log("human_editor_input.beforeinput");
            log("human_editor_input.composition");
            log("human_editor_transactions.boundary");
            uiState.didBootLog = true;
          }
        };

        if (slashButton) {
          slashButton.addEventListener("click", () => {
            uiState.slashMenuOpen = !uiState.slashMenuOpen;
            syncToolbarState();
            log("human_editor.slash_menu:" + (uiState.slashMenuOpen ? "open" : "close"));
          });
        }
        if (toggleHeadingButton) {
          toggleHeadingButton.addEventListener("click", () => {
            void toggleHeadingCollapse().catch((error) => {
              log("human_editor.transform.error:" + (error instanceof Error ? error.message : String(error)));
            });
          });
        }
        if (indentButton) {
          indentButton.addEventListener("click", () => {
            void adjustIndent(1).catch((error) => {
              log("human_editor.transform.error:" + (error instanceof Error ? error.message : String(error)));
            });
          });
        }
        if (outdentButton) {
          outdentButton.addEventListener("click", () => {
            void adjustIndent(-1).catch((error) => {
              log("human_editor.transform.error:" + (error instanceof Error ? error.message : String(error)));
            });
          });
        }
        if (pageRefButton) {
          pageRefButton.addEventListener("click", () => insertReferenceToken("page"));
        }
        if (blockRefButton) {
          blockRefButton.addEventListener("click", () => insertReferenceToken("block"));
        }
        if (slashMenuElement) {
          slashMenuElement.querySelectorAll("[data-slash-action]").forEach((node) => {
            node.addEventListener("click", async () => {
              const action = node.getAttribute("data-slash-action");
              if (action) {
                try {
                  await applySlashAction(action);
                } catch (error) {
                  log("human_editor.transform.error:" + (error instanceof Error ? error.message : String(error)));
                }
              }
            });
          });
        }

        render();
        applyRowState();
        updateSaveStatus(READ_ONLY ? "read-only" : "idle");
        if (uiState.focusedId) {
          focusBlock(uiState.focusedId, "end");
        }
      })();
    </script>
  </body>
</html>"##;

    template
        .replace("__PAGE_TITLE__", &escape_html(&page_title))
        .replace(
            "__WORKSPACE_ID__",
            &escape_html(workspace_id.unwrap_or("未指定")),
        )
        .replace("__DOCUMENT_ID__", &escape_html(document_id))
        .replace("__HOST__", &escape_html(host.unwrap_or("mnote-web")))
        .replace("__READ_ONLY__", if read_only { "true" } else { "false" })
        .replace("__REVISION__", &escape_html(&revision.to_string()))
        .replace("__UPDATED_AT__", &escape_html(&updated_at))
        .replace("__APP_STATE__", &escape_inline_json(&app_state_json))
}

pub async fn document_editor_shell(
    State(state): State<AppState>,
    Extension(context): Extension<RequestContext>,
    Query(query): Query<DocumentEditorShellQuery>,
) -> Result<Response, WebError> {
    let (_, meta_json) = document_meta(
        State(state.clone()),
        Extension(context.clone()),
        Query(DocumentMetaQuery {
            document_id: query.document_id.clone(),
            workspace_id: query.workspace_id.clone(),
        }),
    )
    .await?;

    let (_, content_json) = document_content(
        State(state),
        Extension(context),
        Query(DocumentContentQuery {
            document_id: query.document_id.clone(),
            workspace_id: query.workspace_id.clone(),
        }),
    )
    .await?;

    let meta_result = meta_json.0.get("result").cloned().unwrap_or(Value::Null);
    let content_result = content_json.0.get("result").cloned().unwrap_or(Value::Null);
    let html = build_document_editor_shell_html(
        query.workspace_id.as_deref(),
        &query.document_id,
        query.host.as_deref(),
        &meta_result,
        &content_result,
    );

    let mut response = Html(html).into_response();
    response.headers_mut().insert(
        header::CONTENT_TYPE,
        HeaderValue::from_static("text/html; charset=utf-8"),
    );
    response
        .headers_mut()
        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
    response.headers_mut().insert(
        "x-mnote-shell",
        HeaderValue::from_static("document-runtime-debug"),
    );
    Ok(response)
}

pub async fn transform_runtime_snapshot(
    Extension(context): Extension<RequestContext>,
    Json(body): Json<RuntimeTransformRequest>,
) -> Result<(StatusCode, Json<Value>), WebError> {
    let document_id = body.document_id.trim();
    if document_id.is_empty() {
        return Err(
            WebError::bad_request_code("document_id_required", "缺少有效 documentId")
                .with_context(&context),
        );
    }
    let mut session = EditorSession::new(runtime_snapshot_to_document(&body.snapshot.blocks));
    let command =
        runtime_editor_command(body.command).map_err(|error| error.with_context(&context))?;
    session
        .apply_command(command)
        .map_err(|error| WebError::bad_request(error.to_string()).with_context(&context))?;
    let blocks = runtime_snapshot_from_document(session.document(), &body.snapshot.blocks);
    Ok((
        StatusCode::OK,
        Json(json!({
            "ok": true,
            "requestId": context.trace.request_id,
            "traceId": context.trace.trace_id,
            "result": {
                "documentId": document_id,
                "workspaceId": body.workspace_id,
                "blocks": blocks,
            },
        })),
    ))
}

#[cfg(test)]
mod tests {
    use crate::app::{build_app, AppConfig, AppState};
    use axum::body::{to_bytes, Body};
    use axum::http::{Request, StatusCode};
    use serde_json::{json, Value};
    use tower::util::ServiceExt;

    fn app() -> axum::Router {
        build_app(AppState::new(AppConfig {
            service_name: "mnote-web".into(),
            service_version: "0.1.0".into(),
            bind_addr: "127.0.0.1:0".into(),
            enable_debug_shell_routes: true,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: None,
            convex_admin_key: None,
            allow_dev_fixtures: true,
            query_fixtures_json: Some(
                r#"{
                  "documents:getMeta": {
                    "id": "doc_shell",
                    "workspace_id": "ws_demo",
                    "title": "编辑器壳页面",
                    "updated_at": "2026-04-18T11:22:33Z",
                    "can_edit": true,
                    "show_structure": true
                  },
                  "documents:getContent": {
                    "title": "编辑器壳页面",
                    "content": [
                      {
                        "id": "heading_1",
                        "type": "heading",
                        "props": { "level": 1 },
                        "content": [{ "type": "text", "text": "第一节" }]
                      }
                    ],
                    "revision": 9,
                    "conflict_detection_key": "doc_shell:9",
                    "page_subtree": {
                      "projection_id": "kernel_projection:page_tree:doc_shell",
                      "projection": "page_tree",
                      "root_node_id": "doc_shell",
                      "root_node": {
                        "id": "doc_shell",
                        "parent_node_id": null,
                        "node_type": "page",
                        "block_id": null,
                        "anchor_block_id": null,
                        "depth": 0,
                        "metadata": {
                          "title": "编辑器壳页面",
                          "text_snippet": "页面首段",
                          "block_type": null,
                          "heading_level": null,
                          "numbering": null,
                          "child_count": 2,
                          "order": 0,
                          "path": ["编辑器壳页面"]
                        }
                      },
                      "subtree": {
                        "root_node_id": "doc_shell",
                        "nodes": [
                          {
                            "id": "doc_shell",
                            "parent_node_id": null,
                            "node_type": "page",
                            "block_id": null,
                            "anchor_block_id": null,
                            "depth": 0,
                            "metadata": {
                              "title": "编辑器壳页面",
                              "text_snippet": "页面首段",
                              "block_type": null,
                              "heading_level": null,
                              "numbering": null,
                              "child_count": 2,
                              "order": 0,
                              "path": ["编辑器壳页面"]
                            }
                          },
                          {
                            "id": "node_heading_1",
                            "parent_node_id": "doc_shell",
                            "node_type": "section",
                            "block_id": "heading_1",
                            "anchor_block_id": "heading_1",
                            "depth": 1,
                            "metadata": {
                              "title": "第一节",
                              "text_snippet": "第一节 页面首段",
                              "block_type": "heading",
                              "heading_level": 1,
                              "numbering": "1",
                              "child_count": 1,
                              "order": 0,
                              "path": ["编辑器壳页面", "第一节"]
                            }
                          },
                          {
                            "id": "node_para_1",
                            "parent_node_id": "node_heading_1",
                            "node_type": "content_node",
                            "block_id": "paragraph_1",
                            "anchor_block_id": null,
                            "depth": 2,
                            "metadata": {
                              "title": null,
                              "text_snippet": "这是正文第一段",
                              "block_type": "paragraph",
                              "heading_level": null,
                              "numbering": null,
                              "child_count": 0,
                              "order": 1,
                              "path": ["编辑器壳页面", "第一节", "这是正文第一段"]
                            }
                          }
                        ]
                      },
                      "outline": [
                        {
                          "id": "outline_heading_1",
                          "node_id": "node_heading_1",
                          "anchor_block_id": "heading_1",
                          "title": "第一节",
                          "level": 1,
                          "numbering": "1"
                        }
                      ],
                      "evidence": [],
                      "stats": {
                        "block_count": 3,
                        "heading_count": 1,
                        "evidence_count": 0,
                        "max_depth": 2
                      }
                    }
                  }
                }"#
                .into(),
            ),
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        }))
    }

    #[tokio::test]
    async fn block_editor_shell_returns_interactive_html_document() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/document-debug?documentId=doc_shell&workspaceId=ws_demo&host=next-document-page")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::OK);
        let content_type = response
            .headers()
            .get("content-type")
            .and_then(|value| value.to_str().ok())
            .unwrap_or_default();
        assert!(content_type.contains("text/html"));
        assert_eq!(
            response
                .headers()
                .get("x-mnote-shell")
                .and_then(|value| value.to_str().ok()),
            Some("document-runtime-debug")
        );

        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains("block-editor-shell-state"));
        assert!(html.contains("block_editor_shell.ready"));
        assert!(html.contains("human_editor.focus:"));
        assert!(html.contains("data-block-id"));
        assert!(html.contains("editor-row"));
        assert!(html.contains("next-document-page"));
        assert!(html.contains("focus / selection / hover"));
        assert!(html.contains("\"blocks\":["));
        assert!(html.contains("第一节"));
        assert!(html.contains("\"id\":\"heading_1\""));
        assert!(html.contains("Document Runtime Debug"));
    }

    #[tokio::test]
    async fn editor_interactions_shell_exposes_slash_reference_and_indent_controls() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/document-debug?documentId=doc_shell&workspaceId=ws_demo&host=next-document-page")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains("editor-command-slash"));
        assert!(html.contains("editor-command-indent"));
        assert!(html.contains("editor-command-outdent"));
        assert!(html.contains("[[page]]"));
        assert!(html.contains("((block))"));
        assert!(html.contains("human_editor.slash:"));
        assert!(html.contains("human_editor.reference:"));
        assert!(html.contains("\"indent_block\""));
        assert!(html.contains("\"outdent_block\""));
    }

    #[tokio::test]
    async fn editor_interactions_shell_exposes_heading_collapse_hooks() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/document-debug?documentId=doc_shell&workspaceId=ws_demo")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains("editor-command-toggle-heading"));
        assert!(html.contains("human_editor.toggle_heading_collapse:"));
        assert!(html.contains("\"collapsed\":false"));
        assert!(html.contains("\"headingLevel\":1"));
        assert!(html.contains("\"blockType\":\"heading\""));
    }

    #[tokio::test]
    async fn human_editor_runtime_shell_includes_real_input_and_save_markers() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/document-debug?documentId=doc_shell&workspaceId=ws_demo")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains("editor-save-status"));
        assert!(html.contains("/api/documents/save"));
        assert!(html.contains("editor-input"));
        assert!(html.contains("human_editor_runtime.ready"));
    }

    #[tokio::test]
    async fn human_editor_input_shell_wires_beforeinput_and_composition_events() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/document-debug?documentId=doc_shell&workspaceId=ws_demo")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains("human_editor_input.beforeinput"));
        assert!(html.contains("human_editor_input.composition"));
        assert!(html.contains("selectionStart"));
        assert!(html.contains("compositionstart"));
    }

    #[tokio::test]
    async fn human_editor_transactions_transform_route_applies_split_command() {
        let response = app()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/api/documents/runtime/transform")
                    .header("content-type", "application/json")
                    .body(Body::from(
                        json!({
                            "documentId": "doc_shell",
                            "workspaceId": "ws_demo",
                            "snapshot": {
                                "blocks": [
                                    {
                                        "id": "block_a",
                                        "blockType": "paragraph",
                                        "parentId": null,
                                        "depth": 0,
                                        "text": "AlphaBeta",
                                        "headingLevel": null,
                                        "checked": null,
                                        "collapsed": false,
                                        "editable": true,
                                        "rawType": "paragraph",
                                        "language": null
                                    }
                                ]
                            },
                            "command": {
                                "action": "split_block",
                                "block_id": "block_a",
                                "offset": 5,
                                "new_block_id": "block_b"
                            }
                        })
                        .to_string(),
                    ))
                    .expect("request"),
            )
            .await
            .expect("response");

        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        let blocks = payload["result"]["blocks"].as_array().expect("blocks");
        assert_eq!(blocks.len(), 2);
        assert_eq!(blocks[0]["text"], "Alpha");
        assert_eq!(blocks[1]["text"], "Beta");
    }

    #[tokio::test]
    async fn human_editor_commands_shell_exposes_slash_and_reference_runtime_controls() {
        let response = app()
            .oneshot(
                Request::builder()
                    .uri("/document-debug?documentId=doc_shell&workspaceId=ws_demo")
                    .body(Body::empty())
                    .expect("request"),
            )
            .await
            .expect("response");

        let body = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("utf8");
        assert!(html.contains("human_editor.slash_menu:"));
        assert!(html.contains("human_editor.reference:"));
        assert!(html.contains("[[page]]"));
        assert!(html.contains("((block))"));
        assert!(html.contains("set_block_type"));
    }
}
