const BASE = '';
declare global {
  interface Window { __DKG_TOKEN__?: string; }
}

export function authHeaders(): Record<string, string> {
  if (typeof window === 'undefined') return {};
  const token = window.__DKG_TOKEN__;
  if (!token) return {};
  return { Authorization: `Bearer ${token}` };
}

export class HttpError extends Error {
  status: number;
  body?: unknown;
  constructor(status: number, message?: string, body?: unknown) {
    super(message ?? `HTTP ${status}`);
    this.status = status;
    this.body = body;
  }
}

async function fetchWithTimeout(input: string, init: RequestInit = {}, timeoutMs = 10000): Promise<Response> {
  try {
    return await fetch(input, { ...init, signal: AbortSignal.timeout(timeoutMs) });
  } catch (error) {
    if (error instanceof Error && error.name === 'TimeoutError') {
      throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
    }
    throw error;
  }
}

async function get<T>(path: string): Promise<T> {
  const res = await fetch(`${BASE}${path}`, { headers: authHeaders() });
  if (!res.ok) throw new HttpError(res.status);
  return res.json() as Promise<T>;
}

async function post<T>(path: string, body: unknown): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', ...authHeaders() },
    body: JSON.stringify(body),
  });
  if (!res.ok) {
    const errBody = await res.json().catch(() => ({}));
    const msg = (errBody as { error?: string })?.error ?? `HTTP ${res.status}`;
    throw new HttpError(res.status, msg, errBody);
  }
  return res.json() as Promise<T>;
}

async function put<T>(path: string, body: unknown): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json', ...authHeaders() },
    body: JSON.stringify(body),
  });
  if (!res.ok) {
    const errBody = await res.json().catch(() => ({}));
    const msg = (errBody as { error?: string })?.error ?? `HTTP ${res.status}`;
    throw new Error(msg);
  }
  return res.json() as Promise<T>;
}

async function del<T>(path: string): Promise<T> {
  const res = await fetch(`${BASE}${path}`, { method: 'DELETE', headers: authHeaders() });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<T>;
}

// --- Status ---
export const fetchStatus = () => get<any>('/api/status');

// --- LLM Settings ---
export interface LlmSettingsResponse {
  configured: boolean;
  model?: string;
  baseURL?: string;
}
export const fetchLlmSettings = () => get<LlmSettingsResponse>('/api/settings/llm');
export const updateLlmSettings = (data: { apiKey?: string; model?: string; baseURL?: string; clear?: boolean }) =>
  put<LlmSettingsResponse & { ok: boolean }>('/api/settings/llm', data);
export const fetchRetentionSettings = () => get<{ retentionDays: number }>('/api/settings/retention');
export const updateRetentionSettings = (retentionDays: number) =>
  put<{ ok: boolean; retentionDays: number }>('/api/settings/retention', { retentionDays });
export const fetchTelemetrySettings = () => get<{ enabled: boolean }>('/api/settings/telemetry');
export const updateTelemetrySettings = (enabled: boolean) =>
  put<{ ok: boolean; enabled: boolean }>('/api/settings/telemetry', { enabled });
export const fetchConnections = () => get<any>('/api/connections');
export const connectToPeerWithTimeout = (multiaddr: string, timeoutMs = 10000) =>
  fetchWithTimeout(`${BASE}/api/connect`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', ...authHeaders() },
    body: JSON.stringify({ multiaddr }),
  }, timeoutMs).then(async (res) => {
    if (!res.ok) {
      const errBody = await res.json().catch(() => ({}));
      const msg = (errBody as { error?: string })?.error ?? `HTTP ${res.status}`;
      throw new Error(msg);
    }
    return res.json() as Promise<{ connected?: boolean }>;
  });
// Resolve a peer's current multiaddrs via the libp2p Kademlia DHT and dial.
// Used by V10 invites that carry only a peer id, decoupling invite stability
// from relay/IP rotation. Daemon side: `POST /api/connect { peerId }` →
// `agent.connectToPeerId` → `peerRouting.findPeer` → `libp2p.dial`.
export const connectToPeerIdWithTimeout = (peerId: string, timeoutMs = 20000) =>
  fetchWithTimeout(`${BASE}/api/connect`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', ...authHeaders() },
    body: JSON.stringify({ peerId }),
  }, timeoutMs).then(async (res) => {
    if (!res.ok) {
      const errBody = await res.json().catch(() => ({}));
      const msg = (errBody as { error?: string })?.error ?? `HTTP ${res.status}`;
      throw new Error(msg);
    }
    return res.json() as Promise<{ connected?: boolean }>;
  });
export const fetchAgents = () => get<any>('/api/agents');

// --- Metrics ---
export const fetchMetrics = () => get<any>('/api/metrics');
export const fetchMetricsHistory = (from: number, to: number, maxPoints = 300) =>
  get<{ snapshots: any[] }>(`/api/metrics/history?from=${from}&to=${to}&maxPoints=${maxPoints}`);

// --- Operations ---
export const fetchOperations = (params: Record<string, string> = {}) => {
  const qs = new URLSearchParams(params).toString();
  return get<{ operations: any[]; total: number }>(`/api/operations${qs ? '?' + qs : ''}`);
};
export const fetchOperationsWithPhases = (params: Record<string, string> = {}) => {
  const qs = new URLSearchParams({ ...params, phases: '1' }).toString();
  return get<{ operations: any[]; total: number }>(`/api/operations?${qs}`);
};
export const fetchOperation = (id: string) =>
  get<{ operation: any; logs: any[]; phases: any[] }>(`/api/operations/${id}`);
export const fetchErrorHotspots = (periodMs?: number) => {
  const qs = periodMs ? `?periodMs=${periodMs}` : '';
  return get<{ hotspots: Array<{ phase: string; error_count: number; last_error: string | null; last_occurred: number | null }> }>(`/api/error-hotspots${qs}`);
};
export const fetchFailedOperations = (params: { phase?: string; operationName?: string; periodMs?: number; q?: string; limit?: number } = {}) => {
  const qs = new URLSearchParams();
  if (params.phase) qs.set('phase', params.phase);
  if (params.operationName) qs.set('operationName', params.operationName);
  if (params.periodMs) qs.set('periodMs', String(params.periodMs));
  if (params.q) qs.set('q', params.q);
  if (params.limit) qs.set('limit', String(params.limit));
  const q = qs.toString();
  return get<{ operations: any[] }>(`/api/failed-operations${q ? '?' + q : ''}`);
};

// --- Operation stats ---
export const fetchOperationStats = (params: { name?: string; periodMs?: number } = {}) => {
  const qs = new URLSearchParams();
  if (params.name) qs.set('name', params.name);
  if (params.periodMs) qs.set('periodMs', String(params.periodMs));
  const q = qs.toString();
  return get<{ summary: any; timeSeries: any[] }>(`/api/operation-stats${q ? '?' + q : ''}`);
};

export const fetchSuccessRates = (periodMs: number) =>
  get<{ rates: Array<{ type: string; total: number; success: number; error: number; rate: number; avgMs: number }> }>(`/api/success-rates?periodMs=${periodMs}`);

export const fetchPerTypeStats = (periodMs: number, bucketMs?: number) => {
  const qs = `periodMs=${periodMs}${bucketMs ? `&bucketMs=${bucketMs}` : ''}`;
  return get<{
    buckets: number[];
    types: string[];
    series: Record<string, Array<{ count: number; avgMs: number; successRate: number; gasCostEth: number }>>;
  }>(`/api/per-type-stats?${qs}`);
};

// --- Logs ---
export const fetchLogs = (params: Record<string, string> = {}) => {
  const qs = new URLSearchParams(params).toString();
  return get<{ logs: any[]; total: number }>(`/api/logs${qs ? '?' + qs : ''}`);
};

export const fetchNodeLog = (params: { lines?: number; q?: string } = {}) => {
  const qs = new URLSearchParams();
  if (params.lines) qs.set('lines', String(params.lines));
  if (params.q) qs.set('q', params.q);
  const q = qs.toString();
  return get<{ lines: string[]; totalSize: number }>(`/api/node-log${q ? '?' + q : ''}`);
};

// --- Context graphs (V10) — legacy daemon paths keep working server-side redirects.
export async function fetchContextGraphs(): Promise<{ contextGraphs: any[] }> {
  const data = await get<{ contextGraphs?: any[] }>('/api/context-graph/list');
  const list = data.contextGraphs ?? [];
  return { contextGraphs: list.filter((p: any) => !p.isSystem) };
}

// --- Agent Identity ---
export interface AgentIdentity {
  agentAddress: string;
  agentDid: string;
  name: string;
  framework?: string;
  peerId: string;
  nodeIdentityId: string;
}

export const fetchCurrentAgent = () => get<AgentIdentity>('/api/agent/identity');

export async function createContextGraph(
  id: string,
  name: string,
  description?: string,
  opts?: { allowedAgents?: string[]; accessPolicy?: number },
): Promise<{ created: string; uri: string }> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 30_000);
  try {
    const res = await fetch(`${BASE}/api/context-graph/create`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', ...authHeaders() },
      body: JSON.stringify({
        id, name, description,
        ...(opts?.allowedAgents ? { allowedAgents: opts.allowedAgents } : {}),
        ...(opts?.accessPolicy !== undefined ? { accessPolicy: opts.accessPolicy } : {}),
      }),
      signal: controller.signal,
    });
    if (!res.ok) {
      const errBody = await res.json().catch(() => ({}));
      throw new Error((errBody as { error?: string })?.error ?? `HTTP ${res.status}`);
    }
    return res.json() as Promise<{ created: string; uri: string }>;
  } catch (err: any) {
    if (err.name === 'AbortError') {
      throw new Error('Creating project is taking longer than expected — it may still complete in the background. Refresh the page in a moment.');
    }
    throw err;
  } finally {
    clearTimeout(timeout);
  }
}

// --- Context Graph Participant Management ---
export const addParticipant = (contextGraphId: string, agentAddress: string) =>
  post<{ ok: boolean }>(`/api/context-graph/${encodeURIComponent(contextGraphId)}/add-participant`, { agentAddress });

export const removeParticipant = (contextGraphId: string, agentAddress: string) =>
  post<{ ok: boolean }>(`/api/context-graph/${encodeURIComponent(contextGraphId)}/remove-participant`, { agentAddress });

export const listParticipants = (contextGraphId: string) =>
  get<{ contextGraphId: string; allowedAgents: string[] }>(`/api/context-graph/${encodeURIComponent(contextGraphId)}/participants`);

// --- Join Request flow (Phase 2: signed agent delegation) ---
//
// A join request now IS a `SignedAgentDelegation` — the agent's
// signature scoped to `sync:<cgId>` that authorises their hosting node
// (peer-id and/or operational key) to act on their behalf for that CG.
// On approval the curator promotes the named delegatee identifiers
// into the CG allowlist so post-approval sync passes auth without the
// agent having to co-sign every wire message.
export interface SignedAgentDelegation {
  agentAddress: string;
  scope: string;
  issuedAtMs: number;
  expiresAtMs?: number;
  delegateePeerId?: string;
  delegateeOpKey?: string;
  signature: string;
}

// SignJoinResponse is intentionally narrow — `/sign-join` is sign-only
// (PR #448 review: forwarding lives in `/request-join` to avoid a
// duplicate-forward bug where the UI was sending the same delegation
// twice). Delivery status comes back from `submitJoinRequest` instead.
export interface SignJoinResponse {
  ok: boolean;
  contextGraphId: string;
  delegation: SignedAgentDelegation;
  agentAddress: string;
}

export interface PendingJoinRequest {
  agentAddress: string;
  name?: string;
  signature: string;
  timestamp: number;
  status: string;
}

export const signJoinRequest = (contextGraphId: string) =>
  post<SignJoinResponse>(
    `/api/context-graph/${encodeURIComponent(contextGraphId)}/sign-join`,
    {},
  );

/**
 * Daemon's `/request-join` returns `delivered` describing how the
 * signed delegation was routed:
 *  - `'local'`  — local node IS the curator; stored locally, no P2P
 *  - `number`   — count of remote curator peers that returned `ok` for
 *                 the broadcast/targeted forward (typically `1`)
 * The 502 path (no curator reachable) throws here via `post()`, so a
 * resolved response always implies at least one delivery destination.
 */
export const submitJoinRequest = (
  contextGraphId: string,
  req: { delegation: SignedAgentDelegation; agentName?: string; curatorPeerId?: string },
) =>
  post<{ ok: boolean; status: string; delivered: number | 'local'; alreadyMember?: boolean }>(
    `/api/context-graph/${encodeURIComponent(contextGraphId)}/request-join`,
    req,
  );

export const listJoinRequests = (contextGraphId: string) =>
  get<{ contextGraphId: string; requests: PendingJoinRequest[] }>(`/api/context-graph/${encodeURIComponent(contextGraphId)}/join-requests`);

export const approveJoinRequest = (contextGraphId: string, agentAddress: string) =>
  post<{ ok: boolean; status: string; agentAddress: string }>(`/api/context-graph/${encodeURIComponent(contextGraphId)}/approve-join`, { agentAddress });

export const rejectJoinRequest = (contextGraphId: string, agentAddress: string) =>
  post<{ ok: boolean; status: string; agentAddress: string }>(`/api/context-graph/${encodeURIComponent(contextGraphId)}/reject-join`, { agentAddress });

// --- Catch-up sync jobs ---
export interface CatchupStatusResponse {
  jobId: string;
  contextGraphId: string;
  includeSharedMemory: boolean;
  /**
   * `unreachable` is the V10 terminal status emitted when the daemon
   * subscribed and ran the catchup, but no peer could deliver the CG
   * content (curator offline, no node holds the CG, or transport
   * failures across the whole peer set). Distinct from `denied`
   * (responder explicitly refused) so the UI can render targeted
   * copy + a "send signed join request" CTA.
   */
  status: 'queued' | 'running' | 'done' | 'denied' | 'failed' | 'unreachable';
  queuedAt: number;
  startedAt?: number;
  finishedAt?: number;
  result?: {
    connectedPeers: number;
    syncCapablePeers: number;
    peersTried: number;
    /** See `unreachable` above; subset of `peersTried` that responded without failure or denial. */
    peersSucceeded: number;
    dataSynced: number;
    sharedMemorySynced: number;
    denied: boolean;
    deniedPeers: number;
    diagnostics?: {
      noProtocolPeers: number;
      durable: {
        fetchedMetaTriples: number;
        fetchedDataTriples: number;
        insertedMetaTriples: number;
        insertedDataTriples: number;
        bytesReceived: number;
        resumedPhases: number;
        emptyResponses: number;
        metaOnlyResponses: number;
        dataRejectedMissingMeta: number;
        rejectedKcs: number;
        failedPeers: number;
      };
      sharedMemory: {
        fetchedMetaTriples: number;
        fetchedDataTriples: number;
        insertedMetaTriples: number;
        insertedDataTriples: number;
        bytesReceived: number;
        resumedPhases: number;
        emptyResponses: number;
        droppedDataTriples: number;
        failedPeers: number;
      };
    };
  };
  error?: string;
}

export const fetchCatchupStatus = (contextGraphId: string) =>
  get<CatchupStatusResponse>(`/api/sync/catchup-status?contextGraphId=${encodeURIComponent(contextGraphId)}`);

// --- File import to Working Memory ---
export interface ImportFileResult {
  assertionUri: string;
  fileHash: string;
  rootEntity?: string;
  detectedContentType: string;
  extraction: {
    status: 'completed' | 'skipped' | 'failed';
    tripleCount?: number;
    triplesWritten?: number;
    provenance?: any;
    error?: string;
    pipelineUsed?: string | null;
    mdIntermediateHash?: string;
  };
}

const EXT_TO_MIME: Record<string, string> = {
  md: 'text/markdown', markdown: 'text/markdown', txt: 'text/plain', csv: 'text/csv',
  json: 'application/json', xml: 'application/xml',
  yaml: 'text/yaml', yml: 'text/yaml',
  pdf: 'application/pdf',
  docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  doc: 'application/msword',
  xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  epub: 'application/epub+zip',
  ttl: 'text/turtle', rdf: 'application/rdf+xml', owl: 'application/rdf+xml',
  html: 'text/html', htm: 'text/html',
  py: 'text/x-python', ts: 'text/typescript', js: 'text/javascript',
  tsx: 'text/typescript', jsx: 'text/javascript',
  java: 'text/x-java', go: 'text/x-go', rs: 'text/x-rust',
  c: 'text/x-c', cpp: 'text/x-c++', h: 'text/x-c',
  png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp',
};

function detectContentType(file: File): string | undefined {
  if (file.type && file.type !== 'application/octet-stream') return file.type;
  const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
  return EXT_TO_MIME[ext];
}

export async function importFile(
  assertionName: string,
  contextGraphId: string,
  file: File,
  opts?: { ontologyRef?: string; subGraphName?: string },
): Promise<ImportFileResult> {
  const form = new FormData();
  form.append('file', file);
  form.append('contextGraphId', contextGraphId);
  const ct = detectContentType(file);
  if (ct) form.append('contentType', ct);
  if (opts?.ontologyRef) form.append('ontologyRef', opts.ontologyRef);
  if (opts?.subGraphName) form.append('subGraphName', opts.subGraphName);

  const res = await fetch(`${BASE}/api/assertion/${encodeURIComponent(assertionName)}/import-file`, {
    method: 'POST',
    headers: authHeaders(),
    body: form,
  });
  if (!res.ok) {
    const errBody = await res.json().catch(() => ({}));
    throw new Error((errBody as { error?: string })?.error ?? `HTTP ${res.status}`);
  }
  return res.json() as Promise<ImportFileResult>;
}

// --- Query ---
export const executeQuery = (
  sparql: string,
  contextGraphId?: string,
  includeSharedMemory?: boolean,
  graphSuffix?: '_shared_memory',
  view?: 'verified-memory' | 'shared-working-memory',
) =>
  post<{ result: any }>('/api/query', { sparql, contextGraphId, includeSharedMemory, graphSuffix, view });

// --- Publish (assertion-lifecycle: RFC-001 §9.x sign-at-creation) ---
//
// Creates a fresh auto-named assertion, writes the supplied quads,
// finalizes (computes the merkle root and signs the EIP-712
// AuthorAttestation stamped into `_meta`), promotes into SWM, and
// publishes — the publisher forwards the seal verbatim and never
// re-signs.
export const publishTriples = async (contextGraphId: string, quads: any[]) => {
  const assertionName = `ui-publish-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
  const created = await post<{ assertionUri: string; seal?: Record<string, unknown> }>(
    '/api/assertion/create',
    {
      contextGraphId,
      name: assertionName,
      quads,
      finalize: true,
      promote: true,
    },
  );
  const published = await post<any>('/api/shared-memory/publish', {
    contextGraphId,
    assertionName,
  });
  return { ...published, assertionUri: created.assertionUri, ...(created.seal ? { seal: created.seal } : {}) };
};

export const writeSharedMemory = (
  contextGraphId: string,
  quads: Array<{ subject: string; predicate: string; object: string; graph?: string }>,
  opts: { subGraphName?: string; localOnly?: boolean } = {},
) =>
  post<any>('/api/shared-memory/write', {
    contextGraphId,
    quads,
    ...(opts.subGraphName ? { subGraphName: opts.subGraphName } : {}),
    ...(opts.localOnly !== undefined ? { localOnly: opts.localOnly } : {}),
  });

export const writeProfileQueryCatalog = (
  contextGraphId: string,
  quads: Array<{ subject: string; predicate: string; object: string; graph?: string }>,
) =>
  post<any>('/api/profile/query-catalog/write', {
    contextGraphId,
    quads,
  });

// --- Assertions (WM objects) ---

export interface AssertionInfo {
  name: string;
  graphUri: string;
  tripleCount?: number;
}

/**
 * Discover assertions in a given memory layer.
 *
 * WM uses a cheap graph-listing query: the assertion graph URI shape is
 * `did:dkg:context-graph:<cg>/assertion/<agent>/<name>` and a WM assertion
 * still carries its triples there.
 *
 * SWM is different. When an assertion is promoted its triples move into
 * the single `/_shared_memory` graph, so the assertion graph itself becomes
 * empty and the WM-style listing returns nothing. The authoring node's
 * `_meta` graph also records full lifecycle entities (`dkg:state`,
 * `dkg:memoryLayer`, `prov:Activity` events), but `_meta` is NOT replicated
 * between peers — only the context graph's data graphs and the
 * `_shared_memory_meta` partitions propagate over sync.
 *
 * What DOES land on every replica is one `dkg:ShareTransition` entity per
 * promote, authored by `generateShareTransitionMetadata()` in
 * `@origintrail-official/dkg-publisher`:
 *
 *   GRAPH <did:dkg:context-graph:<cg>[/<sg>]/_shared_memory_meta> {
 *     <urn:dkg:share:<opId>> a dkg:ShareTransition ;
 *                            dkg:source   "assertion/<agent>/<name>" ;
 *                            dkg:agent    did:dkg:agent:<address> ;
 *                            dkg:timestamp "…"^^xsd:dateTime .
 *   }
 *
 * So on every node — authoring or replica — we can enumerate promoted
 * assertions by listing ShareTransitions and reconstructing the lifecycle
 * URN that the UI already uses as `graphUri` elsewhere. We parse the
 * sub-graph suffix (if any) from the meta graph IRI itself so this keeps
 * working for sub-graph-scoped shares.
 */
export async function listAssertions(
  contextGraphId: string,
  layer: 'wm' | 'swm' = 'wm',
): Promise<AssertionInfo[]> {
  if (layer === 'swm') {
    const DKG = 'http://dkg.io/ontology/';
    const swmMetaPrefix = `did:dkg:context-graph:${contextGraphId}`;
    // Mirrors the pattern `useSwmAttributions.ts` uses to read
    // `_shared_memory_meta` graphs — the explicit `GRAPH ?g { … }`
    // plus `FILTER(STRSTARTS … STRENDS)` pair makes the query
    // self-scoping: the query engine's `wrapWithGraph` early-returns
    // when the SPARQL already contains `graph `, so the query runs
    // raw over the store and the FILTER pins it to *this* CG's
    // `_shared_memory_meta` partitions (root + each sub-graph) only.
    // Codex tier-4m flagged this as "runs against the default WM
    // view", which is incorrect for this shape of SPARQL; keeping
    // the same shape as `useSwmAttributions` — which is already in
    // production for the SWM agent-attribution badge — keeps both
    // call sites consistent and provably working on the same path.
    const sparql = `SELECT DISTINCT ?g ?source ?agent WHERE {
      GRAPH ?g {
        ?s a <${DKG}ShareTransition> ;
           <${DKG}source> ?source ;
           <${DKG}agent> ?agent .
      }
      FILTER(STRSTARTS(STR(?g), "${swmMetaPrefix}"))
      FILTER(STRENDS(STR(?g), "/_shared_memory_meta"))
    }`;
    const data = await executeQuery(sparql, contextGraphId);
    const bindings: any[] = data?.result?.bindings ?? [];
    const seen = new Set<string>();
    const result: AssertionInfo[] = [];
    for (const b of bindings) {
      const g = typeof b.g === 'string' ? b.g : b.g?.value;
      const source = typeof b.source === 'string' ? b.source : b.source?.value;
      const agentUri = typeof b.agent === 'string' ? b.agent : b.agent?.value;
      if (!g || !source || !agentUri) continue;

      // `dkg:source` literal is `assertion/<agent>/<name>`. The agent
      // segment is a 0x EVM address (no slashes, no colons), but `<name>`
      // is only slash/whitespace-free — it CAN contain `:` — so split on
      // the first two `/` rather than a blind last-segment parse.
      const m = source.match(/^assertion\/([^/]+)\/(.+)$/);
      if (!m) continue;
      const name = m[2];

      // `dkg:agent` is `did:dkg:agent:<address>`; pull the address so we
      // rebuild the exact lifecycle URN shape used on the authoring node.
      const addrMatch = /^did:dkg:agent:(.+)$/.exec(agentUri);
      const address = addrMatch ? addrMatch[1] : null;
      if (!address) continue;

      // Recover optional sub-graph segment from `?g`:
      //   did:dkg:context-graph:<cg>/_shared_memory_meta          → none
      //   did:dkg:context-graph:<cg>/<sg>/_shared_memory_meta     → <sg>
      const tail = g.slice(swmMetaPrefix.length); // "/<sg?>/_shared_memory_meta"
      const inner = tail.replace(/\/_shared_memory_meta$/, '').replace(/^\//, '');
      const subGraphName = inner.length > 0 ? inner : undefined;

      const lifecycle = subGraphName
        ? `urn:dkg:assertion:${contextGraphId}:${subGraphName}:${address}:${name}`
        : `urn:dkg:assertion:${contextGraphId}:${address}:${name}`;

      if (seen.has(lifecycle)) continue;
      seen.add(lifecycle);
      result.push({ name, graphUri: lifecycle });
    }
    return result;
  }

  // layer === 'wm'
  const sparql = `SELECT DISTINCT ?g (COUNT(?s) AS ?cnt) WHERE { GRAPH ?g { ?s ?p ?o } } GROUP BY ?g`;
  const data = await executeQuery(sparql, contextGraphId);
  const bindings: any[] = data?.result?.bindings ?? [];
  const prefix = `did:dkg:context-graph:${contextGraphId}/assertion/`;
  const result: AssertionInfo[] = [];
  for (const b of bindings) {
    const g = typeof b.g === 'string' ? b.g : b.g?.value;
    if (!g || !g.startsWith(prefix)) continue;
    const tail = g.slice(prefix.length);
    const slash = tail.indexOf('/');
    const name = slash >= 0 ? tail.slice(slash + 1) : tail;
    const cnt = typeof b.cnt === 'string' ? parseInt(b.cnt, 10) : (b.cnt?.value ? parseInt(b.cnt.value, 10) : undefined);
    result.push({ name, graphUri: g, tripleCount: Number.isFinite(cnt) ? cnt : undefined });
  }
  return result;
}

/** Promote an assertion from WM to SWM. */
export const promoteAssertion = (contextGraphId: string, assertionName: string, entities: string | string[] = 'all') =>
  post<{ promotedCount: number }>(`/api/assertion/${encodeURIComponent(assertionName)}/promote`, { contextGraphId, entities });

// --- File preview ---

export interface ExtractionStatus {
  assertionUri: string;
  status: string;
  fileHash: string;
  detectedContentType: string;
  pipelineUsed: string | null;
  tripleCount: number;
  mdIntermediateHash?: string;
  startedAt: string;
  completedAt?: string;
}

/** Fetch extraction status for an assertion (includes fileHash + contentType). */
export const fetchExtractionStatus = (assertionName: string, contextGraphId: string) =>
  get<ExtractionStatus>(`/api/assertion/${encodeURIComponent(assertionName)}/extraction-status?contextGraphId=${encodeURIComponent(contextGraphId)}`);

/** Build a URL to serve a stored file by its hash (sha256: or keccak256:). */
export function fileUrl(hash: string, contentType?: string): string {
  const normalizedHash = hash.startsWith('sha256:') || hash.startsWith('keccak256:')
    ? hash
    : `sha256:${hash}`;
  const params = contentType ? `?contentType=${encodeURIComponent(contentType)}` : '';
  return `${BASE}/api/file/${encodeURIComponent(normalizedHash)}${params}`;
}

export interface SwmRootEntity {
  uri: string;
  label: string;
  tripleCount: number;
}

/** List root entities in SWM with their triple counts. */
export async function listSwmEntities(contextGraphId: string): Promise<SwmRootEntity[]> {
  const sparql = `SELECT ?s (COUNT(?p) AS ?cnt) WHERE { ?s ?p ?o } GROUP BY ?s ORDER BY DESC(?cnt)`;
  const data = await post<{ result: any }>('/api/query', { sparql, contextGraphId, view: 'shared-working-memory' });
  const bindings: any[] = data?.result?.bindings ?? [];
  return bindings.map((b) => {
    const uri = typeof b.s === 'string' ? b.s : b.s?.value ?? '';
    const cntRaw = typeof b.cnt === 'string' ? b.cnt : b.cnt?.value ?? '0';
    const m = cntRaw.match(/^"?(\d+)/);
    const tripleCount = m ? parseInt(m[1], 10) : 0;
    const hash = uri.lastIndexOf('#');
    const slash = uri.lastIndexOf('/');
    const cut = Math.max(hash, slash);
    const label = cut >= 0 ? uri.slice(cut + 1) : uri;
    return { uri, label, tripleCount };
  });
}

export interface PublishResult {
  kcId: string;
  status: string;
  kas: { tokenId: string; rootEntity: string }[];
  txHash?: string;
  blockNumber?: number;
}

/** Publish SWM content on-chain (SWM -> VM). Pass rootEntities to selectively publish, or omit for all. */
export const publishSharedMemory = (contextGraphId: string, rootEntities?: string[]) =>
  post<PublishResult>('/api/shared-memory/publish', {
    contextGraphId,
    selection: rootEntities ?? 'all',
    clearAfter: !rootEntities,
  });

// --- Query history ---
export const fetchQueryHistory = (limit = 50, offset = 0) =>
  get<{ history: any[] }>(`/api/query-history?limit=${limit}&offset=${offset}`);

// --- Saved queries ---
export const fetchSavedQueries = () => get<{ queries: any[] }>('/api/saved-queries');
export const createSavedQuery = (data: { name: string; description?: string; sparql: string }) =>
  post<{ id: number }>('/api/saved-queries', data);
export const updateSavedQuery = (id: number, data: any) =>
  put<{ ok: boolean }>(`/api/saved-queries/${id}`, data);
export const deleteSavedQuery = (id: number) =>
  del<{ ok: boolean }>(`/api/saved-queries/${id}`);

// --- Memory (private chat memories in DKG) ---
export interface MemorySession {
  session: string;
  messages: Array<{
    uri: string;
    author: string;
    text: string;
    ts: string;
    turnId?: string;
    persistStatus?: 'pending' | 'in_progress' | 'stored' | 'failed' | 'skipped';
    failureReason?: string | null;
    attachmentRefs?: LocalAgentChatAttachmentRef[];
    toolCalls?: Array<{ name: string; args: Record<string, unknown>; result: unknown }>;
  }>;
}
export interface MemorySessionGraphDeltaWatermark {
  baseTurnId: string | null;
  previousTurnId: string | null;
  appliedTurnId: string | null;
  latestTurnId: string | null;
  turnIndex: number;
  turnCount: number;
}
export interface MemorySessionGraphDelta {
  mode: 'delta' | 'full_refresh_required';
  reason?: 'session_empty' | 'turn_not_found' | 'missing_watermark' | 'watermark_mismatch';
  sessionId: string;
  turnId: string;
  watermark: MemorySessionGraphDeltaWatermark;
  triples: Array<{ subject: string; predicate: string; object: string }>;
}
export const fetchMemorySessions = (limit = 20) =>
  get<{ sessions: MemorySession[] }>(`/api/memory/sessions?limit=${limit}`);
export const fetchMemorySession = (
  sessionId: string,
  opts: {
    limit?: number;
    order?: 'asc' | 'desc';
  } = {},
) => {
  const params = new URLSearchParams();
  if (opts.limit && Number.isInteger(opts.limit) && opts.limit > 0) {
    params.set('limit', String(opts.limit));
  }
  if (opts.order === 'desc' || opts.order === 'asc') {
    params.set('order', opts.order);
  }
  const query = params.toString();
  return get<MemorySession>(
    `/api/memory/sessions/${encodeURIComponent(sessionId)}${query ? `?${query}` : ''}`,
  );
};
export const fetchMemorySessionGraphDelta = (
  sessionId: string,
  turnId: string,
  opts: { baseTurnId?: string | null } = {},
) => {
  const params = new URLSearchParams();
  params.set('turnId', turnId);
  if (opts.baseTurnId) params.set('baseTurnId', opts.baseTurnId);
  return get<MemorySessionGraphDelta>(
    `/api/memory/sessions/${encodeURIComponent(sessionId)}/graph-delta?${params.toString()}`,
  );
};

// IMPORT_SOURCES / ImportSource / ImportMemoryQuad / ImportMemoryResult /
// importMemories were retired with /api/memory/import as part of the
// openclaw-dkg-primary-memory work. Agents write memory via
// the adapter's dkg_memory_import tool, and file-import flows go through
// /api/assertion/:name/import-file directly.

// --- OpenClaw agents ---
export interface OpenClawAgent {
  peerId: string;
  name: string;
  description?: string;
  framework: string;
  connected: boolean;
  lastSeen: number | null;
  latencyMs: number | null;
}

export const fetchOpenClawAgents = () =>
  get<{ agents: OpenClawAgent[] }>('/api/openclaw-agents');

export interface LocalAgentChatAttachmentRef {
  id?: string;
  fileName: string;
  contextGraphId: string;
  assertionName?: string;
  assertionUri: string;
  fileHash: string;
  detectedContentType?: string;
  extractionStatus?: 'completed';
  tripleCount?: number;
  rootEntity?: string;
  mdIntermediateHash?: string;
  markdownHash?: string;
  markdownForm?: string;
}

export interface LocalAgentChatAttachmentImportResult {
  id?: string;
  fileName: string;
  contextGraphId: string;
  assertionName?: string;
  assertionUri: string;
  fileHash: string;
  detectedContentType: string;
  extractionStatus: 'skipped';
  pipelineUsed?: string | null;
  tripleCount?: number;
  rootEntity?: string;
  mdIntermediateHash?: string;
  error?: string;
}

export interface LocalAgentChatContextEntry {
  key: string;
  label: string;
  value: string;
}

interface LocalAgentChatRequestOptions {
  correlationId?: string;
  signal?: AbortSignal;
  identity?: string;
  sessionId?: string;
  profile?: string;
  persistUserMessage?: string;
  attachments?: LocalAgentChatAttachmentRef[];
  attachmentImportResults?: LocalAgentChatAttachmentImportResult[];
  contextEntries?: LocalAgentChatContextEntry[];
  /**
   * UI-selected project context graph for this turn. Forwarded unchanged as
   * `contextGraphId`; the local agent may use it as contextual signal, while
   * explicit DKG write tools still choose their own target graph.
   */
  contextGraphId?: string;
}

export const sendOpenClawChat = (peerId: string, text: string) =>
  post<{ delivered: boolean; reply: string | null; timedOut: boolean; waitMs: number; error?: string }>(
    '/api/chat-openclaw',
    { peerId, text },
  );

// --- OpenClaw local channel bridge ---

export interface LocalAgentChatResponse {
  text: string;
  correlationId: string;
  sessionId?: string;
  turnId?: string;
}

export async function sendOpenClawLocalChat(
  text: string,
  opts?: LocalAgentChatRequestOptions,
): Promise<LocalAgentChatResponse> {
  const res = await fetch('/api/openclaw-channel/send', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', ...authHeaders() },
    body: JSON.stringify(buildLocalAgentChatBody(text, opts)),
    signal: opts?.signal,
  });
  if (!res.ok) {
    const errBody = await res.json().catch(() => ({}));
    throw new Error((errBody as { error?: string })?.error ?? `Request failed (${res.status})`);
  }
  return res.json();
}

export type OpenClawStreamEvent =
  | { type: 'text_delta'; delta: string }
  | ({ type: 'final' } & LocalAgentChatResponse)
  | { type: 'error'; error: string };

type HermesRawStreamEvent =
  | OpenClawStreamEvent
  | { type: 'delta'; text?: string; correlationId?: string };

export type LocalAgentChannelTarget = 'bridge' | 'gateway';

export interface LocalAgentHealthResponse {
  ok: boolean;
  target?: LocalAgentChannelTarget;
  error?: string;
  profile?: string;
  memory?: string | {
    provider?: string;
    mode?: string;
    status?: string;
    conflict?: boolean;
    error?: string;
  };
  status?: string;
  bridge?: Omit<LocalAgentHealthResponse, 'bridge' | 'gateway'>;
  gateway?: Omit<LocalAgentHealthResponse, 'bridge' | 'gateway'>;
}

function buildLocalAgentChatBody(
  text: string,
  opts?: LocalAgentChatRequestOptions,
): Record<string, unknown> {
  return {
    text,
    correlationId: opts?.correlationId ?? crypto.randomUUID(),
    ...(opts?.identity ? { identity: opts.identity } : {}),
    ...(opts?.sessionId ? { sessionId: opts.sessionId } : {}),
    ...(opts?.profile ? { profile: opts.profile } : {}),
    ...(opts?.persistUserMessage ? { persistUserMessage: opts.persistUserMessage } : {}),
    ...(opts?.attachments?.length ? { attachmentRefs: opts.attachments } : {}),
    ...(opts?.attachmentImportResults?.length ? { attachmentImportResults: opts.attachmentImportResults } : {}),
    ...(opts?.contextEntries?.length ? { contextEntries: opts.contextEntries } : {}),
    ...(opts?.contextGraphId ? { contextGraphId: opts.contextGraphId } : {}),
  };
}

/**
 * SSE streaming variant of sendOpenClawLocalChat.
 * Yields text deltas as the agent produces them.
 */
export async function streamOpenClawLocalChat(
  text: string,
  opts: LocalAgentChatRequestOptions & {
    onEvent?: (event: OpenClawStreamEvent) => void;
  } = {},
): Promise<LocalAgentChatResponse> {
  const res = await fetch('/api/openclaw-channel/stream', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'text/event-stream',
      ...authHeaders(),
    },
    body: JSON.stringify(buildLocalAgentChatBody(text, opts)),
    signal: opts.signal,
  });

  if (!res.ok) {
    const errBody = await res.json().catch(() => ({}));
    throw new Error((errBody as { error?: string })?.error ?? `Request failed (${res.status})`);
  }

  const contentType = (res.headers.get('content-type') ?? '').toLowerCase();

  // Fallback: if server didn't return SSE, treat as JSON
  if (!res.body || !contentType.includes('text/event-stream')) {
    const data = await res.json() as LocalAgentChatResponse;
    opts.onEvent?.({ type: 'final', ...data });
    return data;
  }

  // Read SSE stream
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  let finalPayload: LocalAgentChatResponse | undefined;
  let streamError: Error | undefined;

  const handleEvent = (event: OpenClawStreamEvent): void => {
    opts.onEvent?.(event);
    if (event.type === 'error') {
      streamError = new Error(event.error || 'Stream failed');
    } else if (event.type === 'final') {
      finalPayload = {
        text: event.text,
        correlationId: event.correlationId,
        ...(event.sessionId ? { sessionId: event.sessionId } : {}),
        ...(event.turnId ? { turnId: event.turnId } : {}),
      };
    }
  };

  const processLines = (finalFlush: boolean): void => {
    let lineEnd = buffer.indexOf('\n');
    while (lineEnd !== -1) {
      const line = buffer.slice(0, lineEnd).trim();
      buffer = buffer.slice(lineEnd + 1);
      lineEnd = buffer.indexOf('\n');
      if (!line.startsWith('data:')) continue;
      const dataLine = line.slice(5).trim();
      if (!dataLine) continue;
      try {
        handleEvent(JSON.parse(dataLine) as OpenClawStreamEvent);
      } catch { /* ignore malformed frames */ }
      if (streamError) return;
    }
    if (finalFlush && buffer.trim().startsWith('data:')) {
      const dataLine = buffer.trim().slice(5).trim();
      if (!dataLine) return;
      try {
        handleEvent(JSON.parse(dataLine) as OpenClawStreamEvent);
      } catch { /* ignore malformed frames */ }
      buffer = '';
    }
  };

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    processLines(false);
    if (streamError) break;
  }
  buffer += decoder.decode();
  processLines(true);

  if (streamError) throw streamError;
  if (!finalPayload) throw new Error('Stream ended without final payload');
  return finalPayload;
}

export const fetchOpenClawLocalHealth = () =>
  get<LocalAgentHealthResponse & {
    bridge?: { ok: boolean; channel?: string; cached?: boolean; error?: string };
    gateway?: { ok: boolean; channel?: string; error?: string };
  }>(
    '/api/openclaw-channel/health',
  );

export async function sendHermesLocalChat(
  text: string,
  opts?: LocalAgentChatRequestOptions,
): Promise<LocalAgentChatResponse> {
  const res = await fetch('/api/hermes-channel/send', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', ...authHeaders() },
    body: JSON.stringify(buildLocalAgentChatBody(text, opts)),
    signal: opts?.signal,
  });
  if (!res.ok) {
    const errBody = await res.json().catch(() => ({}));
    throw new Error(formatLocalAgentError(errBody, `Request failed (${res.status})`));
  }
  return res.json();
}

export async function streamHermesLocalChat(
  text: string,
  opts: LocalAgentChatRequestOptions & {
    onEvent?: (event: OpenClawStreamEvent) => void;
  } = {},
): Promise<LocalAgentChatResponse> {
  const res = await fetch('/api/hermes-channel/stream', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'text/event-stream',
      ...authHeaders(),
    },
    body: JSON.stringify(buildLocalAgentChatBody(text, opts)),
    signal: opts.signal,
  });

  if (!res.ok) {
    const errBody = await res.json().catch(() => ({}));
    throw new Error(formatLocalAgentError(errBody, `Request failed (${res.status})`));
  }

  const contentType = (res.headers.get('content-type') ?? '').toLowerCase();

  if (!res.body || !contentType.includes('text/event-stream')) {
    const data = await res.json() as LocalAgentChatResponse;
    opts.onEvent?.({ type: 'final', ...data });
    return data;
  }

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  let finalPayload: LocalAgentChatResponse | undefined;
  let streamError: Error | undefined;

  const normalizeHermesEvent = (event: HermesRawStreamEvent): OpenClawStreamEvent => {
    if (event.type === 'delta') {
      return { type: 'text_delta', delta: event.text ?? '' };
    }
    return event;
  };

  const handleEvent = (event: OpenClawStreamEvent): void => {
    opts.onEvent?.(event);
    if (event.type === 'error') {
      streamError = new Error(event.error || 'Stream failed');
    } else if (event.type === 'final') {
      finalPayload = {
        text: event.text,
        correlationId: event.correlationId,
        ...(event.sessionId ? { sessionId: event.sessionId } : {}),
        ...(event.turnId ? { turnId: event.turnId } : {}),
      };
    }
  };

  const processLines = (finalFlush: boolean): void => {
    let lineEnd = buffer.indexOf('\n');
    while (lineEnd !== -1) {
      const line = buffer.slice(0, lineEnd).trim();
      buffer = buffer.slice(lineEnd + 1);
      lineEnd = buffer.indexOf('\n');
      if (!line.startsWith('data:')) continue;
      const dataLine = line.slice(5).trim();
      if (!dataLine) continue;
      try {
        handleEvent(normalizeHermesEvent(JSON.parse(dataLine) as HermesRawStreamEvent));
      } catch { /* ignore malformed frames */ }
      if (streamError) return;
    }
    if (finalFlush && buffer.trim().startsWith('data:')) {
      const dataLine = buffer.trim().slice(5).trim();
      if (!dataLine) return;
      try {
        handleEvent(normalizeHermesEvent(JSON.parse(dataLine) as HermesRawStreamEvent));
      } catch { /* ignore malformed frames */ }
      buffer = '';
    }
  };

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    processLines(false);
    if (streamError) break;
  }
  buffer += decoder.decode();
  processLines(true);

  if (streamError) throw streamError;
  if (!finalPayload) throw new Error('Stream ended without final payload');
  return finalPayload;
}

export const fetchHermesLocalHealth = () =>
  get<LocalAgentHealthResponse>('/api/hermes-channel/health');

function formatLocalAgentError(body: unknown, fallback: string): string {
  if (!body || typeof body !== 'object' || Array.isArray(body)) return fallback;
  const record = body as Record<string, unknown>;
  const error = typeof record.error === 'string' && record.error.trim()
    ? record.error.trim()
    : fallback;
  if (record.details === undefined || record.details === null) return error;
  const details = typeof record.details === 'string'
    ? record.details.trim()
    : JSON.stringify(record.details);
  if (!details || details === error) return error;
  return `${error}: ${details}`;
}

interface LocalAgentIntegrationRecord {
  id: string;
  name: string;
  description: string;
  enabled: boolean;
  status?: 'disconnected' | 'configured' | 'connecting' | 'ready' | 'degraded' | 'error';
  capabilities?: {
    localChat?: boolean;
    chatAttachments?: boolean;
    connectFromUi?: boolean;
    installNode?: boolean;
    dkgPrimaryMemory?: boolean;
    wmImportPipeline?: boolean;
    nodeServedSkill?: boolean;
  };
  transport?: {
    kind?: string;
    bridgeUrl?: string;
    gatewayUrl?: string;
    healthUrl?: string;
  };
  runtime?: {
    status?: 'disconnected' | 'configured' | 'connecting' | 'ready' | 'degraded' | 'error';
    ready?: boolean;
    lastError?: string | null;
    updatedAt?: string;
  };
  manifest?: {
    packageName?: string;
    version?: string;
    setupEntry?: string;
  };
  metadata?: Record<string, unknown>;
}

export type LocalAgentIntegrationStatus =
  | 'chat_ready'
  | 'connecting'
  | 'degraded'
  | 'bridge_offline'
  | 'available'
  | 'coming_soon';

export interface LocalAgentIntegration {
  id: string;
  name: string;
  framework: string;
  description: string;
  defaultSessionId?: string;
  profile?: string;
  chatSupported: boolean;
  chatAttachments: boolean;
  connectSupported: boolean;
  configured: boolean;
  detected: boolean;
  persistentChat: boolean;
  chatReady: boolean;
  bridgeOnline: boolean;
  bridgeStatusLabel: string;
  status: LocalAgentIntegrationStatus;
  statusLabel: string;
  detail: string;
  error?: string;
  target?: LocalAgentChannelTarget;
  source: 'live' | 'planned';
}

export interface LocalAgentConnectResult {
  integration: LocalAgentIntegration;
  notice?: string;
}

export interface LocalAgentHistoryMessage {
  uri: string;
  text: string;
  author: string;
  ts: string;
  turnId?: string;
  failureReason?: string | null;
  attachmentRefs?: LocalAgentChatAttachmentRef[];
  toolCalls?: Array<{ name: string; args: Record<string, unknown>; result: unknown }>;
}

interface LocalAgentSurface {
  connectSupported: boolean;
  chatSupported: boolean;
  defaultSessionId?: (args: {
    integrationId: string;
    record?: LocalAgentIntegrationRecord;
    health?: LocalAgentHealthResponse | null;
  }) => string;
  resolveChatContext?: (args: {
    integrationId: string;
    sessionId?: string;
    profile?: string;
  }) => Record<string, unknown>;
  fetchHealth?: () => Promise<{
    ok: boolean;
    target?: LocalAgentChannelTarget;
    error?: string;
    profile?: string;
    memory?: LocalAgentHealthResponse['memory'];
    status?: string;
  }>;
  streamChat?: typeof streamOpenClawLocalChat;
}

const LOCAL_AGENT_SURFACES: Record<string, LocalAgentSurface> = {
  openclaw: {
    connectSupported: true,
    chatSupported: true,
    defaultSessionId: ({ integrationId }) => `${integrationId}:dkg-ui`,
    resolveChatContext: ({ integrationId, sessionId }) => {
      if (!sessionId) return {};
      const prefix = `${integrationId}:dkg-ui:`;
      if (!sessionId.startsWith(prefix)) return {};
      const identity = sessionId.slice(prefix.length).trim();
      return identity ? { identity } : {};
    },
    fetchHealth: fetchOpenClawLocalHealth,
    streamChat: streamOpenClawLocalChat,
  },
  hermes: {
    connectSupported: true,
    chatSupported: true,
    defaultSessionId: ({ integrationId, record, health }) => buildHermesDefaultSessionId(integrationId, record, health),
    resolveChatContext: ({ sessionId, profile }) => ({
      ...(sessionId ? { sessionId } : {}),
      ...(profile ? { profile } : {}),
    }),
    fetchHealth: fetchHermesLocalHealth,
    streamChat: streamHermesLocalChat,
  },
};

export function getDefaultLocalAgentSessionId(integrationId: string): string | null {
  const normalizedId = integrationId.trim().toLowerCase();
  return LOCAL_AGENT_SURFACES[normalizedId]?.defaultSessionId?.({ integrationId: normalizedId }) ?? null;
}

function resolveLocalAgentHistorySessionId(integrationId: string, sessionId?: string): string | null {
  if (sessionId?.trim()) return sessionId.trim();
  return getDefaultLocalAgentSessionId(integrationId);
}

async function fetchLocalAgentHistoryBySessionId(
  sessionId: string,
  limit = 50,
): Promise<LocalAgentHistoryMessage[]> {
  const buildFallbackHistoryMessageUri = (message: Pick<MemorySession['messages'][number], 'author' | 'text' | 'ts' | 'turnId'>): string => {
    if (message.turnId) {
      return `urn:dkg:chat:turn:${encodeURIComponent(message.turnId)}:${encodeURIComponent(message.author)}`;
    }
    const source = `${sessionId}\n${message.author}\n${message.ts}\n${message.text}`;
    let hash = 2166136261;
    for (let index = 0; index < source.length; index += 1) {
      hash ^= source.charCodeAt(index);
      hash = Math.imul(hash, 16777619);
    }
    return `urn:dkg:chat:session:${encodeURIComponent(sessionId)}:message:${(hash >>> 0).toString(16)}`;
  };

  try {
    const session = await fetchMemorySession(sessionId, {
      limit,
      order: 'desc',
    });
    return [...session.messages]
      .reverse()
      .map((message) => ({
        uri: message.uri || buildFallbackHistoryMessageUri(message),
        text: message.text,
        author: message.author,
        ts: message.ts,
        turnId: message.turnId,
        failureReason: message.failureReason,
        attachmentRefs: message.attachmentRefs,
        toolCalls: message.toolCalls,
      }));
  } catch (err) {
    if (err instanceof HttpError && err.status === 404) {
      return [];
    }
    throw err;
  }
}

/**
 * Load chat history for the local OpenClaw agent from the DKG graph.
 * Queries schema:Message items linked to the openclaw:dkg-ui session.
 */
export async function fetchOpenClawLocalHistory(limit = 50): Promise<LocalAgentHistoryMessage[]> {
  return fetchLocalAgentHistory('openclaw', limit);
}

export type LocalAgentStreamEvent = OpenClawStreamEvent;

function hasLocalAgentTransportHints(record: LocalAgentIntegrationRecord): boolean {
  return Boolean(
    record.transport?.bridgeUrl
    || record.transport?.gatewayUrl
    || record.transport?.healthUrl,
  );
}

function firstTrimmedString(...values: unknown[]): string | null {
  for (const value of values) {
    if (typeof value === 'string' && value.trim()) return value.trim();
  }
  return null;
}

function sessionSegment(value: string): string {
  const normalized = value
    .trim()
    .toLowerCase()
    .replace(/[^a-z0-9._-]+/g, '-')
    .replace(/^-+|-+$/g, '');
  return normalized || stableSessionHash(value);
}

function stableSessionHash(value: string): string {
  let hash = 2166136261;
  for (let index = 0; index < value.length; index += 1) {
    hash ^= value.charCodeAt(index);
    hash = Math.imul(hash, 16777619);
  }
  return (hash >>> 0).toString(36);
}

function buildHermesDefaultSessionId(
  integrationId: string,
  record?: LocalAgentIntegrationRecord,
  health?: LocalAgentHealthResponse | null,
): string {
  const metadata = record?.metadata ?? {};
  const profile = firstTrimmedString(
    health?.profile,
    metadata.profileName,
    metadata.profile,
  );
  const hermesHome = firstTrimmedString(metadata.hermesHome);
  const transportSegment = !hermesHome ? buildHermesTransportSessionSegment(record) : null;
  const segments = [
    profile ? `profile-${sessionSegment(profile)}` : null,
    hermesHome ? `home-${stableSessionHash(hermesHome)}` : null,
    transportSegment,
  ].filter((value): value is string => value != null);
  return segments.length
    ? `${integrationId}:dkg-ui:${segments.join(':')}`
    : `${integrationId}:dkg-ui`;
}

function buildHermesTransportSessionSegment(record?: LocalAgentIntegrationRecord): string | null {
  const transport = record?.transport;
  if (!transport) return null;
  const parts = [
    transport.kind,
    transport.bridgeUrl,
    transport.gatewayUrl,
    transport.healthUrl,
  ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
  return parts.length ? `transport-${stableSessionHash(parts.join('|'))}` : null;
}

function localAgentMemoryLabel(memory: LocalAgentHealthResponse['memory']): string | null {
  if (!memory) return null;
  if (typeof memory === 'string') return memory;
  return [
    memory.provider,
    memory.mode,
    memory.status,
  ].filter(Boolean).join(' / ') || null;
}

function isDegradedLocalAgentHealth(
  runtimeStatus: LocalAgentIntegrationRecord['status'] | undefined,
  health: LocalAgentHealthResponse | null,
): boolean {
  if (runtimeStatus === 'degraded') return true;
  const status = String(health?.status ?? '').toLowerCase();
  const memory = health?.memory;
  return status.includes('degraded')
    || status.includes('conflict')
    || (typeof memory === 'object' && memory != null && memory.conflict === true);
}

function isHealthObject(value: unknown): value is Omit<LocalAgentHealthResponse, 'bridge' | 'gateway'> {
  return !!value && typeof value === 'object' && !Array.isArray(value);
}

function healthObjectScore(value: unknown): number {
  if (!isHealthObject(value)) return -1;
  let score = value.ok === false ? 100 : 0;
  if (value.profile) score += 10;
  if (value.memory) score += 10;
  if (value.status) score += 10;
  if (value.error) score += 10;
  if (value.ok === true) score += 1;
  return score;
}

function localAgentHealthTargetDetails(health: LocalAgentHealthResponse): {
  target?: LocalAgentChannelTarget;
  details?: Omit<LocalAgentHealthResponse, 'bridge' | 'gateway'>;
} {
  if (health.target === 'bridge') return { target: 'bridge', details: health.bridge };
  if (health.target === 'gateway') return { target: 'gateway', details: health.gateway };

  const bridgeScore = healthObjectScore(health.bridge);
  const gatewayScore = healthObjectScore(health.gateway);
  if (bridgeScore < 0 && gatewayScore < 0) return {};
  return bridgeScore >= gatewayScore
    ? { target: 'bridge', details: health.bridge }
    : { target: 'gateway', details: health.gateway };
}

function normalizeLocalAgentHealth(health: LocalAgentHealthResponse | null): LocalAgentHealthResponse | null {
  if (!health) return null;
  const { target, details: targetDetails } = localAgentHealthTargetDetails(health);
  if (!isHealthObject(targetDetails)) return health;
  const targetOk = typeof targetDetails.ok === 'boolean' ? targetDetails.ok : undefined;
  return {
    ...targetDetails,
    ...health,
    ok: health.ok === false ? false : targetOk ?? health.ok,
    target: health.target ?? target,
    profile: health.profile ?? targetDetails.profile,
    memory: health.memory ?? targetDetails.memory,
    status: health.status ?? targetDetails.status,
    error: health.error ?? targetDetails.error,
  };
}

function hermesDetail(
  record: LocalAgentIntegrationRecord,
  health: LocalAgentHealthResponse | null,
): string | null {
  if (String(record.id ?? '').toLowerCase() !== 'hermes') return null;
  const profile = health?.profile;
  const profileText = profile ? `profile ${profile}` : 'the configured profile';
  const memoryLabel = localAgentMemoryLabel(health?.memory);
  const memory = typeof health?.memory === 'object' && health.memory != null ? health.memory : null;
  if (memory?.conflict === true || String(health?.status ?? '').toLowerCase().includes('conflict')) {
    return `${record.name} ${profileText} has a memory provider conflict${memoryLabel ? ` (${memoryLabel})` : ''}.`;
  }
  if (health?.ok) {
    return `${record.name} ${profileText} is connected${memoryLabel ? ` with ${memoryLabel} memory` : ''}.`;
  }
  if (health?.status) {
    return `${record.name} ${profileText} reports ${health.status}${memoryLabel ? ` (${memoryLabel})` : ''}.`;
  }
  return null;
}

async function mapLocalAgentIntegrationRecord(record: LocalAgentIntegrationRecord): Promise<LocalAgentIntegration> {
  const id = String(record.id ?? '').toLowerCase();
  const surface = LOCAL_AGENT_SURFACES[id];
  const hasChatBridge = record.capabilities?.localChat === true && surface?.chatSupported === true;
  const chatAttachments = hasChatBridge && record.capabilities?.chatAttachments === true;
  const connectSupported = record.capabilities?.connectFromUi === true && surface?.connectSupported === true;
  const configured = record.enabled === true;
  const runtimeStatus = record.runtime?.status;
  const health = configured && hasChatBridge && surface?.fetchHealth
    ? normalizeLocalAgentHealth(await surface.fetchHealth().catch(() => null))
    : null;
  const degraded = isDegradedLocalAgentHealth(runtimeStatus, health);
  const chatReady = health?.ok === true && !degraded;
  const bridgeOnline = chatReady;
  const persistentChat = configured && hasChatBridge && (
    chatReady
    || runtimeStatus === 'connecting'
    || runtimeStatus === 'degraded'
    || record.runtime?.ready === true
    || hasLocalAgentTransportHints(record)
  );
  const hermesRuntimeDetail = hermesDetail(record, health);
  const defaultSessionId = surface?.defaultSessionId?.({ integrationId: id, record, health });
  const profile = id === 'hermes'
    ? firstTrimmedString(health?.profile, record.metadata?.profileName, record.metadata?.profile)
    : undefined;

  let status: LocalAgentIntegrationStatus;
  let statusLabel: string;
  let detail: string;
  if (persistentChat && degraded) {
    status = 'degraded';
    statusLabel = 'Degraded';
    detail = hermesRuntimeDetail
      ?? health?.error
      ?? record.runtime?.lastError
      ?? `${record.name} is attached to this node, but one capability is degraded.`;
  } else if (persistentChat && record.runtime?.status === 'connecting') {
    status = 'connecting';
    statusLabel = 'Connecting';
    detail = record.runtime?.lastError
      ?? `${record.name} is registered and still starting up.`;
  } else if (bridgeOnline) {
    status = 'chat_ready';
    statusLabel = 'Chat ready';
    detail = `${record.name} is connected to this node and ready for chat.`;
  } else if (persistentChat) {
    status = 'bridge_offline';
    statusLabel = 'Bridge offline';
    detail = hermesRuntimeDetail
      ?? health?.error
      ?? record.runtime?.lastError
      ?? `${record.name} is attached to this node, but it is not responding right now.`;
  } else if (surface) {
    status = 'available';
    statusLabel = connectSupported ? 'Ready to connect' : 'Awaiting chat bridge';
    detail = configured
      ? `${record.name} is registered, but this panel is waiting for the framework chat bridge.`
      : (record.runtime?.lastError
          ?? `Use the node-served skill plus ${record.name} onboarding to attach an existing local agent.`);
  } else {
    status = 'coming_soon';
    statusLabel = configured ? 'Registered, panel pending' : 'Next integration';
    detail = configured
      ? `${record.name} is registered on the node, but the right-panel chat bridge is not wired yet.`
      : 'The local-agent registry is in place so this framework can plug into the same side-panel flow next.';
  }

  const bridgeStatusLabel = bridgeOnline
    ? 'Connected'
    : status === 'connecting'
      ? 'Connecting'
      : status === 'degraded'
        ? 'Degraded'
      : persistentChat
        ? 'Unavailable'
        : connectSupported
          ? 'Ready to connect'
          : 'Coming next';

  return {
    id,
    name: record.name,
    framework: record.name,
    description: record.description,
    defaultSessionId,
    profile,
    chatSupported: hasChatBridge,
    chatAttachments,
    connectSupported,
    configured,
    detected: configured || chatReady,
    persistentChat,
    chatReady,
    bridgeOnline,
    bridgeStatusLabel,
    status,
    statusLabel,
    detail,
    error: chatReady ? undefined : (health?.error ?? record.runtime?.lastError ?? undefined),
    target: health?.target,
    source: configured || surface ? 'live' : 'planned',
  } satisfies LocalAgentIntegration;
}

export type RegistryTrustTier = 'community' | 'verified' | 'featured';
export type RegistryMemoryLayer = 'WM' | 'SWM' | 'VM';
export type RegistryInstallKind = 'cli' | 'mcp' | 'service' | 'agent-plugin' | 'manual';

export interface RegistryIntegrationSummary {
  slug: string;
  name: string;
  description: string;
  trustTier: RegistryTrustTier;
  memoryLayers: RegistryMemoryLayer[];
  installKind: RegistryInstallKind;
  repo: string;
  maintainer: string;
  targetAgents: string[];
}

export interface RegistryListResult {
  entries: RegistryIntegrationSummary[];
  failures: Array<{ slug: string; error: string }>;
  fetchedAt: number;
  tier: RegistryTrustTier;
}

export async function fetchRegistryIntegrations(opts: { tier?: RegistryTrustTier } = {}): Promise<RegistryListResult> {
  const search = opts.tier ? `?tier=${encodeURIComponent(opts.tier)}` : '';
  return get<RegistryListResult>(`/api/integrations/registry${search}`);
}

export async function fetchLocalAgentIntegrations(): Promise<{ integrations: LocalAgentIntegration[] }> {
  const response = await get<{ integrations?: LocalAgentIntegrationRecord[] }>('/api/local-agent-integrations');
  const integrations = await Promise.all((response.integrations ?? []).map(mapLocalAgentIntegrationRecord));

  integrations.sort((a, b) => {
    const aPriority = a.id === 'openclaw' ? 0 : 1;
    const bPriority = b.id === 'openclaw' ? 0 : 1;
    if (aPriority !== bPriority) return aPriority - bPriority;
    if (a.persistentChat !== b.persistentChat) return a.persistentChat ? -1 : 1;
    return a.name.localeCompare(b.name);
  });

  return { integrations };
}

export async function connectLocalAgentIntegration(id: string): Promise<LocalAgentConnectResult> {
  const normalizedId = id.trim().toLowerCase();
  const surface = LOCAL_AGENT_SURFACES[normalizedId];
  if (!surface?.connectSupported) {
    throw new Error(`${id} local connect is not available yet.`);
  }

  const response = await post<{ ok: boolean; notice?: string; integration?: LocalAgentIntegrationRecord }>('/api/local-agent-integrations/connect', {
    id: normalizedId,
    metadata: {
      source: 'node-ui',
    },
  });
  const integration = response.integration
    ? await mapLocalAgentIntegrationRecord(response.integration)
    : (await fetchLocalAgentIntegrations()).integrations.find((item) => item.id === normalizedId);
  if (!integration) {
    throw new Error(`Missing local agent integration: ${normalizedId}`);
  }
  return {
    integration,
    notice: response.notice,
  };
}

export async function disconnectLocalAgentIntegration(id: string): Promise<void> {
  const normalizedId = id.trim().toLowerCase();
  await put(`/api/local-agent-integrations/${encodeURIComponent(normalizedId)}`, {
    enabled: false,
    runtime: {
      status: 'disconnected',
      ready: false,
      lastError: null,
    },
  });
}

export async function refreshLocalAgentIntegration(id: string): Promise<LocalAgentConnectResult> {
  const normalizedId = id.trim().toLowerCase();
  const response = await post<{ ok: boolean; notice?: string; integration?: LocalAgentIntegrationRecord }>(
    `/api/local-agent-integrations/${encodeURIComponent(normalizedId)}/refresh`,
    {},
  );
  const integration = response.integration
    ? await mapLocalAgentIntegrationRecord(response.integration)
    : (await fetchLocalAgentIntegrations()).integrations.find((item) => item.id === normalizedId);
  if (!integration) {
    throw new Error(`Missing local agent integration: ${normalizedId}`);
  }
  return {
    integration,
    notice: response.notice,
  };
}

export async function fetchLocalAgentHealth(id: string) {
  if (id === 'openclaw') return fetchOpenClawLocalHealth();
  if (id === 'hermes') return fetchHermesLocalHealth();
  throw new Error(`${id} local health is not available yet.`);
}

export async function fetchLocalAgentHistory(
  id: string,
  limit = 50,
  opts: { sessionId?: string } = {},
): Promise<LocalAgentHistoryMessage[]> {
  const sessionId = resolveLocalAgentHistorySessionId(id, opts.sessionId);
  if (!sessionId) return [];
  return fetchLocalAgentHistoryBySessionId(sessionId, limit);
}

export async function streamLocalAgentChat(
  id: string,
  text: string,
  opts: LocalAgentChatRequestOptions & {
    onEvent?: (event: LocalAgentStreamEvent) => void;
  } = {},
): Promise<LocalAgentChatResponse> {
  const normalizedId = id.trim().toLowerCase();
  const surface = LOCAL_AGENT_SURFACES[normalizedId];
  if (surface?.streamChat) {
    const { sessionId, profile, ...transportOpts } = opts;
    return surface.streamChat(text, {
      ...transportOpts,
      ...surface.resolveChatContext?.({
        integrationId: normalizedId,
        sessionId,
        profile,
      }),
    });
  }
  throw new Error(`${id} local chat is not available yet.`);
}

/** Extract plain string from a SPARQL binding value (standard JSON or N-Triples). */
function bv(v: unknown): string | undefined {
  if (v == null) return undefined;
  if (typeof v === 'object' && 'value' in (v as any)) return String((v as any).value);
  if (typeof v === 'string') {
    // N-Triples typed literal: "value"^^<type> or "value"@lang — strip suffix first
    let s = v;
    const typedMatch = s.match(/^(".*")\^\^<[^>]+>$/);
    if (typedMatch) s = typedMatch[1];
    const langMatch = s.match(/^(".*")@[a-z-]+$/i);
    if (langMatch) s = langMatch[1];
    // Strip surrounding quotes
    if (s.startsWith('"') && s.endsWith('"')) {
      return s.slice(1, -1)
        .replace(/\\"/g, '"')
        .replace(/\\n/g, '\n')
        .replace(/\\r/g, '\r')
        .replace(/\\t/g, '\t');
    }
    return v;
  }
  return String(v);
}

// --- Economics / spending ---
export interface SpendingPeriod {
  label: string;
  publishCount: number;
  successCount: number;
  totalGasEth: number;
  totalTrac: number;
  avgGasEth: number;
  avgTrac: number;
}
export const fetchEconomics = () =>
  get<{ periods: SpendingPeriod[] }>('/api/economics');

// --- Wallet & chain ---
export const fetchWalletsBalances = () =>
  get<{
    wallets: string[];
    balances: Array<{ address: string; eth: string; trac: string; symbol: string }>;
    chainId: string | null;
    rpcUrl: string | null;
    symbol?: string;
    error?: string;
  }>('/api/wallets/balances');
export const fetchRpcHealth = () =>
  get<{ ok: boolean; rpcUrl: string | null; latencyMs: number | null; blockNumber: number | null; error?: string }>('/api/chain/rpc-health');

// --- Node control ---
export const shutdownNode = () =>
  post<{ ok: boolean }>('/api/shutdown', {});

// --- Integrations ---
export const subscribeToContextGraph = (contextGraphId: string) =>
  post<{ subscribed: string; catchup?: { status: string; jobId: string } }>('/api/subscribe', { contextGraphId });

// --- Notifications ---

export interface Notification {
  id: number;
  ts: number;
  type: string;
  title: string;
  message: string;
  source: string | null;
  peer: string | null;
  read: number;
  meta: string | null;
}

export const fetchNotifications = (opts?: { since?: number; limit?: number }) => {
  const params = new URLSearchParams();
  if (opts?.since) params.set('since', String(opts.since));
  if (opts?.limit) params.set('limit', String(opts.limit));
  const qs = params.toString();
  return get<{ notifications: Notification[]; unreadCount: number }>(`/api/notifications${qs ? `?${qs}` : ''}`);
};

export const markNotificationsRead = (ids?: number[]) =>
  post<{ marked: number }>('/api/notifications/read', ids ? { ids } : {});

// --- Sub-graphs (lightweight list + counts for SubGraphBar) ---
export interface SubGraphInfo {
  name: string;
  uri: string;
  description?: string;
  createdBy?: string;
  createdAt?: string;
  entityCount: number;
  tripleCount: number;
}
export const fetchSubGraphs = (contextGraphId: string) =>
  get<{ contextGraphId: string; subGraphs: SubGraphInfo[] }>(
    `/api/sub-graph/list?contextGraphId=${encodeURIComponent(contextGraphId)}`,
  );
