import {
  ServerClient,
  SettingsClient,
} from "@openhands/typescript-client/clients";
import type { ServerInfo as BaseServerInfo } from "@openhands/typescript-client";
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
import { isAuthRequired } from "#/api/agent-server-config";
import {
  getActiveBackend,
  getEffectiveLocalBackend,
  isNoBackend,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import defaults from "../../config/defaults.json";

const AGENT_SERVER_INFO_TIMEOUT_MS = 5000;
const UNKNOWN_AGENT_SERVER_VERSION = "unknown";

export const MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION =
  defaults.compatibility.minimumAgentServer;
export const AGENT_SERVER_UNSUPPORTED_VERSION_ERROR_CODE =
  "AGENT_SERVER_UNSUPPORTED_VERSION";
export const AGENT_SERVER_UNKNOWN_VERSION_ERROR_CODE =
  "AGENT_SERVER_UNKNOWN_VERSION";

/**
 * Sentinel string thrown (as an Error message) when a local backend
 * rejects the configured session API key with HTTP 401.
 * Shared between the backend health probe ({@link validateLocalBackend})
 * and any consumer that needs to detect this specific failure.
 */
export const INVALID_BACKEND_API_KEY_ERROR = "Invalid API key";

export interface AgentServerInfo extends BaseServerInfo {
  sdk_version?: string;
  usable_tools?: string[] | null;
  runtime_services?: unknown;
}

let cachedAgentServerInfo: AgentServerInfo | null = null;
let cachedAgentServerInfoHost: string | null = null;

const getAdvertisedTools = (serverInfo: AgentServerInfo | null) => {
  if (Array.isArray(serverInfo?.usable_tools)) {
    return serverInfo.usable_tools;
  }
  return null;
};

export class AgentServerUnavailableError extends Error {
  readonly details: string | null;
  readonly noBackendConfigured: boolean;

  constructor(
    details?: string | null,
    options?: { noBackendConfigured?: boolean },
  ) {
    const noBackendConfigured = options?.noBackendConfigured ?? false;
    super(
      noBackendConfigured
        ? "No agent server backend is configured yet. Add a backend to get started."
        : "Could not connect to the configured agent server. Make sure it is running and reachable, then reload the page.",
    );
    this.name = "AgentServerUnavailableError";
    this.details = details ?? null;
    this.noBackendConfigured = noBackendConfigured;
  }
}

export const isAgentServerUnavailableError = (
  error: unknown,
): error is AgentServerUnavailableError =>
  error instanceof AgentServerUnavailableError ||
  (typeof error === "object" &&
    error !== null &&
    "name" in error &&
    (error.name === "AgentServerUnavailableError" ||
      error.name === "AgentServerUnsupportedVersionError" ||
      error.name === "AgentServerUnknownVersionError"));

export class AgentServerUnsupportedVersionError extends AgentServerUnavailableError {
  readonly code = AGENT_SERVER_UNSUPPORTED_VERSION_ERROR_CODE;
  readonly actualVersion: string;
  readonly requiredVersion = MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION;

  constructor(actualVersion: string) {
    const message = `Agent Canvas requires agent-server ${MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION} or newer; this backend is running ${actualVersion}. Please upgrade the agent-server backend.`;
    super(message);
    this.name = "AgentServerUnsupportedVersionError";
    this.message = message;
    this.actualVersion = actualVersion;
  }
}

export class AgentServerUnknownVersionError extends AgentServerUnavailableError {
  readonly code = AGENT_SERVER_UNKNOWN_VERSION_ERROR_CODE;
  readonly actualVersion: string | null;
  readonly requiredVersion = MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION;

  constructor(actualVersion: string | null) {
    const reported = actualVersion ? ` It reported "${actualVersion}".` : "";
    const message =
      `Could not determine this backend's agent-server version.${reported} ` +
      `Agent Canvas requires agent-server ${MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION} ` +
      "or newer, but this backend did not return a valid version from " +
      "/server_info. Restart or rebuild the agent-server backend, then try again.";
    super(message);
    this.name = "AgentServerUnknownVersionError";
    this.message = message;
    this.actualVersion = actualVersion;
  }
}

export const isAgentServerUnsupportedVersionError = (
  error: unknown,
): error is AgentServerUnsupportedVersionError =>
  error instanceof AgentServerUnsupportedVersionError ||
  (typeof error === "object" &&
    error !== null &&
    "code" in error &&
    error.code === AGENT_SERVER_UNSUPPORTED_VERSION_ERROR_CODE);

export const isAgentServerUnknownVersionError = (
  error: unknown,
): error is AgentServerUnknownVersionError =>
  error instanceof AgentServerUnknownVersionError ||
  (typeof error === "object" &&
    error !== null &&
    "code" in error &&
    error.code === AGENT_SERVER_UNKNOWN_VERSION_ERROR_CODE);

/**
 * Returns true when the agent-server probe failed with HTTP 401.
 * In public mode this means the stored key is stale (server restarted
 * with a different `LOCAL_BACKEND_API_KEY`). Only meaningful when
 * auth is required — a 401 in local mode is a misconfiguration, not a
 * key-rotation event. Uses {@link isAuthRequired} so both the build-time
 * `VITE_AUTH_REQUIRED` flag and the runtime `window.__AGENT_CANVAS_AUTH_REQUIRED__`
 * injection (used by pre-built static binaries) are honoured.
 */
export const isAgentServerAuthError = (error: unknown): boolean =>
  isAuthRequired() && isSdkHttpStatusError(error, 401);

export function clearCachedAgentServerInfo() {
  cachedAgentServerInfo = null;
  cachedAgentServerInfoHost = null;
}

export function getCachedAgentServerInfo(options?: {
  host?: string | null;
}): AgentServerInfo | null {
  if (options?.host && options.host !== cachedAgentServerInfoHost) {
    return null;
  }
  return cachedAgentServerInfo;
}

export function isAgentServerToolAvailable(toolName: string) {
  const availableTools = getAdvertisedTools(cachedAgentServerInfo);
  if (!Array.isArray(availableTools)) {
    return true;
  }
  return availableTools.includes(toolName);
}

export function isSdkHttpError(error: unknown) {
  return (
    error instanceof Error &&
    error.name === "HttpError" &&
    "status" in error &&
    typeof error.status === "number"
  );
}

/**
 * Narrows an SDK HTTP error to a specific status code.
 * Use instead of manually casting `(err as { status: number }).status`.
 */
export function isSdkHttpStatusError(error: unknown, status: number): boolean {
  return (
    isSdkHttpError(error) && (error as { status: number }).status === status
  );
}

function normalizeAgentServerInfoVersion(version: unknown): string | null {
  if (typeof version !== "string") return null;
  const trimmed = version.trim();
  return trimmed || null;
}

function getRawAgentServerSdkVersion(
  serverInfo: AgentServerInfo,
): string | null {
  return normalizeAgentServerInfoVersion(serverInfo.sdk_version);
}

function getRawAgentServerVersion(serverInfo: AgentServerInfo): string | null {
  return (
    normalizeAgentServerInfoVersion(serverInfo.version) ??
    getRawAgentServerSdkVersion(serverInfo)
  );
}

function getComparableAgentServerVersion(
  serverInfo: AgentServerInfo,
): string | null {
  const version = getRawAgentServerVersion(serverInfo);
  if (!version || version.toLowerCase() === UNKNOWN_AGENT_SERVER_VERSION) {
    return null;
  }
  return version;
}

export function getDisplayAgentServerVersion(
  serverInfo: AgentServerInfo,
): string | null {
  const version = getComparableAgentServerVersion(serverInfo);
  if (!version || !parseAgentServerVersion(version)) {
    return null;
  }
  return version;
}

export function getDisplayAgentServerSdkVersion(
  serverInfo: AgentServerInfo,
): string | null {
  const version =
    getRawAgentServerSdkVersion(serverInfo) ??
    getComparableAgentServerVersion(serverInfo);
  if (!version || version.toLowerCase() === UNKNOWN_AGENT_SERVER_VERSION) {
    return null;
  }
  return version;
}

export function getCachedAgentServerSdkVersion(
  host?: string | null,
): string | null {
  if (!cachedAgentServerInfo) return null;
  if (host && getEffectiveLocalBackend()?.host !== host) return null;
  return getDisplayAgentServerSdkVersion(cachedAgentServerInfo);
}

export function getCachedAgentServerVersion(
  host?: string | null,
): string | null {
  if (!cachedAgentServerInfo) return null;
  if (host && getEffectiveLocalBackend()?.host !== host) return null;
  return getDisplayAgentServerVersion(cachedAgentServerInfo);
}

export function compareAgentServerVersions(actual: string, required: string) {
  const parsedActual = parseAgentServerVersion(actual);
  const parsedRequired = parseAgentServerVersion(required);

  if (!parsedActual || !parsedRequired) {
    return null;
  }

  for (const key of ["major", "minor", "patch"] as const) {
    if (parsedActual[key] > parsedRequired[key]) {
      return 1;
    }
    if (parsedActual[key] < parsedRequired[key]) {
      return -1;
    }
  }

  if (parsedActual.prerelease && !parsedRequired.prerelease) {
    return -1;
  }
  if (!parsedActual.prerelease && parsedRequired.prerelease) {
    return 1;
  }
  if (parsedActual.prerelease && parsedRequired.prerelease) {
    return parsedActual.prerelease.localeCompare(parsedRequired.prerelease);
  }

  return 0;
}

function parseAgentServerVersion(version: string) {
  const trimmed = version.trim().replace(/^v/, "");
  const [withoutBuild] = trimmed.split("+");
  const [core, prerelease] = withoutBuild.split("-", 2);
  const parts = core.split(".");

  if (parts.length !== 3) {
    return null;
  }

  const [major, minor, patch] = parts.map((part) => Number(part));
  if (
    ![major, minor, patch].every((part) => Number.isInteger(part) && part >= 0)
  ) {
    return null;
  }

  return { major, minor, patch, prerelease };
}

export function assertAgentServerVersionIsSupported(
  serverInfo: AgentServerInfo,
) {
  const actualVersion = getComparableAgentServerVersion(serverInfo);
  if (!actualVersion) {
    clearCachedAgentServerInfo();
    throw new AgentServerUnknownVersionError(
      getRawAgentServerVersion(serverInfo),
    );
  }

  const comparison = compareAgentServerVersions(
    actualVersion,
    MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION,
  );

  if (comparison === null) {
    clearCachedAgentServerInfo();
    throw new AgentServerUnknownVersionError(actualVersion);
  }

  if (comparison < 0) {
    clearCachedAgentServerInfo();
    throw new AgentServerUnsupportedVersionError(actualVersion);
  }
}

/**
 * Validates a local agent-server backend with a two-step probe:
 *  1. GET /api/settings — authenticates the configured session API key;
 *     a 401 throws an Error with message {@link INVALID_BACKEND_API_KEY_ERROR}.
 *  2. GET /server_info  — asserts the server meets the minimum version floor.
 *
 * Returns the display version string reported by the server, or `null` when
 * the server does not report a parseable version. Throws on any failure.
 *
 * Used by both the backend health poller and the backend-form connection test
 * so that auth-check semantics (status codes, error messages) stay in one place.
 */
export async function validateLocalBackend(
  backend: Pick<Backend, "host" | "apiKey">,
  timeout: number,
): Promise<string | null> {
  const clientOptions = getAgentServerClientOptions({
    host: backend.host,
    sessionApiKey: backend.apiKey || null,
    timeout,
  });

  try {
    await new SettingsClient(clientOptions).getSettings();
    const serverInfo = await new ServerClient(clientOptions).getServerInfo();
    assertAgentServerVersionIsSupported(serverInfo);
    return getDisplayAgentServerVersion(serverInfo);
  } catch (error) {
    if (isSdkHttpStatusError(error, 401)) {
      throw new Error(INVALID_BACKEND_API_KEY_ERROR);
    }
    throw error;
  }
}

export async function loadAgentServerInfo() {
  // The probe is a *local* agent-server concern — it verifies the runtime
  // hosting the GUI is reachable. It must NEVER run against the active
  // backend when that backend is cloud, because cloud hosts don't
  // expose /api/server_info and would fail with a CORS error besides.
  const local = getEffectiveLocalBackend();
  if (!local) {
    clearCachedAgentServerInfo();

    // Empty registry (NO_BACKEND sentinel) — the user has no backend
    // configured at all.  Throw so root.tsx shows the manage-backends
    // modal instead of silently rendering a broken home page.
    if (isNoBackend(getActiveBackend().backend)) {
      throw new AgentServerUnavailableError("No backend configured", {
        noBackendConfigured: true,
      });
    }

    // Active backend is cloud — no local probe needed.
    return null;
  }

  const clientOptions = getAgentServerClientOptions({
    host: local.host,
    sessionApiKey: local.apiKey || null,
    timeout: AGENT_SERVER_INFO_TIMEOUT_MS,
  });
  let serverInfo: AgentServerInfo;

  try {
    serverInfo = (await new ServerClient(
      clientOptions,
    ).getServerInfo()) as AgentServerInfo;
  } catch (error) {
    clearCachedAgentServerInfo();
    // Preserve 401 so root.tsx can show the auth screen (public mode).
    // All other HTTP errors (502, 503, etc.) mean the server is unreachable
    // or misconfigured — treat them as unavailable.
    if (isSdkHttpStatusError(error, 401)) {
      throw error;
    }

    const details = error instanceof Error ? error.message : null;
    throw new AgentServerUnavailableError(details);
  }

  assertAgentServerVersionIsSupported(serverInfo);

  // /server_info is unprotected, so a stale session key still gets 200.
  // In public mode, validate the key against a protected endpoint so a
  // server restart with a new LOCAL_BACKEND_API_KEY surfaces immediately
  // instead of letting the app load and fail on every subsequent call.
  if (isAuthRequired()) {
    try {
      await new SettingsClient(clientOptions).getSettings();
    } catch (error) {
      // Only rethrow 401 — that means the stored key is invalid /
      // rotated.  Other HTTP errors (403, 5xx) and non-HTTP errors
      // (network, timeout) are swallowed: the server *is* up (we just
      // reached /server_info), so let the app proceed with an
      // unvalidated key rather than blocking the UI.
      // NOTE: If the connection drops between the /server_info and
      // getSettings() probes, the app loads with an unvalidated key and
      // subsequent 401s won't trigger the auth screen (they come from
      // React Query hooks, not this bootstrap path). Acceptable for now
      // since the window is narrow and a page refresh recovers.
      if (isSdkHttpStatusError(error, 401)) {
        throw error;
      }

      console.warn(
        "[agent-server] getSettings() probe failed (non-401):",
        error,
      );
    }
  }

  cachedAgentServerInfo = serverInfo;
  cachedAgentServerInfoHost = clientOptions.host;
  return serverInfo;
}
