import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";

import { messageFromError } from "../src/composables/useForm.ts";
import {
  isChunkLoadError,
  shouldReloadForChunkError,
} from "../src/router/guards.ts";
import { resolveDocumentTitle } from "../src/router/title.ts";

const rootDir = path.resolve(
  path.dirname(fileURLToPath(import.meta.url)),
  "..",
);

test("R6 route meta title and chunk recovery match Sub2API navigation pattern", () => {
  assert.equal(resolveDocumentTitle("Gate"), "Gate - HWLAB Cloud Web");
  assert.equal(resolveDocumentTitle("  "), "平台控制台 - HWLAB Cloud Web");
  assert.equal(
    isChunkLoadError(new Error("Failed to fetch dynamically imported module")),
    true,
  );
  assert.equal(
    isChunkLoadError(new Error("Loading CSS chunk 42 failed")),
    true,
  );
  assert.equal(
    isChunkLoadError(
      new Error("Unable to preload CSS for /assets/MdtodoView.css"),
    ),
    true,
  );
  assert.equal(
    isChunkLoadError(new Error("ordinary route guard failure")),
    false,
  );

  const memory = new Map<string, string>();
  const storage = {
    getItem: (key: string) => memory.get(key) ?? null,
    setItem: (key: string, value: string) => {
      memory.set(key, value);
    },
  };
  assert.equal(shouldReloadForChunkError(100_000, storage), true);
  assert.equal(shouldReloadForChunkError(105_000, storage), false);
  assert.equal(shouldReloadForChunkError(111_000, storage), true);
});

test("R6 useForm normalizes nested API errors for toast/error surfaces", () => {
  assert.equal(messageFromError(new Error("plain failure")), "plain failure");
  assert.equal(
    messageFromError({ error: { message: "nested message" } }),
    "nested message",
  );
  assert.equal(
    messageFromError({ error: { code: "permission_denied" } }),
    "permission_denied",
  );
  assert.equal(
    messageFromError({ reason: "blocked_by_gate" }),
    "blocked_by_gate",
  );
});

test("R6 common module map keeps HWLAB views on shared console primitives", () => {
  assert.equal(
    fs.existsSync(path.join(rootDir, "src/components/common/DataTable.vue")),
    false,
    "legacy DataTable should stay removed after DataGrid migration",
  );
  const expectations: Array<[string, string[]]> = [
    [
      "src/views/admin/ProviderProfilesView.vue",
      [
        "AsyncBoundary",
        "DataGrid",
        "ResourceCollection",
        "BaseDialog",
        "ConfirmDialog",
        "TablePageLayout",
        "useTableLoader",
        "useForm",
      ],
    ],
    [
      "src/components/access/AccessMatrixPanel.vue",
      [
        "AsyncBoundary",
        "OperationsWorkspace",
        "AccessUserIndex",
        "AccessPermissionWorkspace",
        "AccessUserInspector",
      ],
    ],
    [
      "src/views/admin/SecretsView.vue",
      [
        "AsyncBoundary",
        "OperationsWorkspace",
        "SecretInventoryIndex",
        "SecretInventoryWorkspace",
      ],
    ],
    [
      "src/views/admin/UsersView.vue",
      [
        "AsyncBoundary",
        "OperationsWorkspace",
        "UserDirectoryIndex",
        "UserDirectoryCollection",
        "UserAccountInspector",
        "ViewModeSwitch",
      ],
    ],
    [
      "src/views/admin/BillingView.vue",
      [
        "AsyncBoundary",
        "OperationsWorkspace",
        "BillingCodeIndex",
        "BillingCodeInspector",
        "ViewModeSwitch",
      ],
    ],
    [
      "src/views/GateView.vue",
      ["AsyncBoundary", "GateWorkspace", "useTableLoader"],
    ],
    [
      "src/views/PerformanceView.vue",
      ["AsyncBoundary", "PerformanceOverview", "PerformanceDetailGrid", "ViewModeSwitch", "useTableLoader"],
    ],
    [
      "src/views/SkillsView.vue",
      [
        "AsyncBoundary",
        "DataGrid",
        "ResourceCollection",
        "VirtualList",
        "BaseDialog",
        "TablePageLayout",
        "useTableLoader",
        "useForm",
      ],
    ],
  ];

  for (const [relativePath, terms] of expectations) {
    const source = read(relativePath);
    for (const term of terms)
      assert.match(
        source,
        new RegExp(`\\b${term}\\b`, "u"),
        `${relativePath} should use ${term}`,
      );
    assert.doesNotMatch(
      source,
      /<table\s/u,
      `${relativePath} should not regress to local raw table markup`,
    );
    if (
      /admin\/(?:Secrets|Users|Billing)View|access\/AccessMatrixPanel/u.test(
        relativePath,
      )
    )
      assert.doesNotMatch(
        source,
        /view-toggle|admin-user-glyph|billing-glyph|BoundedWorkspace|TablePageLayout/u,
        `${relativePath} should not restore private legacy layout or glyph implementations`,
      );
  }
});

test("R6 console semantics keep real icons, native entity lists, and dynamic access columns", () => {
  assert.equal(fs.existsSync(path.join(rootDir, "src/components/console/ConsoleIcon.vue")), false);
  assert.match(read("package.json"), /"lucide-vue-next"/u);
  const viewSwitchSource = read("src/components/console/ViewModeSwitch.vue");
  assert.match(viewSwitchSource, /type \{ Component \}/u);
  assert.match(viewSwitchSource, /<component :is="option\.icon"/u);
  const entityIndexSource = read("src/components/console/EntityIndex.vue");
  assert.match(entityIndexSource, /<ul\b/u);
  assert.match(entityIndexSource, /<li\b/u);
  assert.doesNotMatch(entityIndexSource, /role="listbox"|role="option"/u);
  const accessSource = read("src/components/access/AccessMatrixPanel.vue");
  assert.match(accessSource, /--permission-tool-count/u);
  assert.doesNotMatch(accessSource, /repeat\(6,/u);
  assert.match(accessSource, /if \(!value\) access\.clearSelection\(\)/u);
  assert.match(
    accessSource,
    /function closeInspector\(\): void \{\s*access\.clearSelection\(\);/u,
  );
  const accessStoreSource = read("src/stores/access.ts");
  assert.match(accessStoreSource, /function clearSelection\(\): void/u);
  assert.match(accessStoreSource, /selectedUserId\.value = null/u);
  assert.match(accessStoreSource, /detail\.value = null/u);
  const accessWorkspaceSource = read("src/components/access/AccessPermissionWorkspace.vue");
  assert.doesNotMatch(accessWorkspaceSource, /<button[^>]+role="row"/u);
  assert.match(accessWorkspaceSource, /role="grid"/u);
  assert.match(accessWorkspaceSource, /role="row"/u);
  assert.match(accessWorkspaceSource, /role="gridcell"/u);
  const accessViewSource = read("src/stores/admin-access-view.ts");
  assert.match(accessViewSource, /interface AccessOpenFgaView/u);
  assert.match(accessViewSource, /interface AccessTupleView/u);
  assert.doesNotMatch(read("src/components/access/AccessUserInspector.vue"), /Record<string, unknown>/u);
  assert.doesNotMatch(read("src/components/access/AccessPermissionWorkspace.vue"), /Record<string, unknown>/u);
});

test("R6 MDTODO task rows expose opaque task references", () => {
  const taskTreeSource = read("src/components/mdtodo/MdtodoTaskTree.vue");
  assert.match(taskTreeSource, /:data-task-ref="task\.taskRef"/u);
});

test("R2 console rewrite owns the final visual layer and removes private letter glyphs", () => {
  const mainSource = read("src/main.ts");
  assert.match(mainSource, /console-shell\.css/u);
  assert.match(mainSource, /console-r2\.css/u);
  assert.doesNotMatch(mainSource, /console-legacy\.css/u);

  const migratedViews: Array<[string, string]> = [
    ["src/components/common/EmptyState.vue", "lucide-vue-next"],
    ["src/views/SkillsView.vue", "EntityCard"],
    ["src/views/projects/ProjectsView.vue", "ProjectResourceCard"],
    ["src/views/admin/ProviderProfilesView.vue", "EntityCard"],
    ["src/views/user/ApiKeysView.vue", "ApiKeyResourceCard"],
  ];
  for (const [relativePath, sharedComponent] of migratedViews) {
    const source = read(relativePath);
    assert.match(source, new RegExp(sharedComponent, "u"), `${relativePath} should use ${sharedComponent}`);
    assert.doesNotMatch(source, />\s*(?:SK|PJ|AI|KEY|HW)\s*</u, `${relativePath} should not render private letter glyphs`);
  }

  const r2Styles = read("src/styles/console-r2.css");
  for (const selector of [".platform-sidebar", ".page-command-bar", ".console-status-strip", ".console-data-grid", ".pcb-card", ".mdtodo-workspace"])
    assert.match(r2Styles, new RegExp(selector.replaceAll(".", "\\."), "u"), `R2 visual layer should own ${selector}`);
  assert.match(r2Styles, /@media \(max-width: 1100px\)/u);
  assert.match(r2Styles, /@media \(max-width: 760px\)/u);
  assert.match(r2Styles, /@media \(max-width: 430px\)/u);
  assert.match(r2Styles, /prefers-reduced-motion/u);

  const collectionSource = read("src/components/common/ResourceCollection.vue");
  assert.match(collectionSource, /VirtualList/u);
  assert.match(collectionSource, /ViewModeSwitch/u);
  assert.match(collectionSource, /virtualizeAfter/u);
  assert.match(collectionSource, /itemIcon/u);
  assert.doesNotMatch(collectionSource, /segmented-control/u);
  const cardSource = read("src/components/common/EntityCard.vue");
  assert.match(cardSource, /type \{ Component \}/u);
  assert.match(cardSource, /<component :is="icon"/u);
  assert.match(cardSource, /entity-card-meta/u);
});

test("R2 page rewrites move business markup into owned modules instead of CSS wrappers", () => {
  const moduleExpectations: Array<[string, string[]]> = [
    ["src/components/access/AccessUserIndex.vue", ["EntityIndex", "AccessUserRow"]],
    ["src/components/access/AccessPermissionWorkspace.vue", ["permission-matrix", "FactPath", "ViewModeSwitch"]],
    ["src/components/access/AccessUserInspector.vue", ["InspectorPanel", "Tool grants", "Tuples"]],
    ["src/components/secrets/SecretInventoryIndex.vue", ["EntityIndex", "StatusBadge"]],
    ["src/components/secrets/SecretInventoryWorkspace.vue", ["FactPath", "SectionFrame", "安全边界"]],
    ["src/components/users/UserDirectoryIndex.vue", ["EntityIndex", "Pagination"]],
    ["src/components/users/UserDirectoryCollection.vue", ["lucide-vue-next", "DataGrid", "用户实体网格"]],
    ["src/components/users/UserAccountInspector.vue", ["InspectorPanel", "entitlements", "reservations", "ledger"]],
    ["src/components/billing/BillingCodeIndex.vue", ["EntityIndex", "RedeemCodeSummary"]],
    ["src/components/billing/BillingCodeInspector.vue", ["InspectorPanel", "Credits", "Usage"]],
    ["src/components/hwpod/HwpodDevicesWorkspace.vue", ["HwpodDeviceCard", "DataGrid", "ResourceCollection"]],
    ["src/components/hwpod/HwpodNodesWorkspace.vue", ["挂载 HWPOD", "HwpodReadinessRail", "DataGrid"]],
    ["src/components/hwpod/HwpodOnboardingWorkspace.vue", ["Python single file", "SHA-256", "Staged readiness"]],
    ["src/components/common/OverlaySurface.vue", ["useOverlaySurface", "lucide-vue-next", "Teleport"]],
    ["src/components/mdtodo/MdtodoTaskPanel.vue", ["ContentViewer", "editingBody", "Markdown"]],
    ["src/components/mdtodo/MdtodoReportPanel.vue", ["ContentViewer", "openFullscreen", "closeFullscreen"]],
    ["src/components/dashboard/DashboardWorkspace.vue", ["StatusStrip", "ResourceCollection", "SectionFrame"]],
    ["src/components/usage/UsageWorkspace.vue", ["StatusStrip", "DataGrid", "SectionFrame", "ViewModeSwitch"]],
    ["src/components/billing/UserBillingWorkspace.vue", ["StatusStrip", "DataGrid", "SectionFrame", "BaseDialog"]],
    ["src/components/settings/SettingsWorkspace.vue", ["StatusStrip", "SectionFrame", "InspectorPanel"]],
    ["src/components/help/HelpWorkspace.vue", ["ContentViewer", "SectionFrame", "InspectorPanel"]],
    ["src/components/secrets/ExternalSecretWorkspace.vue", ["StatusStrip", "DataGrid", "ResourceCollection", "ViewModeSwitch"]],
    ["src/components/gate/GateWorkspace.vue", ["StatusStrip", "DataGrid", "InspectorPanel", "Pagination"]],
    ["src/components/performance/PerformanceOverview.vue", ["StatusStrip", "SectionFrame", "ResourceCollection", "performance-line-chart"]],
    ["src/components/performance/PerformanceDetailGrid.vue", ["DataGrid", "SectionFrame"]],
  ];
  for (const [relativePath, terms] of moduleExpectations) {
    const source = read(relativePath);
    for (const term of terms) assert.match(source, new RegExp(term, "u"), `${relativePath} should contain ${term}`);
  }

  const cardMigrations: Array<[string, string]> = [
    ["src/views/SkillsView.vue", "EntityCard"],
    ["src/views/admin/ProviderProfilesView.vue", "EntityCard"],
    ["src/views/projects/ProjectsView.vue", "ProjectResourceCard"],
    ["src/views/user/ApiKeysView.vue", "ApiKeyResourceCard"],
  ];
  for (const [relativePath, component] of cardMigrations) assert.match(read(relativePath), new RegExp(component, "u"));

  assert.doesNotMatch(read("src/views/SkillsView.vue"), /\.skill-card\b|\.skill-glyph\b/u);
  assert.doesNotMatch(read("src/views/admin/ProviderProfilesView.vue"), /\.provider-card\b|\.provider-glyph\b/u);
  assert.doesNotMatch(read("src/views/projects/ProjectsView.vue"), /\.project-card\b|\.project-glyph\b/u);
  assert.doesNotMatch(read("src/views/user/ApiKeysView.vue"), /\.api-key-card\b|\.api-key-glyph\b/u);

  const viewModules: Array<[string, string]> = [
    ["src/views/user/DashboardView.vue", "DashboardWorkspace"],
    ["src/views/user/UsageView.vue", "UsageWorkspace"],
    ["src/views/user/BillingView.vue", "UserBillingWorkspace"],
    ["src/views/SettingsView.vue", "SettingsWorkspace"],
    ["src/views/HelpView.vue", "HelpWorkspace"],
    ["src/views/admin/ExternalSecretDetailView.vue", "ExternalSecretWorkspace"],
    ["src/views/GateView.vue", "GateWorkspace"],
    ["src/views/PerformanceView.vue", "PerformanceOverview"],
  ];
  for (const [relativePath, component] of viewModules) {
    const source = read(relativePath);
    assert.match(source, new RegExp(component, "u"));
    assert.doesNotMatch(source, /<DataGrid\b|<ResourceCollection\b|<section class="data-panel/u, `${relativePath} should delegate business markup to owned modules`);
  }
});

test("R2 visual constraints keep industrial controls bounded and component-owned", () => {
  const changedStyleSources = [
    "src/styles/console-r2.css",
    "src/styles/console-foundation.css",
    "src/components/hwpod/HwpodDeviceCard.vue",
    "src/views/admin/HwpodGroupsView.vue",
    "src/views/projects/ProjectsView.vue",
    "src/views/user/ApiKeysView.vue",
    "src/views/SkillsView.vue",
    "src/views/admin/ProviderProfilesView.vue",
    "src/views/admin/SecretsView.vue",
    "src/components/access/AccessMatrixPanel.vue",
    "src/components/mdtodo/MdtodoToolbar.vue",
    "src/components/mdtodo/MdtodoCreateTaskDialog.vue",
    "src/components/mdtodo/MdtodoInfoDialog.vue",
    "src/components/mdtodo/MdtodoSourceDialog.vue",
    "src/views/LoginView.vue",
    "src/views/RegisterView.vue",
    "src/views/NotFoundView.vue",
    "src/views/opencode/OpenCodeFrameView.vue",
  ].map(read).join("\n");
  for (const declaration of changedStyleSources.matchAll(/letter-spacing:\s*([^;]+);/gu))
    assert.equal(declaration[1]?.trim(), "0", `letter-spacing must be 0, got ${declaration[1]}`);
  assert.doesNotMatch(changedStyleSources, /font-size:\s*clamp\([^)]*(?:vw|vh)/u);
  for (const relativePath of ["src/components/hwpod/HwpodDeviceCard.vue", "src/views/admin/HwpodGroupsView.vue", "src/views/projects/ProjectsView.vue", "src/views/user/ApiKeysView.vue"])
    assert.doesNotMatch(read(relativePath), /border-radius:\s*(?:9|10|11|12)px/u, `${relativePath} should keep new card and command radii at 8px or below`);

  const r2Styles = read("src/styles/console-r2.css");
  assert.doesNotMatch(r2Styles, /\.(?:project-card|skill-card|provider-card|api-key-card|settings-grid|usage-panels|help-body)\b/u);
  assert.doesNotMatch(read("src/components/mdtodo/MdtodoReportPanel.vue"), /<svg\b|>\s*×\s*</u);
  assert.doesNotMatch(read("src/components/mdtodo/MdtodoToolbar.vue"), /<svg\b|>\s*×\s*</u);
  for (const relativePath of ["src/views/SkillsView.vue", "src/views/admin/ProviderProfilesView.vue", "src/views/projects/ProjectsView.vue", "src/views/user/ApiKeysView.vue", "src/views/admin/HwpodGroupsView.vue"])
    assert.match(read(relativePath), /ViewModeSwitch/u, `${relativePath} should use the shared view switch`);
});

test("HWPOD topology keeps connection state separate and owns bounded scrolling", () => {
  const routes = read("src/router/index.ts");
  for (const path of ["/hwpods/devices/:hwpodId?", "/hwpods/nodes/:nodeId?", "/hwpods/onboarding/:nodeId?"]) {
    const route = routes.split(`{ path: "${path}"`)[1]?.split("},")[0] ?? "";
    assert.match(route, /boundedLayout: true/u, `${path} should use bounded AppShell layout`);
  }

  const view = read("src/views/admin/HwpodGroupsView.vue");
  assert.match(view, /\.hwpod-console \{[^}]*height: 100%;[^}]*overflow: hidden;/u);
  assert.match(view, /\.hwpod-workspace \{[^}]*flex: 1 1 auto;/u);
  assert.doesNotMatch(view, /\.hwpod-workspace \{[^}]*height: clamp\(/u);

  const deviceWorkspace = read("src/components/hwpod/HwpodDevicesWorkspace.vue");
  assert.match(deviceWorkspace, /capabilityCoverage\.matched/u);
  assert.match(deviceWorkspace, /capabilityCoverage\.declared/u);
  assert.doesNotMatch(deviceWorkspace, /actualCapabilities\.length\s*\}\}\/\{\{\s*row\.declaredCapabilities\.length/u);
});

test("R6 Sub2API source comparison is recorded in parent/child issue scope", () => {
  const issueBody = readIssueBodyFixture();
  assert.match(issueBody, /Wei-Shaw\/sub2api@v0\.1\.136=a2f76e4/u);
  assert.match(issueBody, /router\/index\.ts/u);
  assert.match(issueBody, /components\/common/u);
  assert.match(issueBody, /useTableLoader\.ts/u);
  assert.match(issueBody, /useForm\.ts/u);
});

function read(relativePath: string): string {
  return fs.readFileSync(path.join(rootDir, relativePath), "utf8");
}

function readIssueBodyFixture(): string {
  return [
    "Sub2API 对照: Wei-Shaw/sub2api@v0.1.136=a2f76e4",
    "router/index.ts api/client.ts components/common/* composables/useTableLoader.ts useForm.ts admin/user CRUD views",
  ].join("\n");
}
