import tempfile
import unittest
from pathlib import Path

from fastapi import FastAPI
from fastapi.testclient import TestClient

from web.static_files import NoCacheStaticFiles

ROOT = Path(__file__).resolve().parents[1]
STATIC_DIR = ROOT / "web" / "static"
DEV_HTML = STATIC_DIR / "dev.html"
ADMIN_HTML = STATIC_DIR / "admin.html"
UI_CSS = STATIC_DIR / "ui.css"
JS_DIR = STATIC_DIR / "js"
VENDOR_DIR = STATIC_DIR / "vendor" / "markdown"
MERMAID_VENDOR_DIR = STATIC_DIR / "vendor" / "mermaid"
PDFJS_VENDOR_DIR = STATIC_DIR / "vendor" / "pdfjs"


def _frontend_source() -> str:
    """dev.html + 拆出的 ES module 源(路径 1 模块化后逻辑散落 js/*.js)合并文本。"""
    parts = [DEV_HTML.read_text(encoding="utf-8")]
    parts += [p.read_text(encoding="utf-8") for p in sorted(JS_DIR.glob("*.js"))]
    return "\n".join(parts)


class StaticVendorTests(unittest.TestCase):
    def test_dev_console_has_embed_safe_software_job_workspace(self) -> None:
        html = DEV_HTML.read_text(encoding="utf-8")
        source = (JS_DIR / "software_jobs.js").read_text(encoding="utf-8")
        chat_source = (JS_DIR / "chat.js").read_text(encoding="utf-8")
        self.assertIn('id="right-tab-jobs"', html)
        self.assertIn('id="right-jobs-panel"', html)
        self.assertIn('id="software-job-badge"', html)
        self.assertIn(".right-workspace-panel {", html)
        self.assertLess(
            html.index('id="right-tab-jobs"'),
            html.index('id="storage-foot"'),
        )
        self.assertNotIn('id="software-job-panel"', html)
        self.assertIn("before_created_at", source)
        self.assertIn("before_job_id", source)
        self.assertIn("onListScroll", source)
        self.assertIn("/cancel`", source)
        self.assertIn("深入分析", source)
        self.assertIn("查看结果", source)
        self.assertIn("Job ID", source)
        self.assertIn("工程关系：${escapeHtml(workspaceRelation)}", source)
        self.assertIn("续接自 Job ${sourceJobId.slice(0, 8)}", source)
        self.assertIn("<dt>来源 Job</dt>", source)
        self.assertIn("refreshCurrentTaskAfterSoftwareJob(job.task_id)", source)
        self.assertIn("job.execution_runtime", source)
        self.assertIn("执行版本未记录", source)
        self.assertIn("执行版本</dt><dd>${escapeHtml(jobRuntimeText(job))}", source)
        self.assertIn(
            'document.addEventListener("software-job-submitted", refreshSoftwareJobs)',
            source,
        )
        self.assertIn("export function refreshCurrentTaskFiles(taskId)", chat_source)
        self.assertIn("export async function openSoftwareJobResults", chat_source)
        self.assertIn(
            'document.dispatchEvent(new Event("software-job-submitted"))',
            chat_source,
        )

    def test_admin_can_create_windows_node_enrollment_code(self) -> None:
        html = ADMIN_HTML.read_text(encoding="utf-8")
        admin_js = (JS_DIR / "admin.js").read_text(encoding="utf-8")

        self.assertIn('id="node-enrollment-modal" class="modal"', html)
        self.assertIn("生成 Windows Node 注册码", html)
        self.assertIn('"/v1/admin/software-node-enrollments"', admin_js)
        self.assertNotIn('capabilities: ["origin.plot@v2"]', admin_js)
        self.assertIn("节点能力由客户端自动发现并上报", html)
        self.assertIn("runtime.capability_runtime", admin_js)
        self.assertIn('item.health === "ready"', admin_js)
        self.assertIn("item.adapter_version", admin_js)
        self.assertIn("item.software_version", admin_js)
        self.assertIn("可用能力 / 软件版本", admin_js)
        self.assertNotIn("自动发现能力", admin_js)
        self.assertIn(".node-capability { display: flex", html)
        self.assertIn('runtime.origin ? [["origin.plot@v2", runtime.origin]]', admin_js)
        self.assertIn("ttl_seconds: 600", admin_js)
        self.assertIn("navigator.clipboard.writeText(value)", admin_js)
        self.assertIn('apiGet("/v1/admin/software-nodes")', admin_js)
        self.assertIn('apiSend("PATCH", `/v1/admin/software-nodes/${node.node_id}`', admin_js)
        self.assertIn('apiSend("DELETE", `/v1/admin/software-nodes/${node.node_id}`', admin_js)
        self.assertIn("最近心跳", admin_js)
        self.assertIn("重新启用", admin_js)
        self.assertIn("永久删除", admin_js)
        self.assertNotIn("localStorage.setItem", admin_js)

    def test_dev_html_uses_local_markdown_vendor_assets(self) -> None:
        html = DEV_HTML.read_text(encoding="utf-8")

        self.assertNotIn("cdn.jsdelivr.net", html)
        self.assertIn('src="vendor/markdown/marked.umd.js"', html)
        self.assertIn('src="vendor/markdown/purify.min.js"', html)
        self.assertIn('src="vendor/markdown/highlight.min.js"', html)
        self.assertIn('src="vendor/mermaid/mermaid.min.js"', html)
        self.assertIn('href="vendor/markdown/github.min.css"', html)

        for name in (
            "marked.umd.js",
            "purify.min.js",
            "highlight.min.js",
            "github.min.css",
        ):
            path = VENDOR_DIR / name
            self.assertTrue(path.exists(), f"missing vendored asset: {path}")
            self.assertGreater(path.stat().st_size, 0, f"empty vendored asset: {path}")

        mermaid_bundle = MERMAID_VENDOR_DIR / "mermaid.min.js"
        mermaid_license = MERMAID_VENDOR_DIR / "LICENSE.txt"
        self.assertTrue(mermaid_bundle.exists(), f"missing vendored asset: {mermaid_bundle}")
        self.assertGreater(mermaid_bundle.stat().st_size, 1_000_000)
        self.assertTrue(mermaid_license.exists(), f"missing vendored license: {mermaid_license}")

    def test_pdfjs_assets_are_vendored_with_license(self) -> None:
        for name in ("pdf.min.js", "pdf.worker.min.js", "LICENSE.txt"):
            path = PDFJS_VENDOR_DIR / name
            self.assertTrue(path.exists(), f"missing vendored asset: {path}")
            self.assertGreater(path.stat().st_size, 0, f"empty vendored asset: {path}")

    def test_html_preview_host_is_shipped(self) -> None:
        host = STATIC_DIR / "html_preview_host.html"
        self.assertTrue(host.exists())
        source = host.read_text(encoding="utf-8")
        self.assertIn("zcbot-html-preview", source)
        self.assertIn("event.source !== parent", source)

    def test_dev_html_uses_local_ui_foundation(self) -> None:
        html = DEV_HTML.read_text(encoding="utf-8")

        self.assertIn('href="ui.css"', html)
        self.assertTrue(UI_CSS.exists(), f"missing UI foundation: {UI_CSS}")
        css = UI_CSS.read_text(encoding="utf-8")
        self.assertIn("--accent:", css)
        self.assertIn(":focus-visible", css)
        self.assertIn("@media (max-width: 640px)", css)

    def test_file_toolbar_keeps_hidden_toggle_and_groups_add_actions(self) -> None:
        html = DEV_HTML.read_text(encoding="utf-8")
        files_js = (JS_DIR / "files.js").read_text(encoding="utf-8")

        self.assertRegex(html, r'id="btn-toggle-hidden" class="[^"]*\bsmall\b')
        self.assertNotIn(
            'id="btn-toggle-hidden" class="small" title="显示点目录" '
            'aria-label="显示点目录" aria-pressed="false" hidden',
            html,
        )
        self.assertNotIn("hiddenToggle.hidden", files_js)
        self.assertRegex(html, r'id="btn-file-add" class="[^"]*\bsmall\b[^"]*\bdd-toggle\b')
        self.assertRegex(
            html,
            r'id="btn-file-add"[^>]*aria-label="添加文件或文件夹"[^>]*>＋</button>',
        )
        self.assertIn("@container (max-width: 280px)", html)
        self.assertNotIn('id="btn-new-folder"', html)
        self.assertNotIn('id="btn-src-pick"', html)
        self.assertNotIn('id="btn-upload"', html)
        self.assertIn('{ act: "mkdir", label: "新建文件夹"', files_js)
        self.assertIn('{ act: "pick", label: "从其他目录选入"', files_js)
        self.assertIn('{ act: "upload", label: "上传文件"', files_js)
        self.assertIn('await api("POST", "/v1/files/mkdir"', files_js)

    def test_ui_polish_keeps_mobile_composer_and_reading_rail_contracts(self) -> None:
        html = DEV_HTML.read_text(encoding="utf-8")
        css = UI_CSS.read_text(encoding="utf-8")
        frontend = _frontend_source()

        self.assertIn("--list-divider:", css)
        self.assertIn(".msg.assistant", css)
        self.assertIn(".composer-tool .composer-label", css)
        self.assertIn('aria-label="添加附件"', html)
        self.assertIn("await ensureTaskFromDraft();", frontend)
        self.assertIn("attachments: pendingAttachments.map", frontend)
        self.assertIn("parseUserAttachments", frontend)
        self.assertNotIn("发送第一条消息创建对话后即可添加附件", frontend)
        self.assertIn('aria-label="语音输入"', html)
        self.assertIn('aria-label="润色提示词"', html)
        self.assertIn("hasVisibleAssistantContent", frontend)
        self.assertIn('input.placeholder = mqPhone.matches', frontend)
        self.assertIn("#chat-hint:empty", css)
        self.assertNotIn("请选择文件保存位置", frontend)
        self.assertIn("function setMicButtonState(", frontend)
        self.assertNotIn('_micBtn.textContent = "■ 停止"', frontend)
        self.assertIn("const toolCallsById = new Map()", frontend)
        self.assertIn("const runningDet = finishRunningTool(ctx)", frontend)
        self.assertNotIn('<div class="role">科研助手</div>', frontend)
        self.assertIn(".tool-call.activity > summary", css)
        self.assertIn("#task-progress-dock .task-progress", css)

    def test_frontend_surfaces_context_and_cache_stats(self) -> None:
        src = _frontend_source()

        self.assertIn("formatContextStats", src)
        self.assertIn("context_original_chars", src)
        self.assertIn("context_stripped_reasoning_messages", src)
        self.assertIn("当前请求视图压缩", src)
        self.assertIn("历史已整理", src)
        self.assertIn('p > 1 ? "100%+"', src)
        self.assertIn("cache_hit_tokens", src)

    def test_sse_consumer_yields_for_streaming_text_paints(self) -> None:
        chat_js = (JS_DIR / "chat.js").read_text(encoding="utf-8")

        self.assertIn("renderEventsSincePaint", chat_js)
        self.assertIn("renderEventsSincePaint >= 24", chat_js)
        self.assertIn("await yieldToStreamPaint()", chat_js)
        self.assertIn('document.visibilityState === "hidden"', chat_js)

    def test_custom_task_name_is_optional_with_explicit_directory(self) -> None:
        html = DEV_HTML.read_text(encoding="utf-8")
        newtask_js = (JS_DIR / "newtask.js").read_text(encoding="utf-8")

        self.assertIn("任务名(可选)", html)
        self.assertIn("工作目录(必填)", html)
        self.assertIn('id="nt-wd-combobox" class="new-chat-dir-combobox"', html)
        self.assertIn('id="nt-wd-picker" role="combobox"', html)
        self.assertNotIn('id="nt-wd-sel"', html)
        self.assertNotIn('id="nt-wd-new"', html)
        self.assertIn("if (name) body.name = name;", newtask_js)
        self.assertNotIn("任务名为必填项", newtask_js)

    def test_quick_new_conversation_can_choose_model(self) -> None:
        chat_js = (JS_DIR / "chat.js").read_text(encoding="utf-8")
        state_js = (JS_DIR / "state.js").read_text(encoding="utf-8")
        html = DEV_HTML.read_text(encoding="utf-8")

        self.assertIn('id="new-chat-model-sel"', chat_js)
        self.assertIn("draftModelProfile", state_js)
        self.assertIn("state.draftModelProfile = defaultDraftModelProfile();", chat_js)
        self.assertIn('model_profile: state.draftModelProfile || ""', chat_js)
        self.assertIn("function renderDraftChatMeta()", chat_js)
        self.assertIn("${renderMediaModelTrigger()}", chat_js)
        self.assertIn("bindMediaModelTrigger();", chat_js)
        self.assertIn("#chat-meta.draft-meta { flex-wrap:nowrap; }", html)
        self.assertIn(".mdl-wrap { white-space: nowrap; }", html)
        self.assertNotIn("new-chat-model-row", chat_js)
        self.assertIn("function positionDraftFolderOptions()", chat_js)
        self.assertIn("#new-chat-dir-options { position:fixed;", html)

    def test_embed_initial_task_skips_root_directory_bootstrap(self) -> None:
        main_js = (JS_DIR / "main.js").read_text(encoding="utf-8")
        embed_js = (JS_DIR / "embed.js").read_text(encoding="utf-8")

        self.assertIn('export function enterApp(initialTaskId = "")', main_js)
        self.assertIn("selectTask(initialTaskId);", main_js)
        self.assertIn(
            "showNewConversationDraft();  // 登录空态与“+ 新对话”共用同一草稿页",
            main_js,
        )
        self.assertIn("enterApp(takeInitialTaskId());", embed_js)
        self.assertNotIn("gotoInitialTask()", embed_js)
        self.assertIn('state.token = "";', embed_js)
        self.assertNotIn(
            'if (state.token) {\n    enterApp(takeInitialTaskId());',
            embed_js,
        )

    def test_external_system_connections_use_separate_create_and_edit_modal(self) -> None:
        html = DEV_HTML.read_text(encoding="utf-8")
        external_js = (JS_DIR / "external_systems.js").read_text(encoding="utf-8")

        self.assertIn('id="ext-add" class="primary"', html)
        self.assertIn('id="external-connection-modal" class="modal"', html)
        self.assertLess(html.index('id="external-modal"'), html.index('id="external-connection-modal"'))
        self.assertIn("function unconnectedDefinitions()", external_js)
        self.assertIn("function connectorBadgeHtml(connector)", external_js)
        self.assertIn('class="ext-connector ext-connector-openapi"', external_js)
        self.assertIn('class="ext-connector ext-connector-mcp"', external_js)
        self.assertIn('$("ext-add").onclick = () => openConnectionModal();', external_js)
        self.assertIn("openConnectionModal(systemsById.get(id));", external_js)
        self.assertIn('await api("PUT", `/v1/external-systems/${editingId}/credentials`', external_js)
        self.assertIn(
            "result.operation_count ?? result.tool_count ?? 0",
            external_js,
        )
        self.assertNotIn("function connectionForDefinition(", external_js)

    def test_static_js_is_served_with_revalidation_header(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            static_dir = Path(tmp)
            (static_dir / "app.js").write_text("export const ok = true;\n", encoding="utf-8")
            app = FastAPI()
            app.mount("/static", NoCacheStaticFiles(directory=str(static_dir)), name="static")
            client = TestClient(app)

            resp = client.get("/static/app.js")

        self.assertEqual(resp.status_code, 200)
        self.assertEqual(resp.headers.get("cache-control"), "no-cache")

    def test_ui_css_is_served_as_css_with_revalidation(self) -> None:
        app = FastAPI()
        app.mount("/static", NoCacheStaticFiles(directory=str(STATIC_DIR)), name="static")
        client = TestClient(app)

        resp = client.get("/static/ui.css")

        self.assertEqual(resp.status_code, 200)
        self.assertTrue(resp.headers.get("content-type", "").startswith("text/css"))
        self.assertEqual(resp.headers.get("cache-control"), "no-cache")
        self.assertIn("--accent:", resp.text)


if __name__ == "__main__":
    unittest.main()
