use axum::extract::{Path, Query};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::time::{sleep, Duration, Instant};
use uuid::Uuid;

pub const MNOTE_ONLYOFFICE_BRIDGE_GUID: &str = "asc.{05F87DDF-7B42-4C6F-9F2B-9C77A8D5F4E2}";
const MAX_LONG_POLL_MS: u64 = 25_000;
const LONG_POLL_INTERVAL_MS: u64 = 250;

static BRIDGE_COMMAND_COUNTER: AtomicU64 = AtomicU64::new(1);
static BRIDGE_STATE: OnceLock<Mutex<HashMap<String, BridgeSessionState>>> = OnceLock::new();

#[derive(Debug, Default)]
struct BridgeSessionState {
    queue: VecDeque<BridgeCommandWire>,
    results: HashMap<String, BridgeResultWire>,
    token: Option<String>,
    editor_type: Option<String>,
    document_id: Option<String>,
    asset_id: Option<String>,
    file_type: Option<String>,
    doc_key: Option<String>,
    page_origin: Option<String>,
    last_seen_millis: u128,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeCommandWire {
    pub id: String,
    pub action: String,
    #[serde(default)]
    pub payload: Value,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeResultWire {
    pub id: String,
    pub ok: bool,
    #[serde(default)]
    pub result: Value,
    #[serde(default)]
    pub error: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeSessionInfo {
    pub session_id: String,
    pub editor_type: Option<String>,
    pub document_id: Option<String>,
    pub asset_id: Option<String>,
    pub file_type: Option<String>,
    pub doc_key: Option<String>,
    pub page_origin: Option<String>,
    pub last_seen_millis: u128,
    pub pending_commands: usize,
    pub pending_results: usize,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgePluginConfigQuery {
    session_id: Option<String>,
    api_base: Option<String>,
    token: Option<String>,
    document_id: Option<String>,
    asset_id: Option<String>,
    file_type: Option<String>,
    doc_key: Option<String>,
    page_origin: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgePluginIndexQuery {
    session_id: Option<String>,
    api_base: Option<String>,
    token: Option<String>,
    document_id: Option<String>,
    asset_id: Option<String>,
    file_type: Option<String>,
    doc_key: Option<String>,
    page_origin: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BridgePluginIndexState {
    session_id: String,
    api_base: String,
    token: String,
    #[serde(default)]
    document_id: Option<String>,
    #[serde(default)]
    asset_id: Option<String>,
    #[serde(default)]
    file_type: Option<String>,
    #[serde(default)]
    doc_key: Option<String>,
    #[serde(default)]
    page_origin: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeSessionPayload {
    pub session_id: String,
    #[serde(default)]
    pub token: Option<String>,
    #[serde(default)]
    pub editor_type: Option<String>,
    #[serde(default)]
    pub document_id: Option<String>,
    #[serde(default)]
    pub asset_id: Option<String>,
    #[serde(default)]
    pub file_type: Option<String>,
    #[serde(default)]
    pub doc_key: Option<String>,
    #[serde(default)]
    pub page_origin: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeCloseSessionPayload {
    pub session_id: String,
    #[serde(default)]
    pub token: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeEnqueuePayload {
    session_id: String,
    #[serde(default)]
    token: Option<String>,
    action: String,
    #[serde(default)]
    payload: Value,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeNextQuery {
    session_id: String,
    token: Option<String>,
    timeout_ms: Option<u64>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BridgeResultQuery {
    session_id: String,
    token: Option<String>,
    command_id: String,
    timeout_ms: Option<u64>,
}

pub async fn plugin_config(Query(query): Query<BridgePluginConfigQuery>) -> Response {
    let session_id = non_empty(query.session_id).unwrap_or_else(|| "mnote-onlyoffice".into());
    let api_base = normalize_api_base(query.api_base.as_deref());
    let token = non_empty(query.token).unwrap_or_else(new_bridge_token);
    let index_state = encode_index_state(
        &session_id,
        &api_base,
        &token,
        query.document_id.as_deref(),
        query.asset_id.as_deref(),
        query.file_type.as_deref(),
        query.doc_key.as_deref(),
        query.page_origin.as_deref(),
    );
    let index_url = format!("{api_base}/api/onlyoffice/bridge/plugin/index/{index_state}");
    let payload = json!({
        "name": "MNote Bridge",
        "guid": MNOTE_ONLYOFFICE_BRIDGE_GUID,
        "mnoteBridgeToken": token,
        "version": "0.1.0",
        "variations": [{
            "description": "MNote agent bridge for ONLYOFFICE documents, spreadsheets, and presentations.",
            "url": index_url,
            "EditorsSupport": ["word", "cell", "slide"],
            "isViewer": false,
            "isVisual": false,
            "isModal": false,
            "isInsideMode": false,
            "initDataType": "none",
            "initData": "",
            "buttons": []
        }]
    });
    json_response(payload)
}

pub async fn plugin_index(Query(query): Query<BridgePluginIndexQuery>) -> impl IntoResponse {
    let session_id = non_empty(query.session_id).unwrap_or_else(|| "mnote-onlyoffice".into());
    let api_base = normalize_api_base(query.api_base.as_deref());
    let token = non_empty(query.token).unwrap_or_else(new_bridge_token);
    render_plugin_index(
        &session_id,
        &api_base,
        &token,
        query.document_id.as_deref(),
        query.asset_id.as_deref(),
        query.file_type.as_deref(),
        query.doc_key.as_deref(),
        query.page_origin.as_deref(),
    )
}

pub async fn plugin_index_with_state(
    Path(encoded_state): Path<String>,
    Query(query): Query<BridgePluginIndexQuery>,
) -> impl IntoResponse {
    let state = decode_index_state(&encoded_state);
    let session_id = non_empty(query.session_id)
        .or_else(|| state.as_ref().map(|value| value.session_id.clone()))
        .unwrap_or_else(|| "mnote-onlyoffice".into());
    let api_base = normalize_api_base(query.api_base.as_deref().or_else(|| {
        state
            .as_ref()
            .map(|value| value.api_base.as_str())
            .filter(|value| !value.is_empty())
    }));
    let document_id = query.document_id.as_deref().or_else(|| {
        state
            .as_ref()
            .and_then(|value| value.document_id.as_deref())
    });
    let asset_id = query
        .asset_id
        .as_deref()
        .or_else(|| state.as_ref().and_then(|value| value.asset_id.as_deref()));
    let file_type = query
        .file_type
        .as_deref()
        .or_else(|| state.as_ref().and_then(|value| value.file_type.as_deref()));
    let doc_key = query
        .doc_key
        .as_deref()
        .or_else(|| state.as_ref().and_then(|value| value.doc_key.as_deref()));
    let page_origin = query.page_origin.as_deref().or_else(|| {
        state
            .as_ref()
            .and_then(|value| value.page_origin.as_deref())
    });
    let token = non_empty(query.token)
        .or_else(|| {
            state
                .as_ref()
                .map(|value| value.token.as_str())
                .filter(|value| !value.is_empty())
                .map(ToOwned::to_owned)
        })
        .unwrap_or_else(new_bridge_token);
    render_plugin_index(
        &session_id,
        &api_base,
        &token,
        document_id,
        asset_id,
        file_type,
        doc_key,
        page_origin,
    )
}

fn render_plugin_index(
    session_id: &str,
    api_base: &str,
    token: &str,
    document_id: Option<&str>,
    asset_id: Option<&str>,
    file_type: Option<&str>,
    doc_key: Option<&str>,
    page_origin: Option<&str>,
) -> Html<String> {
    let html = format!(
        r#"<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <script src="/onlyoffice-server/sdkjs-plugins/v1/plugins.js"></script>
</head>
<body>
<script>
(function () {{
  "use strict";
  var sessionId = {session_id};
  var apiBase = {api_base};
  var bridgeToken = {token};
  var sessionMeta = {{
    documentId: {document_id},
    assetId: {asset_id},
    fileType: {file_type},
    docKey: {doc_key},
    pageOrigin: {page_origin}
  }};
  var stopped = false;

  function postJson(path, body) {{
    return fetch(apiBase + path, {{
      method: "POST",
      headers: {{ "content-type": "application/json" }},
      body: JSON.stringify(body || {{}})
    }}).then(function (response) {{
      return response.json().catch(function () {{ return {{ ok: response.ok }}; }});
    }});
  }}

  function callMethod(name, args) {{
    return new Promise(function (resolve) {{
      window.Asc.plugin.executeMethod(name, args || [], function (value) {{
        resolve(value);
      }});
    }});
  }}

  function callCommand(scope, func) {{
    return new Promise(function (resolve) {{
      window.Asc.scope.mnoteBridge = scope || {{}};
      window.Asc.plugin.callCommand(func, false, true, function (value) {{
        resolve(value);
      }});
    }});
  }}

  async function callCommandJson(scope, func) {{
    var value = await callCommand(scope, func);
    if (typeof value === "string") return JSON.parse(value);
    return value;
  }}

  function numberOrDefault(value, fallback) {{
    var parsed = Number(value);
    return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : fallback;
  }}

  function columnIndexFromLetters(value) {{
    var letters = String(value || "").toUpperCase();
    var total = 0;
    for (var index = 0; index < letters.length; index += 1) {{
      total = total * 26 + (letters.charCodeAt(index) - 64);
    }}
    return Math.max(0, total - 1);
  }}

  function parseA1Range(address) {{
    var match = String(address || "").trim().match(/^([A-Za-z]+)([0-9]+)(?::([A-Za-z]+)([0-9]+))?$/);
    if (!match) throw new Error("invalid_a1_range:" + address);
    var startCol = columnIndexFromLetters(match[1]);
    var startRow = Math.max(0, Number(match[2]) - 1);
    var endCol = match[3] ? columnIndexFromLetters(match[3]) : startCol;
    var endRow = match[4] ? Math.max(0, Number(match[4]) - 1) : startRow;
    var minRow = Math.min(startRow, endRow);
    var maxRow = Math.max(startRow, endRow);
    var minCol = Math.min(startCol, endCol);
    var maxCol = Math.max(startCol, endCol);
    return {{
      address: String(address || "").trim(),
      startRow: minRow,
      startCol: minCol,
      rowCount: maxRow - minRow + 1,
      colCount: maxCol - minCol + 1
    }};
  }}

  function colorFromHex(value) {{
    var raw = String(value || "").trim();
    if (!raw) return null;
    var match = raw.match(/^#?([0-9a-fA-F]{{6}})$/);
    if (!match) throw new Error("invalid_hex_color:" + raw);
    var hex = match[1];
    return Api.CreateColorFromRGB(
      parseInt(hex.slice(0, 2), 16),
      parseInt(hex.slice(2, 4), 16),
      parseInt(hex.slice(4, 6), 16)
    );
  }}

  function finiteNumberOrNull(value) {{
    var parsed = Number(value);
    return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
  }}

  function expectedEditorTypeForAction(action) {{
    var name = String(action || "");
    if (name.indexOf("document.") === 0) return "word";
    if (name.indexOf("sheet.") === 0) return "cell";
    if (name.indexOf("presentation.") === 0) return "slide";
    return "";
  }}

  function assertEditorTypeForAction(action) {{
    var expected = expectedEditorTypeForAction(action);
    if (!expected) return;
    var actual = String(window.Asc.plugin.info && window.Asc.plugin.info.editorType || "");
    if (actual !== expected) {{
      throw new Error("onlyoffice_editor_type_mismatch:" + action + ":expected_" + expected + ":actual_" + (actual || "unknown"));
    }}
  }}

  async function executeCommand(command) {{
    var payload = command.payload || {{}};
    assertEditorTypeForAction(command.action);
    switch (command.action) {{
      case "selection.get":
        return {{
          editorType: window.Asc.plugin.info && window.Asc.plugin.info.editorType,
          text: await callMethod("GetSelectedText", [payload.options || {{
            Numbering: false,
            Math: false,
            TableCellSeparator: "\n",
            ParaSeparator: "\n",
            TabSymbol: "\t"
          }}])
        }};
      case "document.insert_text":
      case "document.replace_selection":
        return {{ value: await callMethod("PasteText", [String(payload.text || "")]) }};
      case "document.insert_html":
        return {{ value: await callMethod("PasteHtml", [String(payload.html || "")]) }};
      case "document.export":
        return {{
          format: payload.format || "markdown",
          content: await callMethod("ConvertDocument", [payload.format || "markdown", false, false, true, false])
        }};
      case "document.search_replace":
        return await callCommandJson({{
          search: String(payload.search || ""),
          replace: String(payload.replace || ""),
          matchCase: !!payload.matchCase
        }}, function () {{
          var search = String(Asc.scope.mnoteBridge.search || "");
          var replace = String(Asc.scope.mnoteBridge.replace || "");
          if (!search) {{
            return JSON.stringify({{ search: search, replace: replace, matchCase: !!Asc.scope.mnoteBridge.matchCase, skipped: true }});
          }}
          var document = Api.GetDocument();
          document.SearchAndReplace({{
            searchString: search,
            replaceString: replace,
            matchCase: !!Asc.scope.mnoteBridge.matchCase
          }});
          return JSON.stringify({{ search: search, replace: replace, matchCase: !!Asc.scope.mnoteBridge.matchCase }});
        }});
      case "document.insert_table":
        return await callCommandJson({{
          rows: Math.max(1, numberOrDefault(payload.rows, 1)),
          cols: Math.max(1, numberOrDefault(payload.cols || payload.columns, 1)),
          data: Array.isArray(payload.data) ? payload.data : []
        }}, function () {{
          function cellText(value) {{
            if (value === null || value === undefined) return "";
            if (typeof value === "string") return value;
            return String(value);
          }}
          var doc = Api.GetDocument();
          var rows = Number(Asc.scope.mnoteBridge.rows);
          var cols = Number(Asc.scope.mnoteBridge.cols);
          var table = Api.CreateTable(cols, rows);
          table.SetWidth("percent", 100);
          var data = Asc.scope.mnoteBridge.data || [];
          for (var rowIndex = 0; rowIndex < rows; rowIndex += 1) {{
            var row = table.GetRow(rowIndex);
            var rowValues = Array.isArray(data[rowIndex]) ? data[rowIndex] : [];
            for (var colIndex = 0; colIndex < cols; colIndex += 1) {{
              var cell = row.GetCell(colIndex);
              if (!cell || !cell.GetContent) continue;
              var content = cell.GetContent();
              if (content && content.RemoveAllElements) content.RemoveAllElements();
              var paragraph = Api.CreateParagraph();
              paragraph.AddText(cellText(rowValues[colIndex]));
              content.Push(paragraph);
            }}
          }}
          doc.Push(table);
          return JSON.stringify({{ rows: rows, cols: cols }});
        }});
      case "document.get_comments":
        return await callCommandJson({{}}, function () {{
          var doc = Api.GetDocument();
          var comments = doc.GetAllComments ? doc.GetAllComments() : [];
          var result = [];
          for (var index = 0; index < comments.length; index += 1) {{
            var comment = comments[index];
            result.push({{
              index: index,
              id: comment && comment.GetId ? String(comment.GetId()) : "",
              text: comment && comment.GetText ? String(comment.GetText() || "") : "",
              author: comment && comment.GetAuthorName ? String(comment.GetAuthorName() || "") : "",
              quoteText: comment && comment.GetQuoteText ? String(comment.GetQuoteText() || "") : ""
            }});
          }}
          return JSON.stringify({{ comments: result }});
        }});
      case "document.add_comment":
        return await callCommandJson({{
          text: String(payload.text || ""),
          author: String(payload.author || "MNote AI"),
          target: String(payload.target || "selection")
        }}, function () {{
          var doc = Api.GetDocument();
          var text = String(Asc.scope.mnoteBridge.text || "");
          var author = String(Asc.scope.mnoteBridge.author || "MNote AI");
          var target = String(Asc.scope.mnoteBridge.target || "selection");
          var comment = null;
          if (target === "document_start") {{
            var paragraph = doc.GetElement ? doc.GetElement(0) : null;
            if (!paragraph) throw new Error("document_start_not_found");
            if (Api.AddComment) {{
              comment = Api.AddComment(paragraph, text, author);
            }} else if (paragraph.AddComment) {{
              comment = paragraph.AddComment(text, author);
            }}
          }} else {{
            var range = doc.GetRangeBySelect ? doc.GetRangeBySelect() : null;
            if (!range) throw new Error("selection_range_not_found");
            comment = range.AddComment(text, author, "uid" + author);
          }}
          if (!comment) throw new Error("comment_not_created");
          if (doc.ShowComment && comment.GetId) doc.ShowComment([comment.GetId()]);
          return JSON.stringify({{
            id: comment.GetId ? String(comment.GetId()) : "",
            text: comment.GetText ? String(comment.GetText() || text) : text,
            author: comment.GetAuthorName ? String(comment.GetAuthorName() || author) : author,
            target: target
          }});
        }});
      case "sheet.get_sheets":
        return await callCommandJson({{}}, function () {{
          var sheets = Api.GetSheets();
          var result = [];
          for (var index = 0; index < sheets.length; index += 1) {{
            var sheet = sheets[index];
            result.push({{
              index: index,
              name: sheet && sheet.GetName ? sheet.GetName() : ""
            }});
          }}
          return JSON.stringify({{ sheets: result }});
        }});
      case "sheet.add_sheet":
        return await callCommandJson({{ name: String(payload.name || "") }}, function () {{
          var name = String(Asc.scope.mnoteBridge.name || "").trim();
          if (!name) throw new Error("sheet_name_required");
          Api.AddSheet(name);
          var sheet = Api.GetActiveSheet();
          return JSON.stringify({{ name: sheet && sheet.GetName ? sheet.GetName() : name }});
        }});
      case "sheet.rename_sheet":
        return await callCommandJson({{
          name: String(payload.name || ""),
          sheetIndex: payload.sheetIndex === undefined || payload.sheetIndex === null ? -1 : numberOrDefault(payload.sheetIndex, -1),
          sheetName: String(payload.sheetName || "")
        }}, function () {{
          var name = String(Asc.scope.mnoteBridge.name || "").trim();
          if (!name) throw new Error("sheet_name_required");
          var sheets = Api.GetSheets();
          var wantedIndex = Number(Asc.scope.mnoteBridge.sheetIndex);
          var wantedName = String(Asc.scope.mnoteBridge.sheetName || "");
          var sheet = null;
          if (Number.isFinite(wantedIndex) && wantedIndex >= 0 && wantedIndex < sheets.length) {{
            sheet = sheets[wantedIndex];
          }} else if (wantedName) {{
            for (var index = 0; index < sheets.length; index += 1) {{
              if (sheets[index] && sheets[index].GetName && sheets[index].GetName() === wantedName) {{
                sheet = sheets[index];
                break;
              }}
            }}
          }} else {{
            sheet = Api.GetActiveSheet();
          }}
          if (!sheet) throw new Error("sheet_not_found");
          sheet.SetName(name);
          return JSON.stringify({{ name: sheet.GetName ? sheet.GetName() : name }});
        }});
      case "sheet.get_range_values":
        return await callCommandJson({{ range: parseA1Range(payload.address || payload.range || "A1") }}, function () {{
          var worksheet = Api.GetActiveSheet();
          var range = Asc.scope.mnoteBridge.range;
          var values = [];
          var value2 = [];
          var formulas = [];
          for (var row = 0; row < range.rowCount; row += 1) {{
            var valueRow = [];
            var value2Row = [];
            var formulaRow = [];
            for (var col = 0; col < range.colCount; col += 1) {{
              var cell = worksheet.GetRangeByNumber(range.startRow + row, range.startCol + col);
              valueRow.push(cell.GetValue());
              value2Row.push(cell.GetValue2 ? cell.GetValue2() : cell.GetValue());
              formulaRow.push(cell.GetFormula ? cell.GetFormula() : "");
            }}
            values.push(valueRow);
            value2.push(value2Row);
            formulas.push(formulaRow);
          }}
          return JSON.stringify({{
            address: range.address,
            startRow: range.startRow,
            startCol: range.startCol,
            rowCount: range.rowCount,
            colCount: range.colCount,
            values: values,
            value2: value2,
            formulas: formulas
          }});
        }});
      case "sheet.get_range":
        return await callCommandJson({{ address: payload.address || "" }}, function () {{
          var worksheet = Api.GetActiveSheet();
          var range = Asc.scope.mnoteBridge.address ? worksheet.GetRange(Asc.scope.mnoteBridge.address) : Api.GetSelection();
          return JSON.stringify({{
            value: range.GetValue(),
            value2: range.GetValue2 ? range.GetValue2() : range.GetValue(),
            formula: range.GetFormula ? range.GetFormula() : ""
          }});
        }});
      case "sheet.get_values":
        return await callCommandJson({{
          startRow: numberOrDefault(payload.startRow, 0),
          startCol: numberOrDefault(payload.startCol, 0),
          rowCount: Math.max(1, numberOrDefault(payload.rowCount, 1)),
          colCount: Math.max(1, numberOrDefault(payload.colCount, 1))
        }}, function () {{
          var worksheet = Api.GetActiveSheet();
          var values = [];
          var value2 = [];
          var formulas = [];
          for (var row = 0; row < Asc.scope.mnoteBridge.rowCount; row += 1) {{
            var valueRow = [];
            var value2Row = [];
            var formulaRow = [];
            for (var col = 0; col < Asc.scope.mnoteBridge.colCount; col += 1) {{
              var cell = worksheet.GetRangeByNumber(
                Asc.scope.mnoteBridge.startRow + row,
                Asc.scope.mnoteBridge.startCol + col
              );
              valueRow.push(cell.GetValue());
              value2Row.push(cell.GetValue2 ? cell.GetValue2() : cell.GetValue());
              formulaRow.push(cell.GetFormula ? cell.GetFormula() : "");
            }}
            values.push(valueRow);
            value2.push(value2Row);
            formulas.push(formulaRow);
          }}
          return JSON.stringify({{
            startRow: Asc.scope.mnoteBridge.startRow,
            startCol: Asc.scope.mnoteBridge.startCol,
            rowCount: Asc.scope.mnoteBridge.rowCount,
            colCount: Asc.scope.mnoteBridge.colCount,
            values: values,
            value2: value2,
            formulas: formulas
          }});
        }});
      case "sheet.set_value":
        return await callCommandJson({{ address: payload.address || "", value: payload.value }}, function () {{
          var worksheet = Api.GetActiveSheet();
          var range = Asc.scope.mnoteBridge.address ? worksheet.GetRange(Asc.scope.mnoteBridge.address) : Api.GetSelection();
          range.SetValue(Asc.scope.mnoteBridge.value);
          return JSON.stringify({{ value: range.GetValue() }});
        }});
      case "sheet.set_formula":
        return await callCommandJson({{ address: payload.address || "", formula: payload.formula || "" }}, function () {{
          var worksheet = Api.GetActiveSheet();
          var range = Asc.scope.mnoteBridge.address ? worksheet.GetRange(Asc.scope.mnoteBridge.address) : Api.GetSelection();
          range.SetValue(Asc.scope.mnoteBridge.formula);
          return JSON.stringify({{ formula: range.GetFormula ? range.GetFormula() : Asc.scope.mnoteBridge.formula }});
        }});
      case "sheet.batch_set_values":
        return await callCommandJson({{
          startRow: numberOrDefault(payload.startRow, 0),
          startCol: numberOrDefault(payload.startCol, 0),
          values: Array.isArray(payload.values) ? payload.values : []
        }}, function () {{
          var worksheet = Api.GetActiveSheet();
          var rows = Asc.scope.mnoteBridge.values || [];
          var colCount = 0;
          for (var row = 0; row < rows.length; row += 1) {{
            var cells = Array.isArray(rows[row]) ? rows[row] : [rows[row]];
            if (cells.length > colCount) colCount = cells.length;
            for (var col = 0; col < cells.length; col += 1) {{
              worksheet.GetRangeByNumber(
                Asc.scope.mnoteBridge.startRow + row,
                Asc.scope.mnoteBridge.startCol + col
              ).SetValue(cells[col]);
            }}
          }}
          return JSON.stringify({{
            startRow: Asc.scope.mnoteBridge.startRow,
            startCol: Asc.scope.mnoteBridge.startCol,
            rowCount: rows.length,
            colCount: colCount
          }});
        }});
      case "sheet.set_range_values":
        return await callCommandJson({{
          range: parseA1Range(payload.address || payload.range || "A1"),
          values: Array.isArray(payload.values) ? payload.values : []
        }}, function () {{
          var worksheet = Api.GetActiveSheet();
          var range = Asc.scope.mnoteBridge.range;
          var rows = Asc.scope.mnoteBridge.values || [];
          var writtenRows = Math.min(range.rowCount, rows.length);
          var writtenCols = 0;
          for (var row = 0; row < writtenRows; row += 1) {{
            var cells = Array.isArray(rows[row]) ? rows[row] : [rows[row]];
            var colLimit = Math.min(range.colCount, cells.length);
            if (colLimit > writtenCols) writtenCols = colLimit;
            for (var col = 0; col < colLimit; col += 1) {{
              worksheet.GetRangeByNumber(range.startRow + row, range.startCol + col).SetValue(cells[col]);
            }}
          }}
          return JSON.stringify({{
            address: range.address,
            startRow: range.startRow,
            startCol: range.startCol,
            rowCount: writtenRows,
            colCount: writtenCols
          }});
        }});
      case "sheet.format_range":
        return await callCommandJson({{
          address: String(payload.address || payload.range || ""),
          bold: payload.bold === undefined || payload.bold === null ? null : !!payload.bold,
          italic: payload.italic === undefined || payload.italic === null ? null : !!payload.italic,
          underline: payload.underline === undefined || payload.underline === null ? null : !!payload.underline,
          fillColor: String(payload.fillColor || ""),
          fontColor: String(payload.fontColor || ""),
          fontSize: finiteNumberOrNull(payload.fontSize),
          fontName: String(payload.fontName || ""),
          horizontalAlign: String(payload.horizontalAlign || ""),
          verticalAlign: String(payload.verticalAlign || ""),
          numberFormat: String(payload.numberFormat || "")
        }}, function () {{
          function colorFromHex(value) {{
            var raw = String(value || "").trim();
            if (!raw) return null;
            var match = raw.match(/^#?([0-9a-fA-F]{{6}})$/);
            if (!match) throw new Error("invalid_hex_color:" + raw);
            var hex = match[1];
            return Api.CreateColorFromRGB(
              parseInt(hex.slice(0, 2), 16),
              parseInt(hex.slice(2, 4), 16),
              parseInt(hex.slice(4, 6), 16)
            );
          }}
          var worksheet = Api.GetActiveSheet();
          var range = Asc.scope.mnoteBridge.address ? worksheet.GetRange(Asc.scope.mnoteBridge.address) : Api.GetSelection();
          var applied = [];
          if (Asc.scope.mnoteBridge.bold !== null && range.SetBold) {{
            range.SetBold(!!Asc.scope.mnoteBridge.bold);
            applied.push("bold");
          }}
          if (Asc.scope.mnoteBridge.italic !== null && range.SetItalic) {{
            range.SetItalic(!!Asc.scope.mnoteBridge.italic);
            applied.push("italic");
          }}
          if (Asc.scope.mnoteBridge.underline !== null && range.SetUnderline) {{
            range.SetUnderline(!!Asc.scope.mnoteBridge.underline);
            applied.push("underline");
          }}
          if (Asc.scope.mnoteBridge.fillColor && range.SetFillColor) {{
            range.SetFillColor(colorFromHex(Asc.scope.mnoteBridge.fillColor));
            applied.push("fillColor");
          }}
          if (Asc.scope.mnoteBridge.fontColor && range.SetFontColor) {{
            range.SetFontColor(colorFromHex(Asc.scope.mnoteBridge.fontColor));
            applied.push("fontColor");
          }}
          if (Asc.scope.mnoteBridge.fontSize !== null && range.SetFontSize) {{
            range.SetFontSize(Asc.scope.mnoteBridge.fontSize);
            applied.push("fontSize");
          }}
          if (Asc.scope.mnoteBridge.fontName && range.SetFontName) {{
            range.SetFontName(Asc.scope.mnoteBridge.fontName);
            applied.push("fontName");
          }}
          if (Asc.scope.mnoteBridge.horizontalAlign && range.SetAlignHorizontal) {{
            range.SetAlignHorizontal(Asc.scope.mnoteBridge.horizontalAlign);
            applied.push("horizontalAlign");
          }}
          if (Asc.scope.mnoteBridge.verticalAlign && range.SetAlignVertical) {{
            range.SetAlignVertical(Asc.scope.mnoteBridge.verticalAlign);
            applied.push("verticalAlign");
          }}
          if (Asc.scope.mnoteBridge.numberFormat && range.SetNumberFormat) {{
            range.SetNumberFormat(Asc.scope.mnoteBridge.numberFormat);
            applied.push("numberFormat");
          }}
          return JSON.stringify({{
            address: Asc.scope.mnoteBridge.address,
            applied: applied
          }});
        }});
      case "sheet.set_dimensions":
        return await callCommandJson({{
          columnIndex: payload.columnIndex === undefined || payload.columnIndex === null ? -1 : numberOrDefault(payload.columnIndex, -1),
          columnWidth: finiteNumberOrNull(payload.columnWidth),
          rowIndex: payload.rowIndex === undefined || payload.rowIndex === null ? -1 : numberOrDefault(payload.rowIndex, -1),
          rowHeight: finiteNumberOrNull(payload.rowHeight)
        }}, function () {{
          var worksheet = Api.GetActiveSheet();
          var changed = [];
          if (Asc.scope.mnoteBridge.columnIndex >= 0 && Asc.scope.mnoteBridge.columnWidth !== null) {{
            worksheet.SetColumnWidth(Asc.scope.mnoteBridge.columnIndex, Asc.scope.mnoteBridge.columnWidth);
            changed.push("columnWidth");
          }}
          if (Asc.scope.mnoteBridge.rowIndex >= 0 && Asc.scope.mnoteBridge.rowHeight !== null) {{
            worksheet.SetRowHeight(Asc.scope.mnoteBridge.rowIndex, Asc.scope.mnoteBridge.rowHeight);
            changed.push("rowHeight");
          }}
          return JSON.stringify({{
            columnIndex: Asc.scope.mnoteBridge.columnIndex,
            columnWidth: Asc.scope.mnoteBridge.columnWidth,
            rowIndex: Asc.scope.mnoteBridge.rowIndex,
            rowHeight: Asc.scope.mnoteBridge.rowHeight,
            changed: changed
          }});
        }});
      case "sheet.sort_range":
        return await callCommandJson({{
          range: parseA1Range(payload.address || payload.range || "A1"),
          keyColumn: Math.max(1, numberOrDefault(payload.keyColumn || payload.key_column, 1)),
          order: String(payload.order || "ascending"),
          header: payload.header === undefined ? true : !!payload.header
        }}, function () {{
          var worksheet = Api.GetActiveSheet();
          var rangeInfo = Asc.scope.mnoteBridge.range;
          var range = worksheet.GetRange(rangeInfo.address);
          if (!range || !range.SetSort) throw new Error("range_sort_not_supported");
          var keyColumn = Math.min(Math.max(1, Number(Asc.scope.mnoteBridge.keyColumn)), rangeInfo.colCount);
          var keyCell = worksheet.GetCells(
            rangeInfo.startRow + (Asc.scope.mnoteBridge.header ? 2 : 1),
            rangeInfo.startCol + keyColumn
          );
          var rawOrder = String(Asc.scope.mnoteBridge.order || "ascending").toLowerCase();
          var order = rawOrder === "desc" || rawOrder === "descending" || rawOrder === "xldescending"
            ? "xlDescending"
            : "xlAscending";
          var header = Asc.scope.mnoteBridge.header ? "xlYes" : "xlNo";
          range.SetSort(keyCell, order, null, null, null, null, header);
          return JSON.stringify({{
            address: rangeInfo.address,
            keyColumn: keyColumn,
            order: order,
            header: header
          }});
        }});
      case "sheet.add_chart":
        return await callCommandJson({{
          address: String(payload.address || payload.range || ""),
          chartType: String(payload.chartType || payload.chart_type || "bar"),
          inColumns: payload.inColumns === undefined && payload.in_columns === undefined ? true : !!(payload.inColumns === undefined ? payload.in_columns : payload.inColumns),
          style: Math.max(1, numberOrDefault(payload.style, 2)),
          widthMm: finiteNumberOrNull(payload.widthMm || payload.width_mm) || 120,
          heightMm: finiteNumberOrNull(payload.heightMm || payload.height_mm) || 80,
          fromCol: numberOrDefault(payload.fromCol || payload.from_col, 4),
          xOffsetMm: finiteNumberOrNull(payload.xOffsetMm || payload.x_offset_mm) || 0,
          fromRow: numberOrDefault(payload.fromRow || payload.from_row, 1),
          yOffsetMm: finiteNumberOrNull(payload.yOffsetMm || payload.y_offset_mm) || 0
        }}, function () {{
          var worksheet = Api.GetActiveSheet();
          var address = String(Asc.scope.mnoteBridge.address || "").trim();
          if (!address) throw new Error("chart_range_required");
          if (!worksheet.AddChart) throw new Error("chart_not_supported");
          worksheet.AddChart(
            address,
            !!Asc.scope.mnoteBridge.inColumns,
            String(Asc.scope.mnoteBridge.chartType || "bar"),
            Number(Asc.scope.mnoteBridge.style),
            Number(Asc.scope.mnoteBridge.widthMm) * 36000,
            Number(Asc.scope.mnoteBridge.heightMm) * 36000,
            Number(Asc.scope.mnoteBridge.fromCol),
            Number(Asc.scope.mnoteBridge.xOffsetMm) * 36000,
            Number(Asc.scope.mnoteBridge.fromRow),
            Number(Asc.scope.mnoteBridge.yOffsetMm) * 36000
          );
          return JSON.stringify({{
            address: address,
            chartType: String(Asc.scope.mnoteBridge.chartType || "bar"),
            inColumns: !!Asc.scope.mnoteBridge.inColumns,
            style: Number(Asc.scope.mnoteBridge.style)
          }});
        }});
      case "presentation.get_slides":
        return await callCommand({{}}, function () {{
          var presentation = Api.GetPresentation();
          return {{ slidesCount: presentation.GetSlidesCount() }};
        }});
      case "presentation.get_slide_texts":
        return await callCommandJson({{
          slideIndex: payload.slideIndex === undefined || payload.slideIndex === null ? -1 : numberOrDefault(payload.slideIndex, -1)
        }}, function () {{
          function readContentText(content) {{
            if (!content || !content.GetElementsCount) return "";
            var parts = [];
            for (var index = 0; index < content.GetElementsCount(); index += 1) {{
              var element = content.GetElement(index);
              if (element && element.GetText) {{
                var text = String(element.GetText() || "").replace(/[\r\n]+$/g, "");
                if (text) parts.push(text);
              }}
            }}
            return parts.join("\n");
          }}
          function slideShapes(slide) {{
            if (!slide) return [];
            if (slide.GetAllShapes) return slide.GetAllShapes();
            if (slide.GetAllDrawings) return slide.GetAllDrawings();
            return [];
          }}
          function readSlide(presentation, slideIndex) {{
            var slide = presentation.GetSlideByIndex(slideIndex);
            var shapes = slideShapes(slide);
            var shapeTexts = [];
            var textParts = [];
            for (var shapeIndex = 0; shapeIndex < shapes.length; shapeIndex += 1) {{
              var shape = shapes[shapeIndex];
              if (!shape || !shape.GetDocContent) continue;
              var text = readContentText(shape.GetDocContent());
              if (!text) continue;
              shapeTexts.push({{ shapeIndex: shapeIndex, text: text }});
              textParts.push(text);
            }}
            return {{ slideIndex: slideIndex, text: textParts.join("\n"), shapes: shapeTexts }};
          }}
          var presentation = Api.GetPresentation();
          var slidesCount = presentation.GetSlidesCount();
          var wanted = Number(Asc.scope.mnoteBridge.slideIndex);
          var slides = [];
          if (Number.isFinite(wanted) && wanted >= 0) {{
            if (wanted < slidesCount) slides.push(readSlide(presentation, wanted));
          }} else {{
            for (var index = 0; index < slidesCount; index += 1) {{
              slides.push(readSlide(presentation, index));
            }}
          }}
          return JSON.stringify({{ slidesCount: slidesCount, slides: slides }});
        }});
      case "presentation.get_shapes":
        return await callCommandJson({{
          slideIndex: payload.slideIndex === undefined || payload.slideIndex === null ? -1 : numberOrDefault(payload.slideIndex, -1)
        }}, function () {{
          function readContentText(content) {{
            if (!content || !content.GetElementsCount) return "";
            var parts = [];
            for (var index = 0; index < content.GetElementsCount(); index += 1) {{
              var element = content.GetElement(index);
              if (element && element.GetText) {{
                var text = String(element.GetText() || "").replace(/[\r\n]+$/g, "");
                if (text) parts.push(text);
              }}
            }}
            return parts.join("\n");
          }}
          function slideShapes(slide) {{
            if (!slide) return [];
            if (slide.GetAllShapes) return slide.GetAllShapes();
            if (slide.GetAllDrawings) return slide.GetAllDrawings();
            return [];
          }}
          function readShape(shape, slideIndex, shapeIndex) {{
            var content = shape && shape.GetDocContent ? shape.GetDocContent() : null;
            return {{
              slideIndex: slideIndex,
              shapeIndex: shapeIndex,
              shapeId: shape && shape.GetInternalId ? String(shape.GetInternalId()) : "",
              hasText: !!content,
              text: readContentText(content)
            }};
          }}
          var presentation = Api.GetPresentation();
          var slidesCount = presentation.GetSlidesCount();
          var wanted = Number(Asc.scope.mnoteBridge.slideIndex);
          var shapes = [];
          for (var slideIndex = 0; slideIndex < slidesCount; slideIndex += 1) {{
            if (Number.isFinite(wanted) && wanted >= 0 && slideIndex !== wanted) continue;
            var slide = presentation.GetSlideByIndex(slideIndex);
            var slideShapesList = slideShapes(slide);
            for (var shapeIndex = 0; shapeIndex < slideShapesList.length; shapeIndex += 1) {{
              shapes.push(readShape(slideShapesList[shapeIndex], slideIndex, shapeIndex));
            }}
          }}
          return JSON.stringify({{ slidesCount: slidesCount, shapes: shapes }});
        }});
      case "presentation.add_text_slide":
        return await callCommand({{
          title: payload.title || "",
          body: payload.body || ""
        }}, function () {{
          var presentation = Api.GetPresentation();
          var slide = Api.CreateSlide();
          presentation.AddSlide(slide);
          var fill = Api.CreateSolidFill(Api.CreateRGBColor(255, 255, 255));
          var stroke = Api.CreateStroke(0, Api.CreateNoFill());
          var shape = Api.CreateShape("rect", 7600000, 4200000, fill, stroke);
          shape.SetPosition(700000, 900000);
          var content = shape.GetDocContent();
          content.RemoveAllElements();
          var title = Api.CreateParagraph();
          var titleRun = Api.CreateRun();
          titleRun.AddText(String(Asc.scope.mnoteBridge.title || ""));
          titleRun.SetFontSize(32);
          titleRun.SetBold(true);
          title.AddElement(titleRun);
          content.Push(title);
          var body = Api.CreateParagraph();
          var bodyRun = Api.CreateRun();
          bodyRun.AddText(String(Asc.scope.mnoteBridge.body || ""));
          bodyRun.SetFontSize(20);
          body.AddElement(bodyRun);
          content.Push(body);
          slide.AddObject(shape);
          return {{ slidesCount: presentation.GetSlidesCount() }};
        }});
      case "presentation.replace_text":
        return await callCommandJson({{
          search: String(payload.search || ""),
          replace: String(payload.replace || ""),
          slideIndex: payload.slideIndex === undefined || payload.slideIndex === null ? -1 : numberOrDefault(payload.slideIndex, -1),
          matchCase: !!payload.matchCase,
          replaceAll: payload.replaceAll === undefined ? true : !!payload.replaceAll
        }}, function () {{
          function readContentText(content) {{
            if (!content || !content.GetElementsCount) return "";
            var parts = [];
            for (var index = 0; index < content.GetElementsCount(); index += 1) {{
              var element = content.GetElement(index);
              if (element && element.GetText) {{
                var text = String(element.GetText() || "").replace(/[\r\n]+$/g, "");
                if (text) parts.push(text);
              }}
            }}
            return parts.join("\n");
          }}
          function writeContentText(content, text) {{
            content.RemoveAllElements();
            var normalized = String(text || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n+$/g, "");
            var lines = normalized ? normalized.split(/\n/) : [""];
            for (var index = 0; index < lines.length; index += 1) {{
              var paragraph = Api.CreateParagraph();
              paragraph.AddText(lines[index]);
              content.Push(paragraph);
            }}
          }}
          function slideShapes(slide) {{
            if (!slide) return [];
            if (slide.GetAllShapes) return slide.GetAllShapes();
            if (slide.GetAllDrawings) return slide.GetAllDrawings();
            return [];
          }}
          function escapeRegExp(value) {{
            return String(value).replace(/[.*+?^${{}}()|[\]\\]/g, "\\$&");
          }}
          function replaceText(text, search, replace, matchCase, replaceAll) {{
            if (!search) return {{ text: text, count: 0 }};
            var flags = replaceAll ? "g" : "";
            if (!matchCase) flags += "i";
            var pattern = new RegExp(escapeRegExp(search), flags);
            var count = 0;
            var next = String(text || "").replace(pattern, function () {{
              count += 1;
              return replace;
            }});
            return {{ text: next, count: count }};
          }}
          var search = String(Asc.scope.mnoteBridge.search || "");
          var replace = String(Asc.scope.mnoteBridge.replace || "");
          var presentation = Api.GetPresentation();
          var slidesCount = presentation.GetSlidesCount();
          var wanted = Number(Asc.scope.mnoteBridge.slideIndex);
          var changed = [];
          var totalReplacements = 0;
          for (var slideIndex = 0; slideIndex < slidesCount; slideIndex += 1) {{
            if (Number.isFinite(wanted) && wanted >= 0 && slideIndex !== wanted) continue;
            var slide = presentation.GetSlideByIndex(slideIndex);
            var shapes = slideShapes(slide);
            for (var shapeIndex = 0; shapeIndex < shapes.length; shapeIndex += 1) {{
              var shape = shapes[shapeIndex];
              if (!shape || !shape.GetDocContent) continue;
              var content = shape.GetDocContent();
              var currentText = readContentText(content);
              var replaced = replaceText(
                currentText,
                search,
                replace,
                !!Asc.scope.mnoteBridge.matchCase,
                Asc.scope.mnoteBridge.replaceAll !== false
              );
              if (replaced.count > 0) {{
                writeContentText(content, replaced.text);
                totalReplacements += replaced.count;
                changed.push({{ slideIndex: slideIndex, shapeIndex: shapeIndex, replacements: replaced.count }});
              }}
            }}
          }}
          return JSON.stringify({{
            search: search,
            replace: replace,
            replacements: totalReplacements,
            changed: changed
          }});
        }});
      case "presentation.set_shape_text":
        return await callCommandJson({{
          slideIndex: numberOrDefault(payload.slideIndex, 0),
          shapeIndex: payload.shapeIndex === undefined || payload.shapeIndex === null ? -1 : numberOrDefault(payload.shapeIndex, -1),
          shapeId: String(payload.shapeId || ""),
          text: String(payload.text || "")
        }}, function () {{
          function writeContentText(content, text) {{
            content.RemoveAllElements();
            var normalized = String(text || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n+$/g, "");
            var lines = normalized ? normalized.split(/\n/) : [""];
            for (var index = 0; index < lines.length; index += 1) {{
              var paragraph = Api.CreateParagraph();
              paragraph.AddText(lines[index]);
              content.Push(paragraph);
            }}
          }}
          function slideShapes(slide) {{
            if (!slide) return [];
            if (slide.GetAllShapes) return slide.GetAllShapes();
            if (slide.GetAllDrawings) return slide.GetAllDrawings();
            return [];
          }}
          var presentation = Api.GetPresentation();
          var slideIndex = Number(Asc.scope.mnoteBridge.slideIndex);
          var slide = presentation.GetSlideByIndex(slideIndex);
          if (!slide) throw new Error("slide_not_found");
          var shapes = slideShapes(slide);
          var wantedIndex = Number(Asc.scope.mnoteBridge.shapeIndex);
          var wantedId = String(Asc.scope.mnoteBridge.shapeId || "");
          var shape = null;
          var shapeIndex = -1;
          if (Number.isFinite(wantedIndex) && wantedIndex >= 0 && wantedIndex < shapes.length) {{
            shape = shapes[wantedIndex];
            shapeIndex = wantedIndex;
          }} else if (wantedId) {{
            for (var index = 0; index < shapes.length; index += 1) {{
              if (shapes[index] && shapes[index].GetInternalId && String(shapes[index].GetInternalId()) === wantedId) {{
                shape = shapes[index];
                shapeIndex = index;
                break;
              }}
            }}
          }}
          if (!shape || !shape.GetDocContent) throw new Error("shape_text_not_found");
          writeContentText(shape.GetDocContent(), Asc.scope.mnoteBridge.text);
          return JSON.stringify({{
            slideIndex: slideIndex,
            shapeIndex: shapeIndex,
            shapeId: shape.GetInternalId ? String(shape.GetInternalId()) : "",
            text: Asc.scope.mnoteBridge.text
          }});
        }});
      case "presentation.delete_slide":
        return await callCommandJson({{
          slideIndex: numberOrDefault(payload.slideIndex, 0)
        }}, function () {{
          var presentation = Api.GetPresentation();
          var before = presentation.GetSlidesCount();
          var slideIndex = Number(Asc.scope.mnoteBridge.slideIndex);
          var slide = presentation.GetSlideByIndex(slideIndex);
          if (!slide) throw new Error("slide_not_found");
          slide.Delete();
          return JSON.stringify({{
            slideIndex: slideIndex,
            slidesCountBefore: before,
            slidesCount: presentation.GetSlidesCount()
          }});
        }});
      case "presentation.add_table":
        return await callCommandJson({{
          slideIndex: payload.slideIndex === undefined || payload.slideIndex === null ? -1 : numberOrDefault(payload.slideIndex, -1),
          rows: Math.max(1, numberOrDefault(payload.rows, 1)),
          cols: Math.max(1, numberOrDefault(payload.cols || payload.columns, 1)),
          data: Array.isArray(payload.data) ? payload.data : [],
          xMm: finiteNumberOrNull(payload.xMm || payload.x_mm),
          yMm: finiteNumberOrNull(payload.yMm || payload.y_mm),
          widthMm: finiteNumberOrNull(payload.widthMm || payload.width_mm) || 190,
          heightMm: finiteNumberOrNull(payload.heightMm || payload.height_mm) || 90
        }}, function () {{
          function cellText(value) {{
            if (value === null || value === undefined) return "";
            if (typeof value === "string") return value;
            return String(value);
          }}
          var presentation = Api.GetPresentation();
          var slideIndex = Number(Asc.scope.mnoteBridge.slideIndex);
          var slide = slideIndex >= 0 ? presentation.GetSlideByIndex(slideIndex) : presentation.GetCurrentSlide();
          if (!slide) {{
            slideIndex = 0;
            slide = presentation.GetSlideByIndex(0);
          }}
          if (!slide) throw new Error("slide_not_found");
          var rows = Number(Asc.scope.mnoteBridge.rows);
          var cols = Number(Asc.scope.mnoteBridge.cols);
          var table = Api.CreateTable(cols, rows);
          if (!table) throw new Error("table_not_created");
          var tableWidth = Number(Asc.scope.mnoteBridge.widthMm) * 36000;
          var tableHeight = Number(Asc.scope.mnoteBridge.heightMm) * 36000;
          var x = Asc.scope.mnoteBridge.xMm === null
            ? Math.max(0, (presentation.GetWidth() - tableWidth) / 2)
            : Number(Asc.scope.mnoteBridge.xMm) * 36000;
          var y = Asc.scope.mnoteBridge.yMm === null
            ? Math.max(0, (presentation.GetHeight() - tableHeight) / 2)
            : Number(Asc.scope.mnoteBridge.yMm) * 36000;
          if (table.SetPosition) table.SetPosition(x, y);
          if (table.SetSize) table.SetSize(tableWidth, tableHeight);
          var data = Asc.scope.mnoteBridge.data || [];
          for (var rowIndex = 0; rowIndex < rows; rowIndex += 1) {{
            var row = table.GetRow(rowIndex);
            var rowValues = Array.isArray(data[rowIndex]) ? data[rowIndex] : [];
            for (var colIndex = 0; colIndex < cols; colIndex += 1) {{
              var cell = row.GetCell(colIndex);
              var content = cell && cell.GetContent ? cell.GetContent() : null;
              if (!content) continue;
              if (content.RemoveAllElements) content.RemoveAllElements();
              var paragraph = Api.CreateParagraph();
              paragraph.AddText(cellText(rowValues[colIndex]));
              content.Push(paragraph);
            }}
          }}
          slide.AddObject(table);
          return JSON.stringify({{
            slideIndex: slideIndex >= 0 ? slideIndex : 0,
            rows: rows,
            cols: cols,
            widthMm: Number(Asc.scope.mnoteBridge.widthMm),
            heightMm: Number(Asc.scope.mnoteBridge.heightMm)
          }});
        }});
      case "presentation.clear_slide":
        return await callCommandJson({{
          slideIndex: numberOrDefault(payload.slideIndex, 0)
        }}, function () {{
          var presentation = Api.GetPresentation();
          var slideIndex = Number(Asc.scope.mnoteBridge.slideIndex);
          var slide = presentation.GetSlideByIndex(slideIndex);
          if (!slide) throw new Error("slide_not_found");
          if (!slide.RemoveAllObjects) throw new Error("clear_slide_not_supported");
          slide.RemoveAllObjects();
          return JSON.stringify({{
            slideIndex: slideIndex,
            slidesCount: presentation.GetSlidesCount()
          }});
        }});
      case "presentation.add_shape":
        return await callCommandJson({{
          slideIndex: payload.slideIndex === undefined || payload.slideIndex === null ? -1 : numberOrDefault(payload.slideIndex, -1),
          shapeType: String(payload.shapeType || payload.shape_type || "rect"),
          text: String(payload.text || ""),
          fillColor: String(payload.fillColor || payload.fill_color || "4F81BD"),
          strokeColor: String(payload.strokeColor || payload.stroke_color || ""),
          strokeWidthMm: finiteNumberOrNull(payload.strokeWidthMm || payload.stroke_width_mm) || 0,
          xMm: finiteNumberOrNull(payload.xMm || payload.x_mm) || 20,
          yMm: finiteNumberOrNull(payload.yMm || payload.y_mm) || 35,
          widthMm: finiteNumberOrNull(payload.widthMm || payload.width_mm) || 160,
          heightMm: finiteNumberOrNull(payload.heightMm || payload.height_mm) || 70
        }}, function () {{
          function colorFromHexLocal(value, fallback) {{
            var raw = String(value || fallback || "").trim();
            var match = raw.match(/^#?([0-9a-fA-F]{{6}})$/);
            if (!match) throw new Error("invalid_hex_color:" + raw);
            var hex = match[1];
            var red = parseInt(hex.slice(0, 2), 16);
            var green = parseInt(hex.slice(2, 4), 16);
            var blue = parseInt(hex.slice(4, 6), 16);
            if (Api.RGB) return Api.RGB(red, green, blue);
            return Api.CreateRGBColor(red, green, blue);
          }}
          var presentation = Api.GetPresentation();
          var slideIndex = Number(Asc.scope.mnoteBridge.slideIndex);
          var slide = slideIndex >= 0 ? presentation.GetSlideByIndex(slideIndex) : presentation.GetCurrentSlide();
          if (!slide) {{
            slideIndex = 0;
            slide = presentation.GetSlideByIndex(0);
          }}
          if (!slide) throw new Error("slide_not_found");
          var fill = Api.CreateSolidFill(colorFromHexLocal(Asc.scope.mnoteBridge.fillColor, "4F81BD"));
          var strokeFill = Asc.scope.mnoteBridge.strokeColor
            ? Api.CreateSolidFill(colorFromHexLocal(Asc.scope.mnoteBridge.strokeColor, "000000"))
            : Api.CreateNoFill();
          var stroke = Api.CreateStroke(Number(Asc.scope.mnoteBridge.strokeWidthMm) * 36000, strokeFill);
          var shape = Api.CreateShape(
            String(Asc.scope.mnoteBridge.shapeType || "rect"),
            Number(Asc.scope.mnoteBridge.widthMm) * 36000,
            Number(Asc.scope.mnoteBridge.heightMm) * 36000,
            fill,
            stroke
          );
          if (!shape) throw new Error("shape_not_created");
          shape.SetPosition(Number(Asc.scope.mnoteBridge.xMm) * 36000, Number(Asc.scope.mnoteBridge.yMm) * 36000);
          if (shape.SetSize) shape.SetSize(Number(Asc.scope.mnoteBridge.widthMm) * 36000, Number(Asc.scope.mnoteBridge.heightMm) * 36000);
          var content = shape.GetDocContent ? shape.GetDocContent() : (shape.GetContent ? shape.GetContent() : null);
          if (content && Asc.scope.mnoteBridge.text) {{
            if (content.RemoveAllElements) content.RemoveAllElements();
            var paragraph = Api.CreateParagraph();
            paragraph.AddText(String(Asc.scope.mnoteBridge.text || ""));
            content.Push(paragraph);
          }}
          slide.AddObject(shape);
          return JSON.stringify({{
            slideIndex: slideIndex >= 0 ? slideIndex : 0,
            shapeType: String(Asc.scope.mnoteBridge.shapeType || "rect"),
            text: String(Asc.scope.mnoteBridge.text || ""),
            fillColor: String(Asc.scope.mnoteBridge.fillColor || "4F81BD")
          }});
        }});
      default:
        throw new Error("unsupported_onlyoffice_bridge_action:" + command.action);
    }}
  }}

  async function register() {{
    await postJson("/api/onlyoffice/bridge/session", {{
      sessionId: sessionId,
      token: bridgeToken,
      editorType: window.Asc.plugin.info && window.Asc.plugin.info.editorType,
      documentId: sessionMeta.documentId || "",
      assetId: sessionMeta.assetId || "",
      fileType: sessionMeta.fileType || "",
      docKey: sessionMeta.docKey || "",
      pageOrigin: sessionMeta.pageOrigin || ""
    }});
  }}

  async function loop() {{
    while (!stopped) {{
      try {{
        var query = new URLSearchParams({{
          sessionId: sessionId,
          token: bridgeToken,
          timeoutMs: "25000"
        }});
        var response = await fetch(apiBase + "/api/onlyoffice/bridge/commands/next?" + query.toString());
        if (response.status === 204) continue;
        var command = await response.json();
        var result = await executeCommand(command);
        await postJson("/api/onlyoffice/bridge/results", {{
          sessionId: sessionId,
          token: bridgeToken,
          id: command.id,
          ok: true,
          result: result === undefined ? null : result
        }});
      }} catch (error) {{
        if (!stopped && command && command.id) {{
          await postJson("/api/onlyoffice/bridge/results", {{
            sessionId: sessionId,
            token: bridgeToken,
            id: command.id,
            ok: false,
            error: error && error.message ? error.message : String(error)
          }}).catch(function () {{}});
        }} else {{
          await new Promise(function (resolve) {{ setTimeout(resolve, 1000); }});
        }}
      }}
    }}
  }}

  window.Asc.plugin.init = function () {{
    register().then(loop).catch(function () {{
      setTimeout(function () {{ register().then(loop); }}, 1000);
    }});
  }};

  window.Asc.plugin.button = function () {{}};
  window.addEventListener("beforeunload", function () {{ stopped = true; }});
}}());
</script>
</body>
</html>"#,
        session_id = json_string(session_id),
        api_base = json_string(api_base),
        token = json_string(token),
        document_id = json_string(document_id.unwrap_or("")),
        asset_id = json_string(asset_id.unwrap_or("")),
        file_type = json_string(file_type.unwrap_or("")),
        doc_key = json_string(doc_key.unwrap_or("")),
        page_origin = json_string(page_origin.unwrap_or("")),
    );
    Html(html)
}

pub async fn register_session(Json(payload): Json<BridgeSessionPayload>) -> Response {
    let Some(session_id) = non_empty(Some(payload.session_id)) else {
        return bad_request("onlyoffice_bridge_session_required", "缺少 sessionId");
    };
    let Some(token) = non_empty(payload.token) else {
        return unauthorized("onlyoffice_bridge_token_required", "缺少 bridge token");
    };
    let mut state = bridge_state().lock().expect("bridge state lock poisoned");
    let session = state.entry(session_id.clone()).or_default();
    session.token = Some(token);
    session.last_seen_millis = now_millis();
    session.editor_type = payload
        .editor_type
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    session.document_id = payload
        .document_id
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    session.asset_id = payload
        .asset_id
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    session.file_type = payload
        .file_type
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    session.doc_key = payload
        .doc_key
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    session.page_origin = payload
        .page_origin
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned);
    json_response(json!({
        "ok": true,
        "sessionId": session_id,
        "editorType": session.editor_type.clone().unwrap_or_default(),
        "documentId": session.document_id.clone().unwrap_or_default(),
        "assetId": session.asset_id.clone().unwrap_or_default(),
        "fileType": session.file_type.clone().unwrap_or_default(),
        "docKey": session.doc_key.clone().unwrap_or_default(),
        "pageOrigin": session.page_origin.clone().unwrap_or_default()
    }))
}

pub async fn list_sessions() -> Response {
    json_response(json!({
        "ok": true,
        "sessions": session_infos()
    }))
}

pub async fn current_session() -> Response {
    let current = session_infos().into_iter().next();
    json_response(json!({
        "ok": true,
        "session": current
    }))
}

pub async fn close_session(Json(payload): Json<BridgeCloseSessionPayload>) -> Response {
    let Some(session_id) = non_empty(Some(payload.session_id)) else {
        return bad_request("onlyoffice_bridge_session_required", "缺少 sessionId");
    };
    if let Err(response) = ensure_session_token(&session_id, payload.token.as_deref()) {
        return response;
    }
    let mut state = bridge_state().lock().expect("bridge state lock poisoned");
    let closed = state.remove(&session_id).is_some();
    json_response(json!({
        "ok": true,
        "sessionId": session_id,
        "closed": closed
    }))
}

pub async fn capabilities() -> Response {
    json_response(capability_payload())
}

pub fn capability_payload() -> Value {
    json!({
        "ok": true,
        "schema": "mnote.onlyoffice.bridge_capabilities.v1",
        "guid": MNOTE_ONLYOFFICE_BRIDGE_GUID,
        "actions": [
            "selection.get",
            "document.insert_text",
            "document.replace_selection",
            "document.insert_html",
            "document.export",
            "document.search_replace",
            "document.insert_table",
            "document.get_comments",
            "document.add_comment",
            "sheet.get_sheets",
            "sheet.add_sheet",
            "sheet.rename_sheet",
            "sheet.get_range",
            "sheet.get_range_values",
            "sheet.get_values",
            "sheet.set_value",
            "sheet.set_formula",
            "sheet.batch_set_values",
            "sheet.set_range_values",
            "sheet.format_range",
            "sheet.set_dimensions",
            "sheet.sort_range",
            "sheet.add_chart",
            "presentation.get_slides",
            "presentation.get_slide_texts",
            "presentation.get_shapes",
            "presentation.add_text_slide",
            "presentation.replace_text",
            "presentation.set_shape_text",
            "presentation.delete_slide",
            "presentation.add_table",
            "presentation.clear_slide",
            "presentation.add_shape"
        ],
        "editors": {
            "word": ["selection.get", "document.insert_text", "document.replace_selection", "document.insert_html", "document.export", "document.search_replace", "document.insert_table", "document.get_comments", "document.add_comment"],
            "cell": ["selection.get", "sheet.get_sheets", "sheet.add_sheet", "sheet.rename_sheet", "sheet.get_range", "sheet.get_range_values", "sheet.get_values", "sheet.set_value", "sheet.set_formula", "sheet.batch_set_values", "sheet.set_range_values", "sheet.format_range", "sheet.set_dimensions", "sheet.sort_range", "sheet.add_chart"],
            "slide": ["selection.get", "presentation.get_slides", "presentation.get_slide_texts", "presentation.get_shapes", "presentation.add_text_slide", "presentation.replace_text", "presentation.set_shape_text", "presentation.delete_slide", "presentation.add_table", "presentation.clear_slide", "presentation.add_shape"]
        }
    })
}

pub async fn enqueue_command(Json(payload): Json<BridgeEnqueuePayload>) -> Response {
    let Some(session_id) = non_empty(Some(payload.session_id)) else {
        return bad_request("onlyoffice_bridge_session_required", "缺少 sessionId");
    };
    if let Err(response) = ensure_session_token(&session_id, payload.token.as_deref()) {
        return response;
    }
    let Some(action) = non_empty(Some(payload.action)) else {
        return bad_request("onlyoffice_bridge_action_required", "缺少 action");
    };
    let command = push_command(&session_id, action, payload.payload);
    json_response(json!({
        "ok": true,
        "sessionId": session_id,
        "command": command
    }))
}

pub async fn next_command(Query(query): Query<BridgeNextQuery>) -> Response {
    let Some(session_id) = non_empty(Some(query.session_id)) else {
        return bad_request("onlyoffice_bridge_session_required", "缺少 sessionId");
    };
    if let Err(response) = ensure_session_token(&session_id, query.token.as_deref()) {
        return response;
    }
    let timeout = query
        .timeout_ms
        .unwrap_or(MAX_LONG_POLL_MS)
        .min(MAX_LONG_POLL_MS);
    let deadline = Instant::now() + Duration::from_millis(timeout);
    loop {
        if let Some(command) = pop_command(&session_id) {
            return json_response(json!(command));
        }
        if Instant::now() >= deadline {
            return StatusCode::NO_CONTENT.into_response();
        }
        sleep(Duration::from_millis(LONG_POLL_INTERVAL_MS)).await;
    }
}

pub async fn post_result(Json(payload): Json<Value>) -> Response {
    let Some(session_id) = payload
        .get("sessionId")
        .and_then(Value::as_str)
        .map(str::to_string)
        .and_then(|value| non_empty(Some(value)))
    else {
        return bad_request("onlyoffice_bridge_session_required", "缺少 sessionId");
    };
    if let Err(response) =
        ensure_session_token(&session_id, payload.get("token").and_then(Value::as_str))
    {
        return response;
    }
    let Ok(result) = serde_json::from_value::<BridgeResultWire>(payload) else {
        return bad_request("onlyoffice_bridge_result_invalid", "bridge result 格式无效");
    };
    let mut state = bridge_state().lock().expect("bridge state lock poisoned");
    let session = state.entry(session_id.clone()).or_default();
    session.results.insert(result.id.clone(), result.clone());
    json_response(json!({
        "ok": true,
        "sessionId": session_id,
        "result": result
    }))
}

pub async fn get_result(Query(query): Query<BridgeResultQuery>) -> Response {
    let Some(session_id) = non_empty(Some(query.session_id)) else {
        return bad_request("onlyoffice_bridge_session_required", "缺少 sessionId");
    };
    let Some(command_id) = non_empty(Some(query.command_id)) else {
        return bad_request("onlyoffice_bridge_command_required", "缺少 commandId");
    };
    if let Err(response) = ensure_session_token(&session_id, query.token.as_deref()) {
        return response;
    }
    let timeout = query
        .timeout_ms
        .unwrap_or(MAX_LONG_POLL_MS)
        .min(MAX_LONG_POLL_MS);
    let deadline = Instant::now() + Duration::from_millis(timeout);
    loop {
        if let Some(result) = take_result(&session_id, &command_id) {
            return json_response(json!(result));
        }
        if Instant::now() >= deadline {
            return StatusCode::NO_CONTENT.into_response();
        }
        sleep(Duration::from_millis(LONG_POLL_INTERVAL_MS)).await;
    }
}

pub fn session_info(session_id: &str) -> Option<BridgeSessionInfo> {
    let session_id = session_id.trim();
    if session_id.is_empty() {
        return None;
    }
    session_infos()
        .into_iter()
        .find(|session| session.session_id == session_id)
}

pub fn session_token_matches(session_id: &str, token: &str) -> bool {
    let session_id = session_id.trim();
    let token = token.trim();
    if session_id.is_empty() || token.is_empty() {
        return false;
    }
    let state = bridge_state().lock().expect("bridge state lock poisoned");
    state
        .get(session_id)
        .and_then(|session| session.token.as_deref())
        .is_some_and(|expected| expected == token)
}

pub fn session_infos() -> Vec<BridgeSessionInfo> {
    let state = bridge_state().lock().expect("bridge state lock poisoned");
    let mut sessions = state
        .iter()
        .map(|(session_id, session)| (session_id.clone(), session.last_seen_millis))
        .collect::<Vec<_>>();
    sessions.sort_by(|(_, left), (_, right)| right.cmp(left));
    sessions
        .into_iter()
        .filter_map(|(session_id, _)| {
            let session = state.get(&session_id)?;
            Some(BridgeSessionInfo {
                session_id: session_id.clone(),
                editor_type: session.editor_type.clone(),
                document_id: session.document_id.clone(),
                asset_id: session.asset_id.clone(),
                file_type: session.file_type.clone(),
                doc_key: session.doc_key.clone(),
                page_origin: session.page_origin.clone(),
                last_seen_millis: session.last_seen_millis,
                pending_commands: session.queue.len(),
                pending_results: session.results.len(),
            })
        })
        .collect()
}

pub async fn run_bridge_command(
    session_id: &str,
    action: &str,
    payload: Value,
    timeout_ms: u64,
) -> Result<BridgeResultWire, BridgeRunError> {
    let session_id = session_id.trim();
    if session_id.is_empty() {
        return Err(BridgeRunError::BadRequest(
            "缺少 ONLYOFFICE bridge sessionId".into(),
        ));
    }
    let action = action.trim();
    if action.is_empty() {
        return Err(BridgeRunError::BadRequest(
            "缺少 ONLYOFFICE bridge action".into(),
        ));
    }
    let token = {
        let state = bridge_state().lock().expect("bridge state lock poisoned");
        state
            .get(session_id)
            .and_then(|session| session.token.clone())
            .ok_or_else(|| {
                BridgeRunError::BadRequest("ONLYOFFICE bridge session 未注册或缺少 token".into())
            })?
    };
    let command = push_command(session_id, action.to_string(), payload);
    let timeout = timeout_ms.min(MAX_LONG_POLL_MS);
    let deadline = Instant::now() + Duration::from_millis(timeout);
    loop {
        if ensure_session_token(session_id, Some(&token)).is_err() {
            return Err(BridgeRunError::BadRequest(
                "ONLYOFFICE bridge session token 已失效".into(),
            ));
        }
        if let Some(result) = take_result(session_id, &command.id) {
            return Ok(result);
        }
        if Instant::now() >= deadline {
            return Err(BridgeRunError::Timeout {
                session_id: session_id.to_string(),
                command_id: command.id,
            });
        }
        sleep(Duration::from_millis(LONG_POLL_INTERVAL_MS)).await;
    }
}

#[derive(Debug, Clone)]
pub enum BridgeRunError {
    BadRequest(String),
    Timeout {
        session_id: String,
        command_id: String,
    },
}

fn bridge_state() -> &'static Mutex<HashMap<String, BridgeSessionState>> {
    BRIDGE_STATE.get_or_init(|| Mutex::new(HashMap::new()))
}

fn push_command(session_id: &str, action: String, payload: Value) -> BridgeCommandWire {
    let command = BridgeCommandWire {
        id: format!(
            "oo_cmd_{}_{}",
            now_millis(),
            BRIDGE_COMMAND_COUNTER.fetch_add(1, Ordering::Relaxed)
        ),
        action,
        payload,
    };
    let mut state = bridge_state().lock().expect("bridge state lock poisoned");
    state
        .entry(session_id.to_string())
        .or_default()
        .queue
        .push_back(command.clone());
    command
}

fn pop_command(session_id: &str) -> Option<BridgeCommandWire> {
    let mut state = bridge_state().lock().expect("bridge state lock poisoned");
    let session = state.entry(session_id.to_string()).or_default();
    session.last_seen_millis = now_millis();
    session.queue.pop_front()
}

fn take_result(session_id: &str, command_id: &str) -> Option<BridgeResultWire> {
    let mut state = bridge_state().lock().expect("bridge state lock poisoned");
    state
        .get_mut(session_id)
        .and_then(|session| session.results.remove(command_id))
}

fn non_empty(value: Option<String>) -> Option<String> {
    value
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
}

fn normalize_api_base(value: Option<&str>) -> String {
    value
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("http://127.0.0.1:3000")
        .trim_end_matches('/')
        .to_string()
}

fn encode_index_state(
    session_id: &str,
    api_base: &str,
    token: &str,
    document_id: Option<&str>,
    asset_id: Option<&str>,
    file_type: Option<&str>,
    doc_key: Option<&str>,
    page_origin: Option<&str>,
) -> String {
    let payload = json!(BridgePluginIndexState {
        session_id: session_id.to_string(),
        api_base: api_base.to_string(),
        token: token.to_string(),
        document_id: document_id
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToOwned::to_owned),
        asset_id: asset_id
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToOwned::to_owned),
        file_type: file_type
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToOwned::to_owned),
        doc_key: doc_key
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToOwned::to_owned),
        page_origin: page_origin
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToOwned::to_owned),
    });
    URL_SAFE_NO_PAD.encode(payload.to_string())
}

fn decode_index_state(encoded_state: &str) -> Option<BridgePluginIndexState> {
    let bytes = URL_SAFE_NO_PAD.decode(encoded_state.as_bytes()).ok()?;
    serde_json::from_slice(&bytes).ok()
}

fn json_response(payload: Value) -> Response {
    let mut headers = HeaderMap::new();
    headers.insert(
        header::CONTENT_TYPE,
        HeaderValue::from_static("application/json; charset=utf-8"),
    );
    (StatusCode::OK, headers, payload.to_string()).into_response()
}

fn unauthorized(code: &'static str, message: &'static str) -> Response {
    (
        StatusCode::UNAUTHORIZED,
        Json(json!({
            "ok": false,
            "code": code,
            "message": message
        })),
    )
        .into_response()
}

fn bad_request(code: &'static str, message: &'static str) -> Response {
    (
        StatusCode::BAD_REQUEST,
        Json(json!({
            "ok": false,
            "code": code,
            "message": message
        })),
    )
        .into_response()
}

fn new_bridge_token() -> String {
    Uuid::new_v4().to_string()
}

fn ensure_session_token(session_id: &str, token: Option<&str>) -> Result<(), Response> {
    let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
        return Err(unauthorized(
            "onlyoffice_bridge_token_required",
            "缺少 bridge token",
        ));
    };
    let state = bridge_state().lock().expect("bridge state lock poisoned");
    match state
        .get(session_id)
        .and_then(|session| session.token.as_deref())
    {
        Some(expected) if expected == token => Ok(()),
        Some(_) => Err(unauthorized(
            "onlyoffice_bridge_token_invalid",
            "bridge token 无效",
        )),
        None => Err(unauthorized(
            "onlyoffice_bridge_session_unregistered",
            "bridge session 未注册",
        )),
    }
}

fn json_string(input: &str) -> String {
    serde_json::to_string(input).unwrap_or_else(|_| "\"\"".into())
}

fn now_millis() -> u128 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis()
}

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

    #[tokio::test]
    async fn bridge_plugin_config_exposes_dynamic_index_and_guid() {
        let response = plugin_config(Query(BridgePluginConfigQuery {
            session_id: Some("session-a".into()),
            api_base: Some("http://127.0.0.1:3000/".into()),
            token: Some("token-a".into()),
            document_id: Some("doc-a".into()),
            asset_id: Some("asset-a".into()),
            file_type: Some("xlsx".into()),
            doc_key: Some("doc-key-a".into()),
            page_origin: Some("http://127.0.0.1:3000".into()),
        }))
        .await;
        assert_eq!(response.status(), StatusCode::OK);
        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        assert_eq!(payload["guid"], MNOTE_ONLYOFFICE_BRIDGE_GUID);
        assert_eq!(payload["version"], "0.1.0");
        let url = payload["variations"][0]["url"].as_str().unwrap_or_default();
        assert!(url.contains("/api/onlyoffice/bridge/plugin/index"));
        assert!(!url.contains('?'));
        let encoded_state = url.rsplit('/').next().unwrap_or_default();
        let state = decode_index_state(encoded_state).expect("encoded state");
        assert_eq!(state.session_id, "session-a");
        assert_eq!(state.api_base, "http://127.0.0.1:3000");
        assert_eq!(state.token, "token-a");
        assert_eq!(state.document_id.as_deref(), Some("doc-a"));
        assert_eq!(state.asset_id.as_deref(), Some("asset-a"));
        assert_eq!(state.file_type.as_deref(), Some("xlsx"));
        assert_eq!(state.doc_key.as_deref(), Some("doc-key-a"));
        assert_eq!(state.page_origin.as_deref(), Some("http://127.0.0.1:3000"));
    }

    #[tokio::test]
    async fn bridge_plugin_index_accepts_path_state_with_onlyoffice_query_params() {
        let encoded_state = encode_index_state(
            "session-b",
            "http://127.0.0.1:3000",
            "token-b",
            Some("doc-b"),
            Some("asset-b"),
            Some("pptx"),
            Some("doc-key-b"),
            Some("http://127.0.0.1:3001"),
        );
        let response = plugin_index_with_state(
            Path(encoded_state),
            Query(BridgePluginIndexQuery {
                session_id: None,
                api_base: None,
                token: None,
                document_id: None,
                asset_id: None,
                file_type: None,
                doc_key: None,
                page_origin: None,
            }),
        )
        .await
        .into_response();
        assert_eq!(response.status(), StatusCode::OK);
        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("html");
        assert!(html.contains(r#"var sessionId = "session-b";"#));
        assert!(html.contains(r#"var apiBase = "http://127.0.0.1:3000";"#));
        assert!(html.contains(r#"documentId: "doc-b""#));
        assert!(html.contains(r#"assetId: "asset-b""#));
        assert!(html.contains(r#"fileType: "pptx""#));
        assert!(html.contains(r#"docKey: "doc-key-b""#));
        assert!(html.contains(r#"pageOrigin: "http://127.0.0.1:3001""#));
    }

    #[tokio::test]
    async fn bridge_plugin_index_exposes_second_batch_recipe_actions() {
        let response = plugin_index(Query(BridgePluginIndexQuery {
            session_id: Some("session-recipes".into()),
            api_base: Some("http://127.0.0.1:3000".into()),
            token: Some("token-recipes".into()),
            document_id: None,
            asset_id: None,
            file_type: None,
            doc_key: None,
            page_origin: None,
        }))
        .await
        .into_response();
        assert_eq!(response.status(), StatusCode::OK);
        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let html = String::from_utf8(body.to_vec()).expect("html");
        assert!(html.contains(r#"case "document.search_replace":"#));
        assert!(html.contains(r#"case "document.insert_table":"#));
        assert!(html.contains(r#"case "document.get_comments":"#));
        assert!(html.contains(r#"case "document.add_comment":"#));
        assert!(html.contains(r#"case "sheet.get_sheets":"#));
        assert!(html.contains(r#"case "sheet.add_sheet":"#));
        assert!(html.contains(r#"case "sheet.rename_sheet":"#));
        assert!(html.contains(r#"case "sheet.get_range_values":"#));
        assert!(html.contains(r#"case "sheet.set_range_values":"#));
        assert!(html.contains(r#"case "sheet.format_range":"#));
        assert!(html.contains(r#"case "sheet.set_dimensions":"#));
        assert!(html.contains(r#"case "sheet.sort_range":"#));
        assert!(html.contains(r#"case "sheet.add_chart":"#));
        assert!(html.contains("range.SetSort"));
        assert!(html.contains("worksheet.AddChart"));
        assert!(html.contains(r#"case "presentation.get_slide_texts":"#));
        assert!(html.contains(r#"case "presentation.get_shapes":"#));
        assert!(html.contains(r#"case "presentation.replace_text":"#));
        assert!(html.contains(r#"case "presentation.set_shape_text":"#));
        assert!(html.contains(r#"case "presentation.delete_slide":"#));
        assert!(html.contains(r#"case "presentation.add_table":"#));
        assert!(html.contains(r#"case "presentation.clear_slide":"#));
        assert!(html.contains(r#"case "presentation.add_shape":"#));
        assert!(html.contains("slide.AddObject(table)"));
        assert!(html.contains("slide.RemoveAllObjects()"));
        assert!(html.contains("Api.CreateShape"));
        assert!(html.contains("function expectedEditorTypeForAction(action)"));
        assert!(html.contains(r#"if (name.indexOf("document.") === 0) return "word";"#));
        assert!(html.contains(r#"if (name.indexOf("sheet.") === 0) return "cell";"#));
        assert!(html.contains(r#"if (name.indexOf("presentation.") === 0) return "slide";"#));
        assert!(html.contains("onlyoffice_editor_type_mismatch"));
    }

    #[tokio::test]
    async fn bridge_session_registration_lists_metadata_and_capabilities() {
        let session_id = format!("test-session-meta-{}", now_millis());
        let token = format!("token-{session_id}");
        let registered = register_session(Json(BridgeSessionPayload {
            session_id: session_id.clone(),
            token: Some(token),
            editor_type: Some("slide".into()),
            document_id: Some("doc-meta".into()),
            asset_id: Some("asset-meta".into()),
            file_type: Some("pptx".into()),
            doc_key: Some("doc-key-meta".into()),
            page_origin: Some("http://127.0.0.1:3000".into()),
        }))
        .await;
        assert_eq!(registered.status(), StatusCode::OK);

        let listed = list_sessions().await;
        assert_eq!(listed.status(), StatusCode::OK);
        let body = axum::body::to_bytes(listed.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        let session = payload["sessions"]
            .as_array()
            .expect("sessions")
            .iter()
            .find(|value| value["sessionId"] == session_id)
            .expect("registered session");
        assert_eq!(session["editorType"], "slide");
        assert_eq!(session["documentId"], "doc-meta");
        assert_eq!(session["assetId"], "asset-meta");
        assert_eq!(session["fileType"], "pptx");
        assert_eq!(session["docKey"], "doc-key-meta");
        assert_eq!(session["pageOrigin"], "http://127.0.0.1:3000");

        let current = current_session().await;
        assert_eq!(current.status(), StatusCode::OK);
        let body = axum::body::to_bytes(current.into_body(), usize::MAX)
            .await
            .expect("current body");
        let payload: Value = serde_json::from_slice(&body).expect("current json");
        assert!(payload["session"].is_object() || payload["session"].is_null());

        let closed = close_session(Json(BridgeCloseSessionPayload {
            session_id: session_id.clone(),
            token: Some(format!("token-{session_id}")),
        }))
        .await;
        assert_eq!(closed.status(), StatusCode::OK);
        assert!(session_info(&session_id).is_none());

        let capabilities = capabilities().await;
        let body = axum::body::to_bytes(capabilities.into_body(), usize::MAX)
            .await
            .expect("capabilities body");
        let payload: Value = serde_json::from_slice(&body).expect("capabilities json");
        assert!(payload["actions"]
            .as_array()
            .expect("actions")
            .iter()
            .any(|action| action == "presentation.set_shape_text"));
        assert!(payload["actions"]
            .as_array()
            .expect("actions")
            .iter()
            .any(|action| action == "sheet.format_range"));
        assert!(payload["actions"]
            .as_array()
            .expect("actions")
            .iter()
            .any(|action| action == "presentation.delete_slide"));
        assert!(payload["actions"]
            .as_array()
            .expect("actions")
            .iter()
            .any(|action| action == "document.insert_table"));
        assert!(payload["actions"]
            .as_array()
            .expect("actions")
            .iter()
            .any(|action| action == "document.get_comments"));
        assert!(payload["actions"]
            .as_array()
            .expect("actions")
            .iter()
            .any(|action| action == "document.add_comment"));
        assert!(payload["actions"]
            .as_array()
            .expect("actions")
            .iter()
            .any(|action| action == "sheet.sort_range"));
        assert!(payload["actions"]
            .as_array()
            .expect("actions")
            .iter()
            .any(|action| action == "sheet.add_chart"));
        assert!(payload["actions"]
            .as_array()
            .expect("actions")
            .iter()
            .any(|action| action == "presentation.add_table"));
        assert!(payload["actions"]
            .as_array()
            .expect("actions")
            .iter()
            .any(|action| action == "presentation.clear_slide"));
        assert!(payload["actions"]
            .as_array()
            .expect("actions")
            .iter()
            .any(|action| action == "presentation.add_shape"));
    }

    #[tokio::test]
    async fn bridge_queue_round_trips_command_and_result() {
        let session_id = format!("test-session-{}", now_millis());
        let token = format!("token-{session_id}");
        let registered = register_session(Json(BridgeSessionPayload {
            session_id: session_id.clone(),
            token: Some(token.clone()),
            editor_type: Some("word".into()),
            document_id: None,
            asset_id: None,
            file_type: None,
            doc_key: None,
            page_origin: None,
        }))
        .await;
        assert_eq!(registered.status(), StatusCode::OK);

        let denied = enqueue_command(Json(BridgeEnqueuePayload {
            session_id: session_id.clone(),
            token: None,
            action: "selection.get".into(),
            payload: json!({"sample": true}),
        }))
        .await;
        assert_eq!(denied.status(), StatusCode::UNAUTHORIZED);

        let enqueue = enqueue_command(Json(BridgeEnqueuePayload {
            session_id: session_id.clone(),
            token: Some(token.clone()),
            action: "selection.get".into(),
            payload: json!({"sample": true}),
        }))
        .await;
        assert_eq!(enqueue.status(), StatusCode::OK);
        let body = axum::body::to_bytes(enqueue.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        let command_id = payload["command"]["id"].as_str().unwrap().to_string();

        let next = next_command(Query(BridgeNextQuery {
            session_id: session_id.clone(),
            token: Some(token.clone()),
            timeout_ms: Some(1),
        }))
        .await;
        assert_eq!(next.status(), StatusCode::OK);
        let body = axum::body::to_bytes(next.into_body(), usize::MAX)
            .await
            .expect("body");
        let command: BridgeCommandWire = serde_json::from_slice(&body).expect("command");
        assert_eq!(command.id, command_id);
        assert_eq!(command.action, "selection.get");

        let posted = post_result(Json(json!({
            "sessionId": session_id,
            "token": token,
            "id": command_id,
            "ok": true,
            "result": {"text": "hello"}
        })))
        .await;
        assert_eq!(posted.status(), StatusCode::OK);
    }
}
