/**
 * Process-local cache for Codex app-server app inventories, keyed by runtime
 * identity and safe to refresh in the background.
 */
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
  isFutureDateTimestampMs,
  resolveDateTimestampMs,
  resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { JsonValue, v2 } from "./protocol.js";

/** Default app inventory cache freshness window. */
export const CODEX_APP_INVENTORY_CACHE_TTL_MS = 60 * 60 * 1_000;
const CODEX_TARGETED_APP_INVENTORY_LIMIT = 1_000;
const MAX_SERIALIZED_ERROR_MESSAGE_LENGTH = 500;

/** App-server request function used to list installed/available apps. */
export type CodexAppInventoryRequest = (
  method: "app/list",
  params: v2.AppsListParams,
) => Promise<v2.AppsListResponse>;

/** Runtime identity fields that affect visible Codex app inventory. */
export type CodexAppInventoryCacheKeyInput = {
  codexHome?: string;
  endpoint?: string;
  runtimeIdentity?: Record<string, string | undefined>;
  authProfileId?: string;
  accountId?: string;
  envApiKeyFingerprint?: string;
  appServerVersion?: string;
};

/** Last refresh diagnostic stored with a cache key or snapshot. */
export type CodexAppInventoryCacheDiagnostic = {
  message: string;
  atMs: number;
};

/** Immutable app inventory snapshot returned from cache reads and refreshes. */
export type CodexAppInventorySnapshot = {
  key: string;
  apps: v2.AppInfo[];
  fetchedAtMs: number;
  expiresAtMs: number;
  revision: number;
  lastError?: CodexAppInventoryCacheDiagnostic;
};

/** Freshness state for a cache read. */
export type CodexAppInventoryReadState = "fresh" | "stale" | "missing";

/** Cache read result plus refresh scheduling state. */
export type CodexAppInventoryCacheRead = {
  state: CodexAppInventoryReadState;
  key: string;
  revision: number;
  snapshot?: CodexAppInventorySnapshot;
  refreshScheduled: boolean;
  diagnostic?: CodexAppInventoryCacheDiagnostic;
};

type CacheEntry = CodexAppInventorySnapshot & {
  invalidated: boolean;
};

type RefreshParams = {
  key: string;
  request: CodexAppInventoryRequest;
  nowMs?: number;
  forceRefetch?: boolean;
  suppressRefresh?: boolean;
  targetAppIds?: readonly string[];
};

/** In-memory app inventory cache with coalesced refreshes per key. */
export class CodexAppInventoryCache {
  private readonly ttlMs: number;
  private readonly entries = new Map<string, CacheEntry>();
  private readonly inFlight = new Map<string, Promise<CodexAppInventorySnapshot>>();
  // Per-key refresh generation. Each refresh attempt claims the next token so
  // an older request that finishes late cannot overwrite a newer snapshot.
  private readonly refreshTokens = new Map<string, number>();
  private readonly diagnostics = new Map<string, CodexAppInventoryCacheDiagnostic>();
  private revision = 0;

  constructor(options: { ttlMs?: number } = {}) {
    this.ttlMs = options.ttlMs ?? CODEX_APP_INVENTORY_CACHE_TTL_MS;
  }

  /** Reads a snapshot and schedules refresh when missing, stale, or forced. */
  read(params: RefreshParams): CodexAppInventoryCacheRead {
    const nowMs = resolveDateTimestampMs(params.nowMs);
    const entry = this.entries.get(params.key);
    if (!entry) {
      const refreshScheduled = params.suppressRefresh ? false : this.scheduleRefresh(params);
      return {
        state: "missing",
        key: params.key,
        revision: this.revision,
        refreshScheduled,
        ...(this.diagnostics.get(params.key)
          ? { diagnostic: this.diagnostics.get(params.key) }
          : {}),
      };
    }

    const state: CodexAppInventoryReadState =
      entry.invalidated || !isFutureDateTimestampMs(entry.expiresAtMs, { nowMs })
        ? "stale"
        : "fresh";
    const refreshScheduled =
      state === "fresh" && !params.forceRefetch ? false : this.scheduleRefresh(params);
    return {
      state,
      key: params.key,
      revision: entry.revision,
      snapshot: stripEntryState(entry),
      refreshScheduled,
      ...(entry.lastError ? { diagnostic: entry.lastError } : {}),
    };
  }

  /** Forces or joins an immediate refresh for a cache key. */
  refreshNow(params: RefreshParams): Promise<CodexAppInventorySnapshot> {
    return this.refresh(params);
  }

  /** Marks a key stale and records the reason as a diagnostic. */
  invalidate(key: string, reason: string, nowMs = Date.now()): number {
    this.revision += 1;
    const diagnostic = { message: reason, atMs: nowMs };
    const entry = this.entries.get(key);
    if (entry) {
      entry.invalidated = true;
      entry.lastError = diagnostic;
      entry.revision = this.revision;
    } else {
      this.diagnostics.set(key, diagnostic);
    }
    return this.revision;
  }

  /** Clears all cached snapshots, diagnostics, in-flight requests, and revision state. */
  clear(): void {
    this.entries.clear();
    this.inFlight.clear();
    this.refreshTokens.clear();
    this.diagnostics.clear();
    this.revision = 0;
  }

  /** Returns the monotonically increasing cache revision. */
  getRevision(): number {
    return this.revision;
  }

  private scheduleRefresh(params: RefreshParams): boolean {
    if (this.inFlight.has(params.key) && !params.forceRefetch) {
      return true;
    }
    const promise = this.refresh(params);
    this.inFlight.set(params.key, promise);
    promise.catch(() => undefined);
    return true;
  }

  private async refresh(params: RefreshParams): Promise<CodexAppInventorySnapshot> {
    const existing = this.inFlight.get(params.key);
    if (existing && !params.forceRefetch) {
      return existing;
    }

    const refreshToken = (this.refreshTokens.get(params.key) ?? 0) + 1;
    this.refreshTokens.set(params.key, refreshToken);
    const promise = this.refreshUncoalesced(params, refreshToken);
    this.inFlight.set(params.key, promise);
    try {
      return await promise;
    } finally {
      if (this.inFlight.get(params.key) === promise) {
        this.inFlight.delete(params.key);
      }
    }
  }

  private async refreshUncoalesced(
    params: RefreshParams,
    refreshToken: number,
  ): Promise<CodexAppInventorySnapshot> {
    const nowMs = resolveDateTimestampMs(params.nowMs);
    try {
      const apps = await listAllApps(
        params.request,
        params.forceRefetch ?? false,
        params.targetAppIds,
      );
      this.revision += 1;
      const expiresAtMs = resolveExpiresAtMsFromDurationMs(this.ttlMs, { nowMs }) ?? 0;
      const snapshot: CodexAppInventorySnapshot = {
        key: params.key,
        apps,
        fetchedAtMs: nowMs,
        expiresAtMs,
        revision: this.revision,
      };
      // Only publish this snapshot if no newer refresh started for the same key
      // while this request was in flight.
      if (this.refreshTokens.get(params.key) === refreshToken) {
        this.entries.set(params.key, { ...snapshot, invalidated: false });
        this.diagnostics.delete(params.key);
      }
      return snapshot;
    } catch (error) {
      const diagnostic = {
        message: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)),
        atMs: nowMs,
      };
      this.diagnostics.set(params.key, diagnostic);
      const entry = this.entries.get(params.key);
      if (entry) {
        entry.lastError = diagnostic;
      }
      embeddedAgentLog.warn("codex app inventory refresh failed", {
        forceRefetch: params.forceRefetch === true,
        keyFingerprint: fingerprintInventoryCacheKey(params.key),
        error: serializeCodexAppInventoryError(error),
      });
      throw error;
    }
  }
}

/** Serializes a refresh failure without leaking large or sensitive error data. */
export function serializeCodexAppInventoryError(error: unknown): Record<string, unknown> {
  const record = isRecord(error) ? error : undefined;
  const data = record && "data" in record ? redactErrorData(record.data) : undefined;
  return {
    name:
      error instanceof Error
        ? error.name
        : typeof record?.name === "string"
          ? record.name
          : undefined,
    message: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)),
    ...(typeof record?.code === "number" ? { code: record.code } : {}),
    ...(data !== undefined ? { data } : {}),
  };
}

/** Shared app inventory cache used by Codex app-server runtime paths. */
export const defaultCodexAppInventoryCache = new CodexAppInventoryCache();

/** Builds a stable cache key from build versions and runtime identity fields. */
export function buildCodexAppInventoryCacheKey(
  input: CodexAppInventoryCacheKeyInput,
  openClawVersion: string,
  codexPluginVersion: string,
): string {
  return JSON.stringify({
    openClawVersion,
    codexPluginVersion,
    codexHome: input.codexHome ?? null,
    endpoint: input.endpoint ?? null,
    runtimeIdentity: normalizeRuntimeIdentityForCacheKey(input.runtimeIdentity),
    authProfileId: input.authProfileId ?? null,
    accountId: input.accountId ?? null,
    envApiKeyFingerprint: input.envApiKeyFingerprint ?? null,
    appServerVersion: input.appServerVersion ?? null,
  });
}

function normalizeRuntimeIdentityForCacheKey(
  value: Record<string, string | undefined> | undefined,
): Record<string, string> | null {
  if (!value) {
    return null;
  }
  const entries = Object.entries(value)
    .flatMap(([key, rawValue]) => {
      const normalized = rawValue?.trim();
      return normalized ? ([[key, normalized]] as const) : [];
    })
    .toSorted(([left], [right]) => left.localeCompare(right));
  return entries.length > 0 ? Object.fromEntries(entries) : null;
}

async function listAllApps(
  request: CodexAppInventoryRequest,
  forceRefetch: boolean,
  targetAppIds: readonly string[] = [],
): Promise<v2.AppInfo[]> {
  const apps: v2.AppInfo[] = [];
  const targetIds = new Set(targetAppIds.filter(Boolean));
  const remainingTargetIds = new Set(targetIds);
  const seenCursors = new Set<string>();
  let cursor: string | null | undefined;
  do {
    const response = await request("app/list", {
      cursor,
      // Thread startup only needs to recover the configured plugin-owned apps.
      // Large pages minimize startup latency while pagination still proves an
      // absent target instead of publishing a known-incomplete lookup.
      limit: targetIds.size > 0 ? CODEX_TARGETED_APP_INVENTORY_LIMIT : 100,
      forceRefetch,
    });
    apps.push(...response.data);
    for (const app of response.data) {
      remainingTargetIds.delete(app.id);
    }
    if (targetIds.size > 0 && remainingTargetIds.size === 0) {
      break;
    }
    cursor = response.nextCursor;
    if (cursor && seenCursors.has(cursor)) {
      throw new Error(`app/list returned repeated cursor ${cursor}`);
    }
    if (cursor) {
      seenCursors.add(cursor);
    }
  } while (cursor);
  return apps;
}

function stripEntryState(entry: CacheEntry): CodexAppInventorySnapshot {
  const { invalidated: _invalidated, ...snapshot } = entry;
  return snapshot;
}

function fingerprintInventoryCacheKey(key: string): string {
  let hash = 0;
  for (let index = 0; index < key.length; index += 1) {
    hash = (hash * 31 + key.charCodeAt(index)) >>> 0;
  }
  return hash.toString(16).padStart(8, "0");
}

function redactErrorData(value: unknown, depth = 0): JsonValue | undefined {
  if (value === undefined) {
    return undefined;
  }
  if (value === null || typeof value === "boolean" || typeof value === "number") {
    return value;
  }
  if (depth > 6) {
    return "[truncated]";
  }
  if (Array.isArray(value)) {
    return value.map((entry) => redactErrorData(entry, depth + 1) ?? null);
  }
  if (isRecord(value)) {
    const redacted: Record<string, JsonValue> = {};
    for (const [key, entry] of Object.entries(value)) {
      redacted[key] = isSensitiveErrorDataKey(key)
        ? "<redacted>"
        : (redactErrorData(entry, depth + 1) ?? null);
    }
    return redacted;
  }
  if (typeof value === "string" && value.length > 500) {
    return `${value.slice(0, 500)}...`;
  }
  if (typeof value === "string") {
    return value;
  }
  if (typeof value === "bigint") {
    return value.toString();
  }
  if (typeof value === "symbol") {
    return value.description ? `Symbol(${value.description})` : "Symbol()";
  }
  if (typeof value === "function") {
    return value.name ? `[function ${value.name}]` : "[function]";
  }
  return "[unserializable]";
}

function sanitizeErrorMessage(message: string): string {
  const htmlStart = message.search(/<html[\s>]/i);
  const withoutHtml =
    htmlStart >= 0
      ? `${message.slice(0, htmlStart).trimEnd()} [HTML response body omitted]`
      : message;
  const redacted = withoutHtml.replace(
    /([?&][^=\s"'<>]*(?:api[_-]?key|authorization|cookie|credential|password|secret|token|tk)[^=\s"'<>]*=)[^&\s"'<>]+/gi,
    "$1<redacted>",
  );
  return redacted.length > MAX_SERIALIZED_ERROR_MESSAGE_LENGTH
    ? `${redacted.slice(0, MAX_SERIALIZED_ERROR_MESSAGE_LENGTH)}...`
    : redacted;
}

function isSensitiveErrorDataKey(key: string): boolean {
  return /api[_-]?key|authorization|cookie|credential|password|secret|token/i.test(key);
}
