use crate::app::AppConfig;
use crate::app::AppState;
use crate::error::WebError;
use adapter_onlyoffice::{prepare_proxy_request, sign_config, OnlyOfficeProxyPreparationInput};
use axum::body::{Body, Bytes};
use axum::extract::{Path, Query, State};
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, Uri};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use std::env;
use std::fs;
use std::time::Duration;

const ONLYOFFICE_PROBE_PATH: &str = "/web-apps/apps/api/documents/api.js";
const DEFAULT_ONLYOFFICE_INTERNAL_URL: &str = "http://127.0.0.1:8082";
const ONLYOFFICE_RUNTIME_REWRITE_SNIPPET: &str = r#"<script>
window.__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
(function () {
  try {
    var locationOrigin = location.origin.replace(/\/+$/, '');
    var proxyPrefix = locationOrigin + '/onlyoffice-server';
    var internal = {
      'http://127.0.0.1:8081': true,
      'http://localhost:8081': true,
      'http://127.0.0.1:8082': true,
      'http://localhost:8082': true,
      'https://127.0.0.1:8081': true,
      'https://localhost:8081': true,
      'https://127.0.0.1:8082': true,
      'https://localhost:8082': true
    };

    function rewrite(input) {
      try {
        var raw = String(input || '');
        if (!raw) return input;
        var abs = new URL(raw, location.origin);
        var origin = abs.protocol + '//' + abs.host;
        var isProxyPath = abs.pathname === '/onlyoffice-server' || abs.pathname.indexOf('/onlyoffice-server/') === 0;
        var isCachePath = abs.pathname === '/cache' || abs.pathname.indexOf('/cache/') === 0;
        var sameHostWrongPort = abs.hostname === location.hostname && abs.host !== location.host;

        if ((isProxyPath || isCachePath) && sameHostWrongPort) {
          abs.protocol = location.protocol;
          abs.host = location.host;
          return abs.toString();
        }

        if (location.protocol === 'https:' && abs.protocol === 'http:' && abs.hostname === location.hostname && (isProxyPath || isCachePath)) {
          abs.protocol = 'https:';
          abs.host = location.host;
          return abs.toString();
        }

        if (!internal[origin]) return input;
        if (isCachePath) return locationOrigin + abs.pathname + abs.search + abs.hash;
        return proxyPrefix + abs.pathname + abs.search + abs.hash;
      } catch (_error) {
        return input;
      }
    }

    try {
      var origOpen = XMLHttpRequest.prototype.open;
      XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
        return origOpen.call(this, method, rewrite(url), async, user, password);
      };
    } catch (_error) {}

    try {
      var origFetch = window.fetch;
      if (typeof origFetch === 'function') {
        window.fetch = function (input, init) {
          try {
            if (typeof input === 'string') return origFetch.call(this, rewrite(input), init);
            if (input && typeof input === 'object' && typeof input.url === 'string') {
              return origFetch.call(this, new Request(rewrite(input.url), input), init);
            }
          } catch (_error) {}
          return origFetch.call(this, input, init);
        };
      }
    } catch (_error) {}

    try {
      var origWinOpen = window.open;
      if (typeof origWinOpen === 'function') {
        window.open = function (url, target, features) {
          try {
            if (typeof url === 'string') url = rewrite(url);
          } catch (_error) {}
          return origWinOpen.call(this, url, target, features);
        };
      }
    } catch (_error) {}

    try {
      var loc = window.location;
      var origAssign = loc.assign && loc.assign.bind(loc);
      if (origAssign) {
        loc.assign = function (url) { return origAssign(rewrite(url)); };
      }
      var origReplace = loc.replace && loc.replace.bind(loc);
      if (origReplace) {
        loc.replace = function (url) { return origReplace(rewrite(url)); };
      }
    } catch (_error) {}
  } catch (_error) {}
})();
</script>"#;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OnlyOfficePageQuery {
    file_url: Option<String>,
    file_name: Option<String>,
    file_type: Option<String>,
    asset_id: Option<String>,
    document_id: Option<String>,
    user_id: Option<String>,
    mode: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct OnlyOfficeProxyQuery {
    u: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct OnlyOfficeCallbackQuery {
    #[serde(rename = "assetId")]
    asset_id: Option<String>,
    #[serde(rename = "userId")]
    user_id: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct OnlyOfficeForcesaveQuery {
    #[serde(rename = "assetId")]
    asset_id: Option<String>,
    key: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct OnlyOfficeSignPayload {
    config: Option<Value>,
}

fn env_or_dotenv(key: &str) -> Option<String> {
    if let Ok(value) = env::var(key) {
        let trimmed = value.trim().trim_matches('"').to_string();
        if !trimmed.is_empty() {
            return Some(trimmed);
        }
    }

    let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../../..")
        .join(".env.all");
    let content = fs::read_to_string(root).ok()?;
    for line in content.lines() {
        let line = line.trim_end_matches('\r');
        if line.starts_with('#') || line.trim().is_empty() {
            continue;
        }
        let Some((k, v)) = line.split_once('=') else {
            continue;
        };
        if k.trim() != key {
            continue;
        }
        let trimmed = v.trim().trim_matches('"').to_string();
        if !trimmed.is_empty() {
            return Some(trimmed);
        }
    }
    None
}

fn normalize_http_origin(raw: &str) -> Option<String> {
    let value = raw.trim().trim_end_matches('/');
    if value.is_empty() || value == "/onlyoffice-server" {
        return None;
    }
    let url = reqwest::Url::parse(value).ok()?;
    if url.scheme() != "http" && url.scheme() != "https" {
        return None;
    }
    Some(url.to_string().trim_end_matches('/').to_string())
}

fn onlyoffice_internal_candidates() -> Vec<String> {
    let mut candidates = Vec::new();
    let mut push = |value: Option<String>| {
        let Some(value) = value else {
            return;
        };
        let Some(normalized) = normalize_http_origin(&value) else {
            return;
        };
        if !candidates.contains(&normalized) {
            candidates.push(normalized);
        }
    };

    push(env_or_dotenv("ONLYOFFICE_INTERNAL_URL"));
    if let Some(raw) = env_or_dotenv("ONLYOFFICE_INTERNAL_URL_CANDIDATES") {
        for value in raw.split(',') {
            push(Some(value.to_string()));
        }
    }
    push(Some(DEFAULT_ONLYOFFICE_INTERNAL_URL.into()));
    push(Some("http://127.0.0.1:8081".into()));
    push(Some("http://localhost:8082".into()));
    push(Some("http://localhost:8081".into()));
    if candidates.is_empty() {
        candidates.push(DEFAULT_ONLYOFFICE_INTERNAL_URL.into());
    }
    candidates
}

async fn resolve_onlyoffice_internal_url() -> String {
    let client = reqwest::Client::builder()
        .timeout(Duration::from_millis(2_500))
        .build()
        .unwrap_or_else(|_| reqwest::Client::new());
    let candidates = onlyoffice_internal_candidates();
    for candidate in &candidates {
        let probe_url = format!("{candidate}{ONLYOFFICE_PROBE_PATH}");
        if client
            .head(probe_url)
            .send()
            .await
            .map(|response| response.status().is_success())
            .unwrap_or(false)
        {
            return candidate.clone();
        }
    }
    candidates
        .first()
        .cloned()
        .unwrap_or_else(|| DEFAULT_ONLYOFFICE_INTERNAL_URL.into())
}

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

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

fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
    headers
        .get(name)
        .and_then(|value| value.to_str().ok())
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
}

fn forwarded_public_origin(headers: &HeaderMap) -> (String, String, String) {
    let origin_like = header_value(headers, "origin")
        .or_else(|| header_value(headers, "referer"))
        .and_then(|value| reqwest::Url::parse(&value).ok());
    let forwarded_host_raw = header_value(headers, "x-forwarded-host")
        .or_else(|| header_value(headers, header::HOST.as_str()))
        .or_else(|| {
            origin_like.as_ref().map(|url| match url.port() {
                Some(port) => format!("{}:{port}", url.host_str().unwrap_or_default()),
                None => url.host_str().unwrap_or_default().to_string(),
            })
        })
        .unwrap_or_else(|| "127.0.0.1:3000".into());
    let forwarded_host = forwarded_host_raw
        .split(',')
        .next()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("127.0.0.1:3000")
        .to_string();
    let forwarded_proto = header_value(headers, "x-forwarded-proto")
        .and_then(|value| {
            value
                .split(',')
                .next()
                .map(str::trim)
                .map(ToOwned::to_owned)
        })
        .filter(|value| !value.is_empty())
        .or_else(|| origin_like.as_ref().map(|url| url.scheme().to_string()))
        .unwrap_or_else(|| "http".into());
    let forwarded_port = header_value(headers, "x-forwarded-port")
        .and_then(|value| {
            value
                .split(',')
                .next()
                .map(str::trim)
                .map(ToOwned::to_owned)
        })
        .filter(|value| !value.is_empty())
        .or_else(|| {
            forwarded_host
                .rsplit(':')
                .next()
                .filter(|value| value.chars().all(|ch| ch.is_ascii_digit()))
                .map(ToOwned::to_owned)
        })
        .unwrap_or_else(|| {
            if forwarded_proto == "https" {
                "443".into()
            } else {
                "80".into()
            }
        });
    (forwarded_host, forwarded_proto, forwarded_port)
}

pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response, WebError> {
    let file_url = query.file_url.unwrap_or_default();
    let file_name = query.file_name.unwrap_or_else(|| "附件".into());
    let file_type = query.file_type.unwrap_or_else(|| "docx".into());
    let asset_id = query.asset_id.unwrap_or_default();
    let document_id = query.document_id.unwrap_or_default();
    let user_id = query.user_id.unwrap_or_default();
    let mode = match query.mode.as_deref() {
        Some("view") => "view",
        _ => "edit",
    };

    let html = format!(
        r#"<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>{title}</title>
  <style>
    html, body, #onlyoffice-frame {{ width: 100%; height: 100%; margin: 0; }}
    body {{ overflow: hidden; background: #f7f7f5; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
    .mnote-onlyoffice-error {{ position: fixed; inset: 0; display: none; place-items: center; padding: 24px; color: #2f3437; background: #f7f7f5; }}
    .mnote-onlyoffice-error[data-visible="true"] {{ display: grid; }}
    .mnote-onlyoffice-error > div {{ max-width: 560px; border: 1px solid #e2e2df; background: #fff; padding: 18px 20px; border-radius: 6px; box-shadow: 0 8px 28px rgba(15, 15, 15, .08); }}
    .mnote-onlyoffice-error h1 {{ margin: 0 0 8px; font-size: 16px; font-weight: 600; }}
    .mnote-onlyoffice-error p {{ margin: 0; font-size: 13px; line-height: 1.6; color: #73726e; word-break: break-word; }}
  </style>
</head>
<body>
  <div id="onlyoffice-frame"></div>
  <div id="onlyoffice-error" class="mnote-onlyoffice-error"><div><h1>ONLYOFFICE 加载失败</h1><p id="onlyoffice-error-message"></p></div></div>
  <script>
    window.__MNOTE_ONLYOFFICE_READY__ = false;
    window.__MNOTE_ONLYOFFICE_ERRLOG__ = [];
    const initial = {{
      fileUrl: {file_url},
      fileName: {file_name},
      fileType: {file_type},
      assetId: {asset_id},
      documentId: {document_id},
      userId: {user_id},
      mode: {mode}
    }};
    const MNOTE_AGENT_PLUGIN_GUID = "asc.{{05F87DDF-7B42-4C6F-9F2B-9C77A8D5F4E2}}";
    const pageLocal = location.hostname === "127.0.0.1" || location.hostname === "localhost";
    const proxyOrigin = pageLocal ? "http://host.docker.internal:3000" : location.origin;
    const callbackOrigin = proxyOrigin;

    function showError(message) {{
      window.__MNOTE_ONLYOFFICE_ERRLOG__.push({{ kind: "error", message: String(message || "") }});
      const wrap = document.getElementById("onlyoffice-error");
      const text = document.getElementById("onlyoffice-error-message");
      if (text) text.textContent = String(message || "未知错误");
      if (wrap) wrap.setAttribute("data-visible", "true");
    }}
    window.addEventListener("error", (event) => {{
      const name = event.error && event.error.name ? String(event.error.name) : "";
      const message = event.message || (event.error && event.error.message) || "";
      window.__MNOTE_ONLYOFFICE_ERRLOG__.push({{ kind: "error", name, message }});
      if (name === "NotFoundError" || name === "SecurityError" || message.includes("ServiceWorker")) {{
        event.preventDefault();
      }}
    }});
    window.addEventListener("unhandledrejection", (event) => {{
      const reason = event.reason || {{}};
      const name = reason.name ? String(reason.name) : "";
      const message = reason.message ? String(reason.message) : String(reason || "");
      window.__MNOTE_ONLYOFFICE_ERRLOG__.push({{ kind: "rejection", name, message }});
      if (name === "NotFoundError" || name === "SecurityError" || message.includes("ServiceWorker")) {{
        event.preventDefault();
      }}
    }});

    function hashOnlyOfficeKey(input) {{
      let hash = 0;
      const value = String(input || "");
      for (let i = 0; i < value.length; i += 1) {{
        hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0;
      }}
      return String(Math.abs(hash));
    }}
    function docTypeFromExt(ext) {{
      const value = String(ext || "").toLowerCase();
      if (["ppt", "pptx", "odp"].includes(value)) return "slide";
      if (["xls", "xlsx", "ods", "csv"].includes(value)) return "cell";
      if (value === "pdf") return "pdf";
      return "word";
    }}
    function base64UrlEncodeUtf8(input) {{
      const bytes = new TextEncoder().encode(String(input || ""));
      let binary = "";
      for (const b of bytes) binary += String.fromCharCode(b);
      return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
    }}
    function resolveDocumentUrl(raw) {{
      const value = String(raw || "").trim();
      if (!value) return "";
      try {{
        const url = new URL(value, location.origin);
        const alreadyProxy = url.pathname.includes("/api/onlyoffice/proxy");
        if (alreadyProxy && proxyOrigin) {{
          const origin = new URL(proxyOrigin);
          url.protocol = origin.protocol;
          url.host = origin.host;
          return url.toString();
        }}
        const isLocal = ["127.0.0.1", "localhost", "host.docker.internal"].includes(url.hostname);
        if (proxyOrigin && (isLocal || url.searchParams.has("token"))) {{
          const proxy = new URL("/api/onlyoffice/proxy", proxyOrigin);
          proxy.searchParams.set("u", base64UrlEncodeUtf8(url.toString()));
          return proxy.toString();
        }}
        return url.toString();
      }} catch {{
        return value;
      }}
    }}
    function buildCallbackUrl(assetId, userId) {{
      const callback = new URL("/api/onlyoffice/callback", callbackOrigin || location.origin);
      if (assetId) callback.searchParams.set("assetId", assetId);
      if (userId) callback.searchParams.set("userId", userId);
      return callback.toString();
    }}
    function loadScript(url) {{
      return new Promise((resolve, reject) => {{
        const script = document.createElement("script");
        script.src = url;
        script.onload = resolve;
        script.onerror = () => reject(new Error("无法加载 ONLYOFFICE API: " + url));
        document.head.appendChild(script);
      }});
    }}
    const MNOTE_ONLYOFFICE_FRAME_REV = "mnote-proxy-identity-20260511";
    function withOnlyOfficeFrameRevision(raw) {{
      try {{
        const value = String(raw || "");
        if (!value) return raw;
        const url = new URL(value, location.origin);
        if (url.pathname.includes("/web-apps/apps/documenteditor/main/index.html") && !url.searchParams.has("mnoteProxyRev")) {{
          url.searchParams.set("mnoteProxyRev", MNOTE_ONLYOFFICE_FRAME_REV);
          return url.toString();
        }}
      }} catch {{
        // ignore
      }}
      return raw;
    }}
    function installOnlyOfficeFrameSrcPatch() {{
      try {{
        if (window.__MNOTE_ONLYOFFICE_FRAME_SRC_PATCHED__) return;
        window.__MNOTE_ONLYOFFICE_FRAME_SRC_PATCHED__ = true;
        const originalSetAttribute = Element.prototype.setAttribute;
        Element.prototype.setAttribute = function (name, value) {{
          if (this && this.tagName === "IFRAME" && String(name || "").toLowerCase() === "src") {{
            value = withOnlyOfficeFrameRevision(value);
          }}
          return originalSetAttribute.call(this, name, value);
        }};
        const descriptor = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, "src");
        if (descriptor && descriptor.get && descriptor.set) {{
          Object.defineProperty(HTMLIFrameElement.prototype, "src", {{
            configurable: true,
            enumerable: descriptor.enumerable,
            get: descriptor.get,
            set(value) {{
              return descriptor.set.call(this, withOnlyOfficeFrameRevision(value));
            }}
          }});
        }}
      }} catch {{
        // ignore
      }}
    }}
    async function waitForDocEditorReady(timeoutMs) {{
      const deadline = Date.now() + timeoutMs;
      while (Date.now() < deadline) {{
        if (window.DocsAPI && window.DocsAPI.DocEditor) return;
        await new Promise((resolve) => setTimeout(resolve, 250));
      }}
      throw new Error("ONLYOFFICE DocEditor 初始化超时");
    }}
    async function resolveWhoami() {{
      if (initial.userId) return initial.userId;
      try {{
        const response = await fetch("/api/auth/whoami");
        const payload = await response.json().catch(() => null);
        return String(payload && (payload.userId || payload.id) || "").trim();
      }} catch {{
        return "";
      }}
    }}
    async function resolveAssetUrlAndKey() {{
      let effectiveFileUrl = initial.fileUrl;
      let storageId = "";
      if (initial.assetId) {{
        try {{
          const response = await fetch("/api/media/sign?assetId=" + encodeURIComponent(initial.assetId));
          const payload = await response.json().catch(() => null);
          if (response.ok && payload) {{
            effectiveFileUrl = String(payload.signedUrl || effectiveFileUrl || "").trim();
            storageId = String(payload.asset && payload.asset.storage_id || "").trim();
          }}
        }} catch {{
          // ignore
        }}
      }}
      const docKey = initial.assetId
        ? (storageId ? initial.assetId + "_" + hashOnlyOfficeKey(storageId) : initial.assetId)
        : hashOnlyOfficeKey(String(effectiveFileUrl || "") + "-" + initial.fileName);
      return {{ effectiveFileUrl, storageId, docKey, resolvedFileUrl: resolveDocumentUrl(effectiveFileUrl) }};
    }}
    async function boot() {{
      const userId = await resolveWhoami();
      const fileState = await resolveAssetUrlAndKey();
      if (!fileState.resolvedFileUrl) throw new Error("缺少 fileUrl 参数");
      await loadScript("/onlyoffice-server/web-apps/apps/api/documents/api.js");
      await waitForDocEditorReady(120000);

      const resolvedMode = initial.mode === "view" ? "view" : "edit";
      const config = {{
        width: "100%",
        height: "100%",
        documentType: docTypeFromExt(initial.fileType),
        document: {{
          fileType: initial.fileType,
          title: initial.fileName,
          url: fileState.resolvedFileUrl,
          key: fileState.docKey,
          permissions: {{
            edit: resolvedMode !== "view",
            download: true,
            print: true,
            copy: true
          }}
        }},
        editorConfig: {{
          mode: resolvedMode,
          lang: "zh-CN",
          callbackUrl: buildCallbackUrl(initial.assetId, userId),
          customization: {{
            feedback: {{ visible: false }},
            forcesave: resolvedMode !== "view"
          }}
        }},
        events: {{
          onDocumentReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
          onAppReady: () => {{ window.__MNOTE_ONLYOFFICE_READY__ = true; }},
          onError: (event) => showError(JSON.stringify(event))
        }}
      }};
      window.__MNOTE_ONLYOFFICE_DEBUG__ = {{
        pageOrigin: location.origin,
        baseUrl: "/onlyoffice-server",
        proxyOrigin,
        callbackOrigin,
        fileUrlInput: fileState.effectiveFileUrl,
        resolvedFileUrl: fileState.resolvedFileUrl,
        fileName: initial.fileName,
        fileType: initial.fileType,
        mode: initial.mode,
        resolvedMode,
        assetId: initial.assetId,
        documentId: initial.documentId,
        docKey: fileState.docKey
      }};
      const readyDeadline = Date.now() + 120000;
      const timer = window.setInterval(() => {{
        const root = document.getElementById("onlyoffice-frame");
        const count = (root ? root.querySelectorAll("iframe,canvas").length : 0) + document.body.querySelectorAll("iframe,canvas").length;
        if (count > 0) {{
          window.__MNOTE_ONLYOFFICE_READY__ = true;
          window.clearInterval(timer);
        }} else if (Date.now() > readyDeadline) {{
          window.clearInterval(timer);
        }}
      }}, 500);
      const signResponse = await fetch("/api/onlyoffice/sign", {{
        method: "POST",
        headers: {{ "content-type": "application/json" }},
        body: JSON.stringify({{ config }})
      }});
      const signPayload = await signResponse.json().catch(() => null);
      if (!signResponse.ok) throw new Error(signPayload && signPayload.message || signPayload && signPayload.error || "OnlyOffice 签名失败");
      if (signPayload.token) config.token = signPayload.token;
      if (signPayload.documentToken) config.document.token = signPayload.documentToken;
      if (signPayload.editorConfigToken) config.editorConfig.token = signPayload.editorConfigToken;
      installOnlyOfficeFrameSrcPatch();
      window.__MNOTE_ONLYOFFICE_EDITOR__ = new window.DocsAPI.DocEditor("onlyoffice-frame", config);
    }}
    boot().catch((error) => showError(error && error.message ? error.message : error));
  </script>
</body>
</html>"#,
        title = escape_html(&file_name),
        file_url = json_string(&file_url),
        file_name = json_string(&file_name),
        file_type = json_string(&file_type),
        asset_id = json_string(&asset_id),
        document_id = json_string(&document_id),
        user_id = json_string(&user_id),
        mode = json_string(mode),
    );

    Ok(Html(html).into_response())
}

pub async fn sign(Json(payload): Json<OnlyOfficeSignPayload>) -> Result<Response, WebError> {
    let config = payload.config.ok_or_else(|| {
        WebError::bad_request_code("onlyoffice_sign_config_missing", "缺少 config")
    })?;
    let secret = env_or_dotenv("ONLYOFFICE_JWT_SECRET").unwrap_or_default();
    let tokens = sign_config(&config, &secret)
        .map_err(|error| WebError::internal(format!("OnlyOffice 签名失败: {error}")))?;
    Ok(Json(tokens).into_response())
}

fn proxy_origin_env(key: &str) -> Option<String> {
    env_or_dotenv(key).and_then(|value| normalize_http_origin(&value))
}

pub async fn proxy(
    Query(query): Query<OnlyOfficeProxyQuery>,
    headers: HeaderMap,
    method: Method,
) -> Result<Response, WebError> {
    let encoded_url = query
        .u
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| WebError::bad_request_code("onlyoffice_proxy_url_missing", "缺少 u"))?;
    let prepared = prepare_proxy_request(OnlyOfficeProxyPreparationInput {
        encoded_url: encoded_url.to_string(),
        method: method.as_str().to_string(),
        range: headers
            .get(header::RANGE)
            .and_then(|value| value.to_str().ok())
            .map(ToOwned::to_owned),
        supabase_url: proxy_origin_env("NEXT_PUBLIC_SUPABASE_URL")
            .or_else(|| proxy_origin_env("SUPABASE_URL")),
        supabase_internal_url: proxy_origin_env("SUPABASE_INTERNAL_URL"),
        onlyoffice_storage_host_override: env_or_dotenv(
            "NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE",
        ),
        convex_origin: proxy_origin_env("CONVEX_SELF_HOSTED_URL")
            .or_else(|| proxy_origin_env("NEXT_PUBLIC_CONVEX_URL")),
        supabase_anon_key: env_or_dotenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")
            .or_else(|| env_or_dotenv("SUPABASE_ANON_KEY")),
    })
    .map_err(|error| WebError::bad_request_code("onlyoffice_proxy_prepare_failed", error))?;

    let client = reqwest::Client::new();
    let mut request = client.request(
        reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET),
        &prepared.target_url,
    );
    for item in prepared.forward_headers {
        request = request.header(item.name, item.value);
    }
    let upstream = request.send().await.map_err(|error| {
        WebError::bad_gateway_code(
            "onlyoffice_proxy_fetch_failed",
            format!("回源下载失败: {error}"),
        )
    })?;
    let status = upstream.status();
    let mut response_headers = HeaderMap::new();
    for (name, value) in upstream.headers() {
        if name == header::SET_COOKIE {
            continue;
        }
        response_headers.insert(name, value.clone());
    }
    if method == Method::HEAD {
        let mut response = Response::new(Body::empty());
        *response.status_mut() = status;
        *response.headers_mut() = response_headers;
        return Ok(response);
    }
    let bytes = upstream.bytes().await.map_err(|error| {
        WebError::bad_gateway_code(
            "onlyoffice_proxy_body_failed",
            format!("读取回源文件失败: {error}"),
        )
    })?;
    let mut response = Response::new(Body::from(bytes));
    *response.status_mut() = status;
    *response.headers_mut() = response_headers;
    Ok(response)
}

pub async fn callback(
    State(state): State<AppState>,
    uri: Uri,
    Query(query): Query<OnlyOfficeCallbackQuery>,
    Json(body): Json<Value>,
) -> Response {
    let status = body
        .get("status")
        .and_then(Value::as_i64)
        .unwrap_or_default();
    tracing::info!(
        asset_id = query.asset_id.as_deref().unwrap_or(""),
        user_id = query.user_id.as_deref().unwrap_or(""),
        status,
        "OnlyOffice callback received by mnote-web"
    );
    match proxy_legacy_onlyoffice_json(
        state.config(),
        "/api/onlyoffice/callback",
        uri.query(),
        Some(body),
    )
    .await
    {
        Ok(response) => response,
        Err(error) if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED => (
            axum::http::StatusCode::NOT_IMPLEMENTED,
            Json(json!({
                "error": 1,
                "degraded": true,
                "code": "onlyoffice_legacy_writeback_unavailable",
                "message": error.message(),
            })),
        )
            .into_response(),
        Err(error) => (
            axum::http::StatusCode::BAD_GATEWAY,
            Json(json!({
                "error": 1,
                "degraded": true,
                "code": "onlyoffice_legacy_writeback_failed",
                "message": error.message(),
            })),
        )
            .into_response(),
    }
}

pub async fn forcesave(
    State(state): State<AppState>,
    uri: Uri,
    Query(query): Query<OnlyOfficeForcesaveQuery>,
) -> Result<Response, WebError> {
    let asset_id = query
        .asset_id
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| {
            WebError::bad_request_code("onlyoffice_forcesave_asset_missing", "缺少 assetId")
        })?;
    let key = query
        .key
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| {
            WebError::bad_request_code("onlyoffice_forcesave_key_missing", "缺少 key")
        })?;
    let response = proxy_legacy_onlyoffice_json(
        state.config(),
        "/api/onlyoffice/forcesave",
        uri.query(),
        None,
    )
    .await
    .map_err(|error| {
        if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED {
            return WebError::new(
                axum::http::StatusCode::NOT_IMPLEMENTED,
                "onlyoffice_legacy_writeback_unavailable",
                format!(
                    "OnlyOffice forcesave 未配置 legacy Next 写回链: assetId={asset_id}, key={key}"
                ),
            );
        }
        error
    })?;
    Ok(response)
}

fn legacy_onlyoffice_writeback_base(config: &AppConfig) -> Option<String> {
    config
        .legacy_next_base_url
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(|value| value.trim_end_matches('/').to_string())
}

async fn proxy_legacy_onlyoffice_json(
    config: &AppConfig,
    path: &str,
    query: Option<&str>,
    body: Option<Value>,
) -> Result<Response, WebError> {
    let base = legacy_onlyoffice_writeback_base(config).ok_or_else(|| {
        WebError::new(
            axum::http::StatusCode::NOT_IMPLEMENTED,
            "onlyoffice_legacy_writeback_unavailable",
            "OnlyOffice Rust route 暂未直接写回，且未配置 legacy Next 写回链",
        )
    })?;
    let target = append_path_and_query(&base, path, query);
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(30))
        .build()
        .map_err(|error| {
            WebError::internal(format!(
                "OnlyOffice legacy proxy HTTP 客户端创建失败: {error}"
            ))
        })?;
    let mut request = client
        .post(target)
        .header(header::CONTENT_TYPE, "application/json");
    if let Some(body) = body {
        request = request.json(&body);
    } else {
        request = request.body("{}");
    }
    let upstream = request.send().await.map_err(|error| {
        WebError::bad_gateway_code(
            "onlyoffice_legacy_writeback_failed",
            format!("OnlyOffice legacy 写回请求失败: {error}"),
        )
    })?;
    let status = upstream.status();
    let content_type = upstream
        .headers()
        .get(header::CONTENT_TYPE)
        .cloned()
        .unwrap_or_else(|| HeaderValue::from_static("application/json"));
    let bytes = upstream.bytes().await.map_err(|error| {
        WebError::bad_gateway_code(
            "onlyoffice_legacy_writeback_failed",
            format!("OnlyOffice legacy 写回响应读取失败: {error}"),
        )
    })?;
    let mut response = Response::new(Body::from(bytes));
    *response.status_mut() = status;
    response
        .headers_mut()
        .insert(header::CONTENT_TYPE, content_type);
    Ok(response)
}

fn append_path_and_query(base: &str, path: &str, query: Option<&str>) -> String {
    let mut target = format!(
        "{}/{}",
        base.trim_end_matches('/'),
        path.trim_start_matches('/')
    );
    if let Some(query) = query.filter(|value| !value.is_empty()) {
        target.push('?');
        target.push_str(query);
    }
    target
}

fn request_body_bytes(
    request: Request<Body>,
) -> impl std::future::Future<Output = Result<Bytes, WebError>> {
    async move {
        axum::body::to_bytes(request.into_body(), 32 * 1024 * 1024)
            .await
            .map_err(|error| {
                WebError::bad_request_code(
                    "onlyoffice_server_body_failed",
                    format!("读取请求体失败: {error}"),
                )
            })
    }
}

fn inject_onlyoffice_html_fixups(body: &str) -> String {
    if body.contains("window.__MNOTE_ONLYOFFICE_XHR_REWRITE__") {
        return body.to_string();
    }
    body.replacen(
        "<head>",
        &format!("<head>\n{}\n", ONLYOFFICE_RUNTIME_REWRITE_SNIPPET),
        1,
    )
}

fn strip_hop_by_hop_headers(headers: &mut HeaderMap) {
    for name in [
        header::CONNECTION,
        header::CONTENT_ENCODING,
        header::CONTENT_LENGTH,
        header::HeaderName::from_static("keep-alive"),
        header::HeaderName::from_static("proxy-authenticate"),
        header::HeaderName::from_static("proxy-authorization"),
        header::TE,
        header::TRAILER,
        header::TRANSFER_ENCODING,
        header::UPGRADE,
    ] {
        headers.remove(name);
    }
}

async fn proxy_onlyoffice_path(
    upstream_prefix: &str,
    upstream_path: &str,
    uri: Uri,
    method: Method,
    headers: HeaderMap,
    request: Request<Body>,
) -> Result<Response, WebError> {
    let base = resolve_onlyoffice_internal_url().await;
    let prefix = upstream_prefix.trim_matches('/');
    let normalized_path = upstream_path.trim_start_matches('/');
    let is_cache_path = prefix == "cache" || normalized_path.starts_with("cache/");
    let target_base = if prefix.is_empty() {
        base.clone()
    } else {
        format!("{}/{}", base.trim_end_matches('/'), prefix)
    };
    let target = append_path_and_query(&target_base, upstream_path, uri.query());
    let target_origin = reqwest::Url::parse(&base).ok();
    let body = request_body_bytes(request).await?;
    let client = reqwest::Client::new();
    let mut builder = client.request(
        reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET),
        target,
    );
    for (name, value) in headers.iter() {
        if matches!(
            name.as_str(),
            "host"
                | "connection"
                | "upgrade"
                | "sec-websocket-key"
                | "sec-websocket-version"
                | "accept-encoding"
        ) {
            continue;
        }
        builder = builder.header(name, value);
    }
    // 说明：部分 ONLYOFFICE HTML 需要注入同源代理补丁。这里强制回源明文，
    // 避免 gzip 字节被当成 text/html 注入后在 iframe 中显示乱码。
    builder = builder.header(header::ACCEPT_ENCODING, "identity");
    if let Some(base_url) = target_origin.as_ref() {
        builder = builder.header(header::HOST, base_url.host_str().unwrap_or("127.0.0.1"));
    }
    let (forwarded_host, forwarded_proto, forwarded_port) = forwarded_public_origin(&headers);
    builder = builder
        .header("x-forwarded-host", forwarded_host)
        .header("x-forwarded-proto", forwarded_proto)
        .header("x-forwarded-port", forwarded_port)
        .header("x-forwarded-prefix", "/onlyoffice-server");
    if method != Method::GET && method != Method::HEAD {
        builder = builder.body(body);
    }
    let upstream = builder.send().await.map_err(|error| {
        WebError::bad_gateway_code(
            "onlyoffice_server_proxy_failed",
            format!("OnlyOffice 代理失败: {error}"),
        )
    })?;
    let status = upstream.status();
    let upstream_headers = upstream.headers().clone();
    let mut response_headers = HeaderMap::new();
    for (name, value) in upstream_headers.iter() {
        if name == header::SET_COOKIE {
            continue;
        }
        response_headers.insert(name, value.clone());
    }
    if method == Method::HEAD {
        if is_cache_path {
            let upstream_content_length = upstream_headers.get(header::CONTENT_LENGTH).cloned();
            strip_hop_by_hop_headers(&mut response_headers);
            if let Some(content_length) = upstream_content_length {
                response_headers.insert(header::CONTENT_LENGTH, content_length);
            }
        }
        let mut response = Response::new(Body::empty());
        *response.status_mut() = status;
        *response.headers_mut() = response_headers;
        return Ok(response);
    }
    let bytes = upstream.bytes().await.map_err(|error| {
        WebError::bad_gateway_code(
            "onlyoffice_server_proxy_body_failed",
            format!("读取 OnlyOffice 响应失败: {error}"),
        )
    })?;
    let maybe_html = upstream_headers
        .get(header::CONTENT_TYPE)
        .and_then(|value| value.to_str().ok())
        .map(str::to_ascii_lowercase)
        .map(|value| value.contains("text/html"))
        .unwrap_or(false);
    let mut response = if maybe_html {
        let html = String::from_utf8_lossy(&bytes);
        let body = inject_onlyoffice_html_fixups(&html);
        strip_hop_by_hop_headers(&mut response_headers);
        response_headers.insert(
            header::CACHE_CONTROL,
            header::HeaderValue::from_static("no-store"),
        );
        response_headers.insert(
            header::CONTENT_LENGTH,
            header::HeaderValue::from_str(&body.as_bytes().len().to_string())
                .unwrap_or_else(|_| header::HeaderValue::from_static("0")),
        );
        Response::new(Body::from(body))
    } else {
        if is_cache_path {
            strip_hop_by_hop_headers(&mut response_headers);
            response_headers.insert(
                header::CONTENT_LENGTH,
                header::HeaderValue::from_str(&bytes.len().to_string())
                    .unwrap_or_else(|_| header::HeaderValue::from_static("0")),
            );
        }
        Response::new(Body::from(bytes))
    };
    *response.status_mut() = status;
    *response.headers_mut() = response_headers;
    Ok(response)
}

pub async fn server_proxy(
    State(_state): State<AppState>,
    Path(path): Path<String>,
    uri: Uri,
    method: Method,
    headers: HeaderMap,
    request: Request<Body>,
) -> Result<Response, WebError> {
    proxy_onlyoffice_path("", &path, uri, method, headers, request).await
}

pub async fn cache_proxy(
    State(_state): State<AppState>,
    Path(path): Path<String>,
    uri: Uri,
    method: Method,
    headers: HeaderMap,
    request: Request<Body>,
) -> Result<Response, WebError> {
    proxy_onlyoffice_path("cache", &path, uri, method, headers, request).await
}

#[cfg(test)]
pub fn stable_doc_key(asset_id: &str, storage_id: &str, file_url: &str, file_name: &str) -> String {
    if !asset_id.trim().is_empty() {
        if storage_id.trim().is_empty() {
            return asset_id.trim().to_string();
        }
        return format!("{}_{}", asset_id.trim(), js_hash_abs(storage_id));
    }
    js_hash_abs(&format!("{file_url}-{file_name}"))
}

#[cfg(test)]
fn js_hash_abs(input: &str) -> String {
    let mut hash: i32 = 0;
    for unit in input.encode_utf16() {
        hash = hash
            .wrapping_shl(5)
            .wrapping_sub(hash)
            .wrapping_add(unit as i32);
    }
    hash.abs().to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::{AppConfig, AppState};
    use axum::extract::State;
    use axum::http::StatusCode;
    use serde_json::json;
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    use tokio::net::TcpListener;
    use tokio::sync::oneshot;

    #[test]
    fn stable_doc_key_uses_onlyoffice_safe_characters() {
        let key = stable_doc_key("asset_1", "kg2abc:def", "", "");
        assert!(key.len() <= 128);
        assert!(key
            .chars()
            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-')));
        assert!(key.starts_with("asset_1_"));
    }

    #[test]
    fn onlyoffice_internal_candidates_keep_default_first_after_env() {
        let candidates = onlyoffice_internal_candidates();
        assert!(candidates.contains(&DEFAULT_ONLYOFFICE_INTERNAL_URL.to_string()));
    }

    fn test_state(legacy_next_base_url: Option<String>) -> AppState {
        AppState::new(AppConfig {
            service_name: "mnote-web".into(),
            service_version: "0.1.0".into(),
            bind_addr: "127.0.0.1:0".into(),
            public_bind_addr: "127.0.0.1:3000".into(),
            legacy_next_base_url,
            enable_legacy_next_compat: true,
            enable_debug_shell_routes: false,
            hermes_base_path: "/api/hermes".into(),
            compat_next_base_path: "/api/compat/next".into(),
            convex_url: None,
            convex_admin_key: None,
            allow_dev_fixtures: false,
            query_fixtures_json: None,
            mutation_fixtures_json: None,
            dev_user_id: "dev-user".into(),
            dev_user_name: "开发用户".into(),
            dev_user_email: "dev@mnote.local".into(),
        })
    }

    async fn spawn_legacy_json_server(
        response_body: &'static str,
    ) -> (String, oneshot::Receiver<String>) {
        let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
        let addr = listener.local_addr().expect("addr");
        let (tx, rx) = oneshot::channel();
        tokio::spawn(async move {
            let (mut stream, _) = listener.accept().await.expect("accept");
            let mut buffer = vec![0_u8; 8192];
            let read = stream.read(&mut buffer).await.expect("read");
            let request = String::from_utf8_lossy(&buffer[..read]).to_string();
            let _ = tx.send(request);
            let response = format!(
                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
                response_body.len(),
                response_body
            );
            stream.write_all(response.as_bytes()).await.expect("write");
        });
        (format!("http://{addr}"), rx)
    }

    #[tokio::test]
    async fn onlyoffice_callback_without_legacy_next_fails_explicitly() {
        let response = callback(
            State(test_state(None)),
            "/api/onlyoffice/callback?assetId=asset_1"
                .parse::<Uri>()
                .expect("uri"),
            Query(OnlyOfficeCallbackQuery {
                asset_id: Some("asset_1".into()),
                user_id: None,
            }),
            Json(json!({
                "status": 2,
                "url": "http://127.0.0.1:8082/cache/files/out.docx"
            })),
        )
        .await;

        assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
        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["error"], 1);
        assert_eq!(payload["degraded"], true);
    }

    #[tokio::test]
    async fn onlyoffice_callback_proxies_to_legacy_next_writeback() {
        let (base_url, captured) = spawn_legacy_json_server(r#"{"error":0}"#).await;
        let response = callback(
            State(test_state(Some(base_url))),
            "/api/onlyoffice/callback?assetId=asset_1&userId=user_1"
                .parse::<Uri>()
                .expect("uri"),
            Query(OnlyOfficeCallbackQuery {
                asset_id: Some("asset_1".into()),
                user_id: Some("user_1".into()),
            }),
            Json(json!({
                "status": 2,
                "key": "doc_key",
                "url": "http://127.0.0.1:8082/cache/files/out.docx"
            })),
        )
        .await;
        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        let request = captured.await.expect("captured");

        assert_eq!(payload["error"], 0);
        assert!(request
            .starts_with("POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1"));
        assert!(request.contains(r#""status":2"#));
        assert!(request.contains(r#""key":"doc_key""#));
    }

    #[tokio::test]
    async fn onlyoffice_forcesave_without_legacy_next_is_not_noop_success() {
        let response = forcesave(
            State(test_state(None)),
            "/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key"
                .parse::<Uri>()
                .expect("uri"),
            Query(OnlyOfficeForcesaveQuery {
                asset_id: Some("asset_1".into()),
                key: Some("doc_key".into()),
            }),
        )
        .await
        .map(IntoResponse::into_response)
        .unwrap_or_else(IntoResponse::into_response);

        assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
        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["ok"], false);
        assert_eq!(payload["code"], "onlyoffice_legacy_writeback_unavailable");
        assert_ne!(payload["via"], "mnote-web-rust-noop");
    }

    #[tokio::test]
    async fn onlyoffice_forcesave_proxies_to_legacy_next_writeback() {
        let (base_url, captured) =
            spawn_legacy_json_server(r#"{"ok":true,"via":"forcesave","result":{"error":0}}"#).await;
        let response = forcesave(
            State(test_state(Some(base_url))),
            "/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key"
                .parse::<Uri>()
                .expect("uri"),
            Query(OnlyOfficeForcesaveQuery {
                asset_id: Some("asset_1".into()),
                key: Some("doc_key".into()),
            }),
        )
        .await
        .expect("response");
        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        let payload: Value = serde_json::from_slice(&body).expect("json");
        let request = captured.await.expect("captured");

        assert_eq!(payload["ok"], true);
        assert_eq!(payload["via"], "forcesave");
        assert!(request
            .starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1"));
    }
}
