/** Doctor diagnostics for pending, paired, and locally cached device auth state. */
import path from "node:path";
import { normalizeUniqueSingleOrTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
import { note } from "../../packages/terminal-core/src/note.js";
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
import { formatCliCommand } from "../cli/command-format.js";
import { quoteCliArg } from "../cli/quote-cli-arg.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { HealthFinding } from "../flows/health-checks.js";
import { callGateway } from "../gateway/call.js";
import {
  listApprovedPairedDeviceRoles,
  listDevicePairing,
  summarizeDeviceTokens,
  type DeviceAuthTokenSummary,
  type DevicePairingPendingRequest,
  type PairedDevice,
} from "../infra/device-pairing.js";
import { JsonFileReadError, tryReadJsonSync } from "../infra/json-files.js";
import type { DeviceAuthStore } from "../shared/device-auth.js";
import { normalizeDeviceAuthScopes } from "../shared/device-auth.js";
import { roleScopesAllow } from "../shared/operator-scope-compat.js";

const DEVICE_PAIRING_CHECK_ID = "core/doctor/device-pairing";

type GatewayListedPairedDevice = Omit<PairedDevice, "tokens" | "approvedScopes"> & {
  tokens?: DeviceAuthTokenSummary[];
};

type GatewayDevicePairingPayload = {
  pending: DevicePairingPendingRequest[];
  paired: GatewayListedPairedDevice[];
};

type DoctorPairedDevice = Omit<PairedDevice, "tokens"> & {
  tokenSummaries: DeviceAuthTokenSummary[];
};

type DoctorPairingSnapshot = {
  pending: DevicePairingPendingRequest[];
  paired: DoctorPairedDevice[];
};

type PendingPairingIssue =
  | {
      kind: "first-time";
      pending: DevicePairingPendingRequest;
      deviceLabel: string;
      approveCommand: string;
      inspectCommand: string;
    }
  | {
      kind: "public-key-repair";
      pending: DevicePairingPendingRequest;
      deviceLabel: string;
      approveCommand: string;
      inspectCommand: string;
      removeCommand: string;
    }
  | {
      kind: "role-upgrade";
      pending: DevicePairingPendingRequest;
      deviceLabel: string;
      approveCommand: string;
      inspectCommand: string;
      approvedRoles: string[];
      requestedRoles: string[];
    }
  | {
      kind: "scope-upgrade";
      pending: DevicePairingPendingRequest;
      deviceLabel: string;
      approveCommand: string;
      inspectCommand: string;
      approvedScopes: string[];
      requestedScopes: string[];
    }
  | {
      kind: "repair";
      pending: DevicePairingPendingRequest;
      deviceLabel: string;
      approveCommand: string;
      inspectCommand: string;
    };

type PairedRecordIssue = {
  kind:
    | "missing-operator-scope-baseline"
    | "missing-active-role-token"
    | "token-outside-approved-scope";
  deviceId: string;
  deviceLabel: string;
  role?: string;
  message: string;
  fixHint?: string;
};

type LocalDeviceAuthIssue = {
  kind: "local-role-no-longer-approved" | "local-token-stale" | "local-scopes-mismatch";
  deviceId: string;
  deviceLabel: string;
  role: string;
  message: string;
  fixHint: string;
};

type StoredDeviceIdentity = {
  version: 1;
  deviceId: string;
};

function hasNumberVersion(value: object): value is { version: number } {
  return "version" in value && typeof value.version === "number";
}

function isDeviceAuthStoreTokenEntry(value: unknown): value is DeviceAuthStore["tokens"][string] {
  return (
    typeof value === "object" &&
    value !== null &&
    "token" in value &&
    typeof value.token === "string" &&
    "role" in value &&
    typeof value.role === "string" &&
    "scopes" in value &&
    Array.isArray(value.scopes) &&
    value.scopes.every((scope) => typeof scope === "string") &&
    "updatedAtMs" in value &&
    typeof value.updatedAtMs === "number"
  );
}

function normalizeGatewayPairedDevice(device: GatewayListedPairedDevice): DoctorPairedDevice {
  return {
    ...device,
    tokenSummaries: device.tokens ?? [],
  };
}

function normalizeLocalPairedDevice(device: PairedDevice): DoctorPairedDevice {
  return {
    ...device,
    tokenSummaries: summarizeDeviceTokens(device.tokens) ?? [],
  };
}

async function loadDoctorPairingSnapshot(params: {
  cfg: OpenClawConfig;
  healthOk: boolean;
}): Promise<DoctorPairingSnapshot | null> {
  if (params.healthOk) {
    try {
      const payload = await callGateway<GatewayDevicePairingPayload>({
        method: "device.pair.list",
        timeoutMs: 5_000,
        config: params.cfg,
      });
      return {
        pending: payload.pending,
        paired: payload.paired.map((device) => normalizeGatewayPairedDevice(device)),
      };
    } catch {
      // Gateway health already reported separately. Fall back to local pairing
      // state when doctor is running against a local gateway.
    }
  }
  if (params.cfg.gateway?.mode === "remote") {
    return null;
  }
  const local = await listDevicePairing();
  return {
    pending: local.pending,
    paired: local.paired.map((device) => normalizeLocalPairedDevice(device)),
  };
}

function resolveApprovedScopes(
  device: Pick<DoctorPairedDevice, "approvedScopes" | "scopes">,
): string[] {
  return normalizeDeviceAuthScopes(device.approvedScopes ?? device.scopes);
}

function formatScopes(scopes: string[]): string {
  return scopes.length > 0 ? scopes.join(", ") : "none";
}

function formatRoles(roles: string[]): string {
  return roles.length > 0 ? roles.join(", ") : "none";
}

function formatCliArgs(args: string[]): string {
  return formatCliCommand(args.map(quoteCliArg).join(" "));
}

function describeDevice(params: {
  deviceId: string;
  displayName?: string;
  clientId?: string;
}): string {
  const label =
    sanitizeTerminalText(params.displayName?.trim() || "") ||
    sanitizeTerminalText(params.clientId?.trim() || "");
  return label ? `${label} (${params.deviceId})` : params.deviceId;
}

function findTokenSummary(
  device: DoctorPairedDevice,
  role: string,
): DeviceAuthTokenSummary | undefined {
  const normalizedRole = role.trim();
  return device.tokenSummaries.find((entry) => entry.role === normalizedRole && !entry.revokedAtMs);
}

function hasPendingScopeUpgrade(params: {
  requestedRoles: string[];
  pendingScopes: string[];
  approvedRoles: string[];
  approvedScopes: string[];
}): boolean {
  for (const role of params.requestedRoles) {
    if (!params.approvedRoles.includes(role)) {
      continue;
    }
    const requestedForRole = params.pendingScopes.filter((scope) =>
      role === "operator" ? scope.startsWith("operator.") : !scope.startsWith("operator."),
    );
    if (requestedForRole.length === 0) {
      continue;
    }
    if (
      !roleScopesAllow({
        role,
        requestedScopes: requestedForRole,
        allowedScopes: params.approvedScopes,
      })
    ) {
      return true;
    }
  }
  return false;
}

function resolvePendingPairingIssue(
  pending: DevicePairingPendingRequest,
  paired: DoctorPairedDevice | undefined,
): PendingPairingIssue {
  const deviceLabel = describeDevice({
    deviceId: pending.deviceId,
    displayName: pending.displayName,
    clientId: pending.clientId,
  });
  const approveCommand = formatCliArgs(["openclaw", "devices", "approve", pending.requestId]);
  const inspectCommand = formatCliArgs(["openclaw", "devices", "list"]);
  if (!paired) {
    return {
      kind: "first-time",
      pending,
      deviceLabel,
      approveCommand,
      inspectCommand,
    };
  }
  if (paired.publicKey !== pending.publicKey) {
    return {
      kind: "public-key-repair",
      pending,
      deviceLabel,
      approveCommand,
      inspectCommand,
      removeCommand: formatCliArgs(["openclaw", "devices", "remove", pending.deviceId]),
    };
  }
  const requestedRoles = normalizeUniqueSingleOrTrimmedStringList(
    [pending.roles, pending.role].flat(),
  );
  const approvedRoles = listApprovedPairedDeviceRoles(paired);
  if (requestedRoles.some((role) => !approvedRoles.includes(role))) {
    return {
      kind: "role-upgrade",
      pending,
      deviceLabel,
      approveCommand,
      inspectCommand,
      approvedRoles,
      requestedRoles,
    };
  }
  const approvedScopes = resolveApprovedScopes(paired);
  const requestedScopes = normalizeDeviceAuthScopes(pending.scopes);
  if (
    hasPendingScopeUpgrade({
      requestedRoles,
      pendingScopes: requestedScopes,
      approvedRoles,
      approvedScopes,
    })
  ) {
    return {
      kind: "scope-upgrade",
      pending,
      deviceLabel,
      approveCommand,
      inspectCommand,
      approvedScopes,
      requestedScopes,
    };
  }
  return {
    kind: "repair",
    pending,
    deviceLabel,
    approveCommand,
    inspectCommand,
  };
}

function formatPendingPairingIssue(issue: PendingPairingIssue): string {
  switch (issue.kind) {
    case "first-time":
      return `- Pending device pairing request ${issue.pending.requestId} for ${issue.deviceLabel}. Review with ${issue.inspectCommand}, then approve with ${issue.approveCommand}.`;
    case "public-key-repair":
      return `- Pending device repair ${issue.pending.requestId} for ${issue.deviceLabel}: the current device identity no longer matches the approved pairing record. This commonly loops on pairing-required for an already paired device. Remove the stale record with ${issue.removeCommand}, then rerun ${issue.inspectCommand} and approve with ${issue.approveCommand}.`;
    case "role-upgrade":
      return `- Pending role upgrade ${issue.pending.requestId} for ${issue.deviceLabel}: approved roles [${formatRoles(issue.approvedRoles)}], requested roles [${formatRoles(issue.requestedRoles)}]. Review with ${issue.inspectCommand}, then approve with ${issue.approveCommand}.`;
    case "scope-upgrade":
      return `- Pending scope upgrade ${issue.pending.requestId} for ${issue.deviceLabel}: approved scopes [${formatScopes(issue.approvedScopes)}], requested scopes [${formatScopes(issue.requestedScopes)}]. Review with ${issue.inspectCommand}, then approve with ${issue.approveCommand}.`;
    case "repair":
      return `- Pending device repair ${issue.pending.requestId} for ${issue.deviceLabel}: the device is already paired, but a new approval is still required before the requested auth can be used. Review with ${issue.inspectCommand}, then approve with ${issue.approveCommand}.`;
  }
  throw new Error("Unsupported pending pairing issue");
}

function collectPendingPairingIssues(snapshot: DoctorPairingSnapshot): PendingPairingIssue[] {
  const pairedByDeviceId = new Map(snapshot.paired.map((device) => [device.deviceId, device]));
  return snapshot.pending.map((pending) =>
    resolvePendingPairingIssue(pending, pairedByDeviceId.get(pending.deviceId)),
  );
}

function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): PairedRecordIssue[] {
  const issues: PairedRecordIssue[] = [];
  for (const device of snapshot.paired) {
    const deviceLabel = describeDevice({
      deviceId: device.deviceId,
      displayName: device.displayName,
      clientId: device.clientId,
    });
    const approvedRoles = listApprovedPairedDeviceRoles(device);
    const approvedScopes = resolveApprovedScopes(device);
    if (approvedRoles.includes("operator") && approvedScopes.length === 0) {
      issues.push({
        kind: "missing-operator-scope-baseline",
        deviceId: device.deviceId,
        deviceLabel,
        message: `Paired device ${deviceLabel} is missing its approved operator scope baseline. Scope upgrades can get stuck in pairing-required until the device repairs or is re-approved.`,
      });
    }
    for (const role of approvedRoles) {
      const token = findTokenSummary(device, role);
      const rotateCommand = formatCliArgs([
        "openclaw",
        "devices",
        "rotate",
        "--device",
        device.deviceId,
        "--role",
        role,
      ]);
      if (!token) {
        issues.push({
          kind: "missing-active-role-token",
          deviceId: device.deviceId,
          deviceLabel,
          role,
          message: `Paired device ${deviceLabel} has no active ${role} device token even though the role is approved. This commonly ends in pairing-required or device-token-mismatch. Rotate a fresh token with ${rotateCommand}.`,
          fixHint: `Rotate a fresh token with ${rotateCommand}.`,
        });
        continue;
      }
      if (
        token.scopes.length > 0 &&
        !roleScopesAllow({
          role,
          requestedScopes: token.scopes,
          allowedScopes: approvedScopes,
        })
      ) {
        issues.push({
          kind: "token-outside-approved-scope",
          deviceId: device.deviceId,
          deviceLabel,
          role,
          message: `Paired device ${deviceLabel} has a ${role} token outside the approved scope baseline [${formatScopes(approvedScopes)}]. Rotate it with ${rotateCommand}.`,
          fixHint: `Rotate it with ${rotateCommand}.`,
        });
      }
    }
  }
  return issues;
}

function formatPairedRecordIssue(issue: PairedRecordIssue): string {
  return `- ${issue.message}`;
}

function readJsonFile(filePath: string): unknown {
  return tryReadJsonSync(filePath);
}

function readLocalIdentity(env: NodeJS.ProcessEnv = process.env): StoredDeviceIdentity | null {
  const filePath = path.join(resolveStateDir(env), "identity", "device.json");
  const identity = readJsonFile(filePath);
  if (
    !identity ||
    typeof identity !== "object" ||
    !hasNumberVersion(identity) ||
    identity.version !== 1 ||
    !("deviceId" in identity) ||
    typeof identity.deviceId !== "string" ||
    !identity.deviceId.trim()
  ) {
    return null;
  }
  return {
    version: 1,
    deviceId: identity.deviceId,
  };
}

function readLocalDeviceAuthStore(env: NodeJS.ProcessEnv = process.env): DeviceAuthStore | null {
  const filePath = path.join(resolveStateDir(env), "identity", "device-auth.json");
  const store = readJsonFile(filePath);
  if (
    !store ||
    typeof store !== "object" ||
    !hasNumberVersion(store) ||
    store.version !== 1 ||
    !("deviceId" in store) ||
    typeof store.deviceId !== "string" ||
    !store.deviceId.trim() ||
    !("tokens" in store) ||
    typeof store.tokens !== "object" ||
    store.tokens === null
  ) {
    return null;
  }
  const tokens: DeviceAuthStore["tokens"] = {};
  for (const [role, entry] of Object.entries(store.tokens)) {
    if (!isDeviceAuthStoreTokenEntry(entry)) {
      return null;
    }
    tokens[role] = entry;
  }
  return {
    version: 1,
    deviceId: store.deviceId,
    tokens,
  };
}

function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): LocalDeviceAuthIssue[] {
  const identity = readLocalIdentity();
  const store = readLocalDeviceAuthStore();
  if (!identity || !store || store.deviceId !== identity.deviceId) {
    return [];
  }
  const paired = snapshot.paired.find((device) => device.deviceId === identity.deviceId);
  if (!paired) {
    return [];
  }
  const deviceLabel = describeDevice({
    deviceId: paired.deviceId,
    displayName: paired.displayName,
    clientId: paired.clientId,
  });
  const issues: LocalDeviceAuthIssue[] = [];
  const approvedRoles = new Set(listApprovedPairedDeviceRoles(paired));
  for (const entry of Object.values(store.tokens)) {
    const role = entry.role.trim();
    if (!role) {
      continue;
    }
    const pairedToken = findTokenSummary(paired, role);
    if (!pairedToken) {
      if (approvedRoles.has(role)) {
        continue;
      }
      issues.push({
        kind: "local-role-no-longer-approved",
        deviceId: paired.deviceId,
        deviceLabel,
        role,
        message: `Local cached ${role} device auth for ${deviceLabel} no longer has a matching active gateway token, and that role is no longer approved for this device. Reconnect with shared gateway auth to refresh local auth, or remove the stale cached ${role} auth entry.`,
        fixHint: `Reconnect with shared gateway auth to refresh local auth, or remove the stale cached ${role} auth entry.`,
      });
      continue;
    }
    const rotateCommand = formatCliArgs([
      "openclaw",
      "devices",
      "rotate",
      "--device",
      paired.deviceId,
      "--role",
      role,
    ]);
    const gatewayIssuedAtMs = pairedToken.rotatedAtMs ?? pairedToken.createdAtMs;
    // Local device auth survives gateway restarts; compare timestamps to catch stale cached tokens.
    if (entry.updatedAtMs < gatewayIssuedAtMs) {
      issues.push({
        kind: "local-token-stale",
        deviceId: paired.deviceId,
        deviceLabel,
        role,
        message: `Local cached ${role} device token for ${deviceLabel} predates the gateway rotation. This is a stale device-token pattern and can fail with device token mismatch. Reconnect with shared gateway auth to refresh it, or rotate again with ${rotateCommand}.`,
        fixHint: `Reconnect with shared gateway auth to refresh it, or rotate again with ${rotateCommand}.`,
      });
      continue;
    }
    const cachedScopes = normalizeDeviceAuthScopes(entry.scopes);
    const pairedScopes = normalizeDeviceAuthScopes(pairedToken.scopes);
    if (cachedScopes.join("\n") !== pairedScopes.join("\n")) {
      issues.push({
        kind: "local-scopes-mismatch",
        deviceId: paired.deviceId,
        deviceLabel,
        role,
        message: `Local cached ${role} device scopes for ${deviceLabel} differ from the gateway record. Cached scopes [${formatScopes(cachedScopes)}], gateway scopes [${formatScopes(pairedScopes)}]. Reconnect with shared gateway auth to refresh it, or rotate with ${rotateCommand}.`,
        fixHint: `Reconnect with shared gateway auth to refresh it, or rotate with ${rotateCommand}.`,
      });
    }
  }
  return issues;
}

function formatLocalDeviceAuthIssue(issue: LocalDeviceAuthIssue): string {
  return `- ${issue.message}`;
}

function formatPairingStoreReadIssue(error: JsonFileReadError): string {
  const problem = error.reason === "parse" ? "contains invalid JSON" : "could not be read";
  return `- Device pairing store ${error.filePath} ${problem}. OpenClaw refused to treat it as empty to avoid overwriting approved pairings. Fix the JSON or file permissions, or move it aside and re-pair devices.`;
}

function stripListMarker(message: string): string {
  return message.startsWith("- ") ? message.slice(2) : message;
}

function pendingPairingIssueToHealthFinding(issue: PendingPairingIssue): HealthFinding {
  const fixHint =
    issue.kind === "public-key-repair"
      ? `Remove the stale record with ${issue.removeCommand}, then rerun ${issue.inspectCommand} and approve with ${issue.approveCommand}.`
      : `Review with ${issue.inspectCommand}, then approve with ${issue.approveCommand}.`;
  return {
    checkId: DEVICE_PAIRING_CHECK_ID,
    severity: "warning",
    message: stripListMarker(formatPendingPairingIssue(issue)),
    path: "devices.pending",
    target: `${issue.pending.deviceId}:${issue.pending.requestId}`,
    requirement: issue.kind,
    fixHint,
  };
}

function pairedRecordIssueToHealthFinding(issue: PairedRecordIssue): HealthFinding {
  return {
    checkId: DEVICE_PAIRING_CHECK_ID,
    severity: "warning",
    message: issue.message,
    path: "devices.paired",
    target: issue.role ? `${issue.deviceId}:${issue.role}` : issue.deviceId,
    requirement: issue.kind,
    ...(issue.fixHint ? { fixHint: issue.fixHint } : {}),
  };
}

function localDeviceAuthIssueToHealthFinding(issue: LocalDeviceAuthIssue): HealthFinding {
  return {
    checkId: DEVICE_PAIRING_CHECK_ID,
    severity: "warning",
    message: issue.message,
    path: "identity.device-auth",
    target: `${issue.deviceId}:${issue.role}`,
    requirement: issue.kind,
    fixHint: issue.fixHint,
  };
}

function pairingStoreReadIssueToHealthFinding(error: JsonFileReadError): HealthFinding {
  return {
    checkId: DEVICE_PAIRING_CHECK_ID,
    severity: "warning",
    message: stripListMarker(formatPairingStoreReadIssue(error)),
    path: error.filePath,
    requirement: `pairing-store-${error.reason}`,
    fixHint: "Fix the JSON or file permissions, or move the store aside and re-pair devices.",
  };
}

export async function collectDevicePairingHealthFindings(params: {
  cfg: OpenClawConfig;
  healthOk?: boolean;
}): Promise<HealthFinding[]> {
  let snapshot: DoctorPairingSnapshot | null;
  try {
    snapshot = await loadDoctorPairingSnapshot({
      cfg: params.cfg,
      healthOk: params.healthOk ?? false,
    });
  } catch (error) {
    if (error instanceof JsonFileReadError) {
      return [pairingStoreReadIssueToHealthFinding(error)];
    }
    throw error;
  }
  if (!snapshot) {
    return [];
  }
  return [
    ...collectPendingPairingIssues(snapshot).map(pendingPairingIssueToHealthFinding),
    ...collectPairedRecordIssues(snapshot).map(pairedRecordIssueToHealthFinding),
    ...collectLocalDeviceAuthIssues(snapshot).map(localDeviceAuthIssueToHealthFinding),
  ];
}

/**
 * Emits device pairing repair guidance from live gateway state or local pairing files.
 *
 * Remote gateways only report through the gateway API; local gateways can fall back to on-disk
 * pairing state when the gateway is down.
 */
export async function noteDevicePairingHealth(params: {
  cfg: OpenClawConfig;
  healthOk: boolean;
}): Promise<void> {
  let snapshot: DoctorPairingSnapshot | null;
  try {
    snapshot = await loadDoctorPairingSnapshot(params);
  } catch (error) {
    if (error instanceof JsonFileReadError) {
      note(formatPairingStoreReadIssue(error), "Device pairing");
      return;
    }
    throw error;
  }
  if (!snapshot) {
    return;
  }
  const lines = [
    ...collectPendingPairingIssues(snapshot).map(formatPendingPairingIssue),
    ...collectPairedRecordIssues(snapshot).map(formatPairedRecordIssue),
    ...collectLocalDeviceAuthIssues(snapshot).map(formatLocalDeviceAuthIssue),
  ];
  if (lines.length === 0) {
    return;
  }
  note(lines.join("\n"), "Device pairing");
}
