import { createHash } from "node:crypto";
import { hostname } from "node:os";
import { MAX_AGENT_HOST_CHARS } from "./protocol.ts";
import type { AgentRecord, DeliveryMode, HubEvent, LeaseRecord, MessageRecord, WorkflowMessageContext } from "./protocol.ts";
import type { ContextAuthority, ContextConfidence, ContextItem, ContextItemAuditMetadata, ContextItemKind, ContextPacket } from "./context.ts";
import {
  canonicalWorkflowEvidenceKey,
  type ImprovementArea,
  type ImprovementAreaReport,
  type ImprovementSignal,
  type JournalCategory,
  type WorkflowCheckpointStatus,
  type WorkflowEvidenceInput,
  type WorkflowEvidenceReferenceInput,
  type WorkflowJournalEntry,
  type WorkflowRun,
} from "./workflow.ts";

/** Metadata-only audit of an assembled context packet. Never contains raw
 * item bodies. */
export interface ContextRequestAudit {
  request: { project: string; role: string; task: string; workflowRunId?: string; stageId?: string };
  selectedIds: string[];
  provenanceSummary: Record<string, number>;
  estimatedTokens: number;
  budgetTokens: number;
  candidateCount: number;
  excludedSuperseded: number;
  unresolvedGaps: string[];
  /** Numbers only: distinct task tokens, eligible candidates sharing a task
   * token, and each selected item's rounded relevance (index-aligned with
   * selectedIds). */
  relevance: { taskTokens: number; matchedCandidates: number; selected: number[] };
}

/** One recall result: bounded metadata plus the item's rounded relevance to
 * the query. Never includes the summary. */
export type ContextRecallItem = ContextItemAuditMetadata & { relevance: number };

export interface ContextExplanation {
  found: boolean;
  lineage: string[];
  evidenceRefs: string[];
  sources: { id: string; sourceType: string; sourceRef?: string }[];
}

export interface ContextWikiCompilation {
  audit: {
    project: string;
    pages: string[];
    stateItems: number;
    contextItems: number;
    contradictions: number;
    compiledAt: string;
  };
  pages: { path: string; content: string }[];
}

export interface HubClientOptions {
  serverUrl: string;
  authToken?: string;
  name: string;
  purpose: string;
  project: string;
  model?: string;
  /** Label for the box this client runs on, declared at registration.
   * Defaults to the machine hostname; the hub records it for readers and
   * never uses it to authorize anything. */
  host?: string;
  heartbeatMs?: number;
  reconnectMs?: number;
  requestTimeoutMs?: number;
  /** Injectable fetch for CLI/tests; defaults to global fetch. */
  fetchImpl?: typeof fetch;
}

export interface SendOptions {
  target: string;
  content: string;
  delivery?: DeliveryMode;
  hops?: number;
  maxHops?: number;
  correlationId?: string;
  replyTo?: string;
  idempotencyKey?: string;
  workflowContext?: Omit<WorkflowMessageContext, "schema">;
  ttlMs?: number;
  allowOffline?: boolean;
}

export interface FanoutResult {
  target: string;
  messageId?: string;
  status: "pending" | "replied" | "cancelled" | "expired" | "error";
  messageStatus?: "queued" | "delivered";
  expiresAt?: string;
  waitStatus?: "timed_out" | "aborted";
  reply?: string;
  error?: string;
}

class MeshWaitError extends Error {
  readonly waitStatus: "timed_out" | "aborted";

  constructor(waitStatus: "timed_out" | "aborted", messageId: string) {
    super(waitStatus === "aborted" ? "await cancelled" : `timed out waiting for ${messageId}`);
    this.name = "MeshWaitError";
    this.waitStatus = waitStatus;
  }
}

/** The box label a client declares when it registers. Bounded to the hub's
 * limit so a long hostname cannot turn registration into a 400, and dropped
 * entirely when the platform cannot report one. */
function defaultHostLabel(): string | undefined {
  try {
    return hostname().trim().slice(0, MAX_AGENT_HOST_CHARS) || undefined;
  } catch {
    return undefined;
  }
}

function completedFanoutResult(target: string, message: MessageRecord): FanoutResult {
  if (message.status === "queued" || message.status === "delivered") {
    throw new Error(`message ${message.id} is not complete`);
  }
  return {
    target,
    messageId: message.id,
    status: message.status,
    ...(message.reply ? { reply: message.reply.content } : {}),
    ...(message.error ? { error: message.error } : {}),
  };
}

function fanoutIdempotencyKey(
  prefix: string,
  target: string,
  correlationId?: string,
  workflowContext?: Omit<WorkflowMessageContext, "schema">,
): string {
  // Preserve the exact pre-0.4.3 hash input when no workflow provenance is
  // requested. A pending fanout created before upgrade must remain an exact
  // idempotent retry instead of dispatching duplicate work.
  const scope = JSON.stringify(workflowContext
    ? {
        prefix,
        correlationId: correlationId ?? null,
        target: target.toLowerCase(),
        workflowContext: {
          runId: workflowContext.runId,
          stageId: workflowContext.stageId,
          requirementKey: canonicalWorkflowEvidenceKey(workflowContext.requirementKey),
          attempt: workflowContext.attempt,
        },
      }
    : {
        prefix,
        correlationId: correlationId ?? null,
        target: target.toLowerCase(),
      });
  return `fanout:${createHash("sha256").update(scope).digest("hex")}`;
}

export class HubHttpError extends Error {
  readonly statusCode: number;
  readonly code?: string;
  readonly requestId?: string;
  readonly extras?: Record<string, unknown>;

  constructor(
    statusCode: number,
    message: string,
    code?: string,
    requestId?: string,
    extras?: Record<string, unknown>,
  ) {
    super(message);
    this.name = "HubHttpError";
    this.statusCode = statusCode;
    if (code) this.code = code;
    if (requestId) this.requestId = requestId;
    if (extras) this.extras = extras;
  }
}

export class HubClient {
  readonly options: HubClientOptions;
  agent: AgentRecord | undefined;
  private agentKey: string | undefined;
  private heartbeatTimer?: NodeJS.Timeout;
  private eventsAbort?: AbortController;
  private stopped = true;
  private onEvent: ((event: HubEvent) => void | Promise<void>) | undefined;
  private registration: Promise<AgentRecord> | undefined;
  private eventLoop: Promise<void> | undefined;

  constructor(options: HubClientOptions) {
    this.options = { heartbeatMs: 10_000, reconnectMs: 1_000, requestTimeoutMs: 15_000, ...options };
  }

  async start(onEvent: (event: HubEvent) => void | Promise<void>): Promise<AgentRecord> {
    if (!this.stopped) throw new Error("hub client is already started");
    this.stopped = false;
    this.onEvent = onEvent;
    try {
      await this.register();
    } catch (error) {
      this.stopped = true;
      throw error;
    }
    this.heartbeatTimer = setInterval(() => void this.heartbeat(), this.options.heartbeatMs);
    this.heartbeatTimer.unref();
    this.eventLoop = this.runEventLoop();
    return this.agent!;
  }

  async stop(): Promise<void> {
    if (this.stopped) return;
    this.stopped = true;
    this.eventsAbort?.abort();
    if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
    await this.eventLoop;
    if (this.agent) {
      try {
        await this.request(`/v1/agents/${encodeURIComponent(this.agent.id)}`, { method: "DELETE" });
      } catch {
        // The hub may already be gone during process shutdown.
      }
    }
    this.agent = undefined;
    this.agentKey = undefined;
    this.onEvent = undefined;
    this.eventLoop = undefined;
  }

  /** Online peers of this client's project. `includeOffline` also returns
   * registered members whose lease the hub has already retired. */
  async listAgents(options: { includeOffline?: boolean } = {}): Promise<AgentRecord[]> {
    const path = options.includeOffline ? "/v1/agents?includeOffline=true" : "/v1/agents";
    const result = await this.request<{ agents: AgentRecord[] }>(path);
    return result.agents;
  }

  async send(options: SendOptions): Promise<MessageRecord> {
    const result = await this.request<{ message: MessageRecord }>("/v1/messages", {
      method: "POST",
      body: JSON.stringify(options),
    });
    return result.message;
  }

  async fanout(options: {
    targets: string[];
    content: string;
    correlationId?: string;
    idempotencyKeyPrefix?: string;
    workflowContext?: Omit<WorkflowMessageContext, "schema">;
    ttlMs?: number;
    timeoutMs?: number;
    signal?: AbortSignal;
    hops?: number;
    maxHops?: number;
  }): Promise<FanoutResult[]> {
    const targets = [...new Set(options.targets.map((target) => target.trim().toLowerCase()).filter(Boolean))];
    if (targets.length < 1 || targets.length > 3) throw new Error("fanout requires between one and three unique targets");
    return await Promise.all(targets.map(async (target): Promise<FanoutResult> => {
      let message: MessageRecord | undefined;
      try {
        message = await this.send({
          target,
          content: options.content,
          delivery: "followUp",
          ...(options.correlationId ? { correlationId: options.correlationId } : {}),
          ...(options.workflowContext ? { workflowContext: options.workflowContext } : {}),
          ...(options.idempotencyKeyPrefix ? {
            idempotencyKey: fanoutIdempotencyKey(
              options.idempotencyKeyPrefix,
              target,
              options.correlationId,
              options.workflowContext,
            ),
          } : {}),
          ...(options.ttlMs ? { ttlMs: options.ttlMs } : {}),
          ...(options.hops !== undefined ? { hops: options.hops } : {}),
          ...(options.maxHops !== undefined ? { maxHops: options.maxHops } : {}),
        });
        const completed = await this.awaitResponse(
          message.id,
          options.timeoutMs ?? 30 * 60_000,
          options.signal,
        );
        return completedFanoutResult(target, completed);
      } catch (error) {
        if (message && error instanceof MeshWaitError) {
          try {
            const current = await this.getMessage(message.id);
            if (current.status === "queued" || current.status === "delivered") {
              return {
                target,
                messageId: current.id,
                status: "pending",
                messageStatus: current.status,
                expiresAt: current.expiresAt,
                waitStatus: error.waitStatus,
              };
            }
            return completedFanoutResult(target, current);
          } catch (finalError) {
            return {
              target,
              messageId: message.id,
              status: "error",
              error: finalError instanceof Error ? finalError.message : String(finalError),
            };
          }
        }
        return {
          target,
          ...(message ? { messageId: message.id } : {}),
          status: "error",
          error: error instanceof Error ? error.message : String(error),
        };
      }
    }));
  }

  /** This agent's open inbound requests (queued or delivered), oldest first. A read: nothing
   * is acknowledged. */
  async listInbox(): Promise<MessageRecord[]> {
    if (!this.agent) throw new Error("hub client is not registered");
    const result = await this.request<{ messages: MessageRecord[] }>(
      `/v1/agents/${encodeURIComponent(this.agent.id)}/inbox`,
    );
    return result.messages;
  }

  async getMessage(messageId: string): Promise<MessageRecord> {
    const result = await this.request<{ message: MessageRecord }>(`/v1/messages/${encodeURIComponent(messageId)}`);
    return result.message;
  }

  async acknowledge(messageId: string): Promise<MessageRecord> {
    const result = await this.request<{ message: MessageRecord }>(
      `/v1/messages/${encodeURIComponent(messageId)}/ack`,
      { method: "POST", body: "{}" },
    );
    return result.message;
  }

  async reply(messageId: string, content: string): Promise<MessageRecord> {
    const result = await this.request<{ message: MessageRecord }>(
      `/v1/messages/${encodeURIComponent(messageId)}/reply`,
      { method: "POST", body: JSON.stringify({ content }) },
    );
    return result.message;
  }

  async cancel(messageId: string): Promise<MessageRecord> {
    const result = await this.request<{ message: MessageRecord }>(
      `/v1/messages/${encodeURIComponent(messageId)}`,
      { method: "DELETE" },
    );
    return result.message;
  }

  // ----- Fenced leases over shared resources (P3) -----

  /**
   * Take or extend the lease over `resource` inside this client's project.
   *
   * The returned `fencingToken` is the whole point: hold it, present it on every
   * renewal, and present it again before committing anything shared. A hub that
   * has moved past it refuses, and the caller must stop rather than retry —
   * another holder owns the resource now. Rejects `HubHttpError` with code
   * `lease_held` when a live holder has it.
   */
  async acquireLease(resource: string, ttlMs?: number): Promise<{ lease: LeaseRecord; renewed: boolean }> {
    return await this.request(`/v1/leases/${encodeURIComponent(resource)}/acquire`, {
      method: "POST",
      body: JSON.stringify(ttlMs === undefined ? {} : { ttlMs }),
    });
  }

  /** Extend a lease this client holds. The token never changes on renewal; a
   * `lease_superseded` or `lease_expired` refusal means it is gone. */
  async renewLease(resource: string, fencingToken: number, ttlMs?: number): Promise<{ lease: LeaseRecord }> {
    return await this.request(`/v1/leases/${encodeURIComponent(resource)}/renew`, {
      method: "POST",
      body: JSON.stringify(ttlMs === undefined ? { fencingToken } : { fencingToken, ttlMs }),
    });
  }

  /** Give the resource back. */
  async releaseLease(resource: string, fencingToken: number): Promise<{ released: boolean; lease: LeaseRecord }> {
    return await this.request(`/v1/leases/${encodeURIComponent(resource)}/release`, {
      method: "POST",
      body: JSON.stringify({ fencingToken }),
    });
  }

  async listWorkflows(): Promise<WorkflowRun[]> {
    const result = await this.request<{ runs: WorkflowRun[] }>("/v1/workflows");
    return result.runs;
  }

  async getWorkflow(runId: string): Promise<{ run: WorkflowRun; journal: WorkflowJournalEntry[] }> {
    return await this.request(`/v1/workflows/${encodeURIComponent(runId)}`);
  }

  async checkpointWorkflow(
    runId: string,
    input: {
      stageId: string;
      status: WorkflowCheckpointStatus;
      summary: string;
      evidence?: WorkflowEvidenceInput;
      evidenceRefs?: WorkflowEvidenceReferenceInput;
    },
  ): Promise<{ run: WorkflowRun; retry: boolean; completed: boolean; instruction: string }> {
    return await this.request(`/v1/workflows/${encodeURIComponent(runId)}/checkpoints`, {
      method: "POST",
      body: JSON.stringify(input),
    });
  }

  async waitForWorkflowSignal(
    runId: string,
    input: {
      stageId: string;
      signalKey: string;
      summary: string;
      evidence?: WorkflowEvidenceInput;
      evidenceRefs?: WorkflowEvidenceReferenceInput;
      timeoutMs?: number;
    },
  ): Promise<{ run: WorkflowRun; instruction: string }> {
    return await this.request(`/v1/workflows/${encodeURIComponent(runId)}/waits`, {
      method: "POST",
      body: JSON.stringify(input),
    });
  }

  async recordWorkflowEntry(
    runId: string,
    input: {
      category: JournalCategory;
      /** Required unless stageId names a stage that declares an area. */
      area?: ImprovementArea;
      severity?: "info" | "warning" | "error";
      summary: string;
      details?: string;
      evidence?: string[];
      relatedEntryIds?: string[];
      /** Stage the entry is recorded against; binds run/stage/attempt
       * provenance. The hub derives the attempt; callers never supply one. */
      stageId?: string;
    },
  ): Promise<WorkflowJournalEntry> {
    const result = await this.request<{ entry: WorkflowJournalEntry }>(
      `/v1/workflows/${encodeURIComponent(runId)}/journal`,
      { method: "POST", body: JSON.stringify(input) },
    );
    return result.entry;
  }

  async improvementReport(): Promise<{ reports: ImprovementAreaReport[]; signals: ImprovementSignal[]; entries: number }> {
    return await this.request("/v1/improvements");
  }

  // ----- Context operating-system API (v0.5, issue #34) -----

  async contextGet(input: {
    project: string;
    role: string;
    task: string;
    workflowRunId?: string;
    stageId?: string;
    budgetTokens?: number;
    includeKinds?: ContextItemKind[];
  }): Promise<{ packet: ContextPacket; audit: ContextRequestAudit }> {
    return await this.request("/v1/context/get", { method: "POST", body: JSON.stringify(input) });
  }

  async contextRecall(input: {
    project: string;
    query?: string;
    kinds?: ContextItemKind[];
    limit?: number;
  }): Promise<{ items: ContextRecallItem[]; unresolvedGaps: string[] }> {
    return await this.request("/v1/context/recall", { method: "POST", body: JSON.stringify(input) });
  }

  async contextState(input: {
    project: string;
    key: string;
    asOf?: string;
  }): Promise<{ state: ContextItem | null; key: string }> {
    return await this.request("/v1/context/state", { method: "POST", body: JSON.stringify(input) });
  }

  async contextStatePropose(input: {
    project: string;
    key: string;
    summary: string;
    authority: ContextAuthority;
    confidence: ContextConfidence;
    evidenceRefs: string[];
  }): Promise<{ proposalId: string }> {
    return await this.request("/v1/context/state/propose", { method: "POST", body: JSON.stringify(input) });
  }

  async contextStatePromote(input: {
    project: string;
    proposalId: string;
    evidence: string[];
  }): Promise<{ state: ContextItem }> {
    return await this.request("/v1/context/state/promote", { method: "POST", body: JSON.stringify(input) });
  }

  async contextEpisode(input: {
    project: string;
    workflowRunId?: string;
  }): Promise<{ episodes: ContextItem[] }> {
    return await this.request("/v1/context/episode", { method: "POST", body: JSON.stringify(input) });
  }

  async contextExplain(input: {
    project: string;
    id: string;
  }): Promise<ContextExplanation> {
    return await this.request("/v1/context/explain", { method: "POST", body: JSON.stringify(input) });
  }

  async contextWikiCompile(input: { project: string }): Promise<ContextWikiCompilation> {
    return await this.request("/v1/context/wiki/compile", { method: "POST", body: JSON.stringify(input) });
  }

  async awaitResponse(messageId: string, timeoutMs = 30 * 60_000, signal?: AbortSignal): Promise<MessageRecord> {
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
      if (signal?.aborted) throw new MeshWaitError("aborted", messageId);
      const message = await this.getMessage(messageId);
      if (["replied", "cancelled", "expired", "error"].includes(message.status)) return message;
      await new Promise<void>((resolve, reject) => {
        const onAbort = () => {
          signal?.removeEventListener("abort", onAbort);
          clearTimeout(timer);
          reject(new MeshWaitError("aborted", messageId));
        };
        const timer = setTimeout(() => {
          signal?.removeEventListener("abort", onAbort);
          resolve();
        }, Math.min(500, Math.max(1, deadline - Date.now())));
        signal?.addEventListener("abort", onAbort, { once: true });
        if (signal?.aborted) onAbort();
        timer.unref();
      });
    }
    throw new MeshWaitError("timed_out", messageId);
  }

  private async heartbeat(): Promise<void> {
    if (this.stopped || !this.agent) return;
    try {
      await this.request(`/v1/agents/${encodeURIComponent(this.agent.id)}/heartbeat`, {
        method: "POST",
        body: "{}",
      });
    } catch (error) {
      if (error instanceof HubHttpError && error.statusCode === 401) void this.recoverRegistration();
    }
  }

  private async runEventLoop(): Promise<void> {
    while (!this.stopped && this.agent) {
      this.eventsAbort = new AbortController();
      try {
        const response = await (this.options.fetchImpl ?? fetch)(
          `${this.options.serverUrl.replace(/\/$/, "")}/v1/events?agentId=${encodeURIComponent(this.agent.id)}`,
          {
            headers: this.headers(),
            signal: this.eventsAbort.signal,
          },
        );
        if (response.status === 401) {
          await response.body?.cancel();
          await this.recoverRegistration();
          continue;
        }
        if (!response.ok || !response.body) throw new Error(`event stream failed with HTTP ${response.status}`);
        const decoder = new TextDecoder();
        let buffer = "";
        for await (const chunk of response.body) {
          if (this.stopped) break;
          buffer += decoder.decode(chunk, { stream: true }).replaceAll("\r\n", "\n");
          let boundary: number;
          while ((boundary = buffer.indexOf("\n\n")) >= 0) {
            const frame = buffer.slice(0, boundary);
            buffer = buffer.slice(boundary + 2);
            const data = frame
              .split("\n")
              .filter((line) => line.startsWith("data:"))
              .map((line) => line.slice(5).trimStart())
              .join("\n");
            if (!data) continue;
            const parsed = JSON.parse(data) as HubEvent | { agent: AgentRecord };
            if ("type" in parsed) await this.onEvent?.(parsed);
          }
        }
      } catch (error) {
        if (this.stopped || (error instanceof Error && error.name === "AbortError")) return;
      }
      if (!this.stopped) await new Promise((resolve) => setTimeout(resolve, this.options.reconnectMs));
    }
  }

  private headers(includeIdentity = true): Record<string, string> {
    const headers: Record<string, string> = { "content-type": "application/json" };
    if (this.options.authToken) headers.authorization = `Bearer ${this.options.authToken}`;
    if (includeIdentity && this.agent && this.agentKey) {
      headers["x-kxm-agent-id"] = this.agent.id;
      headers["x-kxm-agent-key"] = this.agentKey;
    }
    return headers;
  }

  private async register(): Promise<AgentRecord> {
    if (this.registration) return this.registration;
    this.registration = (async () => {
      const registration = await this.request<{ agent: AgentRecord; agentKey: string }>("/v1/agents/register", {
        method: "POST",
        body: JSON.stringify({
          name: this.options.name,
          purpose: this.options.purpose,
          project: this.options.project,
          model: this.options.model,
          host: this.options.host ?? defaultHostLabel(),
        }),
      }, false);
      this.agent = registration.agent;
      this.agentKey = registration.agentKey;
      return registration.agent;
    })();
    try {
      return await this.registration;
    } finally {
      this.registration = undefined;
    }
  }

  private async recoverRegistration(): Promise<void> {
    if (this.stopped) return;
    this.agent = undefined;
    this.agentKey = undefined;
    await this.register();
  }

  private async request<T = unknown>(path: string, init: RequestInit = {}, includeIdentity = true): Promise<T> {
    return await hubJsonRequest<T>(this.options, path, init, this.headers(includeIdentity));
  }
}

/** One JSON request to the hub with a bounded timeout. A non-2xx answer
 * becomes a `HubHttpError` carrying the hub's code and next-action hints. */
async function hubJsonRequest<T>(
  options: { serverUrl: string; requestTimeoutMs?: number; fetchImpl?: typeof fetch },
  path: string,
  init: RequestInit,
  headers: Record<string, string>,
): Promise<T> {
    const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
    const timeoutSignal = AbortSignal.timeout(requestTimeoutMs);
    const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal;
    let response: Response;
    try {
      response = await (options.fetchImpl ?? fetch)(`${options.serverUrl.replace(/\/$/, "")}${path}`, {
        ...init,
        signal,
        headers: { ...headers, ...(init.headers ?? {}) },
      });
    } catch (error) {
      if (timeoutSignal.aborted) throw new Error(`request timed out after ${requestTimeoutMs}ms`);
      throw error;
    }
    const text = await response.text();
    let body: Record<string, unknown> = {};
    if (text) {
      try {
        body = JSON.parse(text) as Record<string, unknown>;
      } catch {
        throw new Error(`hub returned invalid JSON with HTTP ${response.status}`);
      }
    }
    if (!response.ok) {
      const extras: Record<string, unknown> = {};
      for (const key of ["operation", "nextAction", "assignedCoordinatorName"]) {
        if (typeof body[key] === "string") extras[key] = body[key];
      }
      // A lease refusal carries the lease that won, so the loser can record who
      // holds the resource and at which token instead of guessing.
      if (body.lease && typeof body.lease === "object") extras.lease = body.lease;
      throw new HubHttpError(
        response.status,
        String(body.error ?? `HTTP ${response.status}`),
        typeof body.code === "string" ? body.code : undefined,
        response.headers.get("x-request-id") ?? undefined,
        Object.keys(extras).length > 0 ? extras : undefined,
      );
    }
    return body as T;
}

export interface RuntimeHubClientOptions {
  serverUrl: string;
  /** The hub project this Runtime reports into; its token is the admission. */
  project: string;
  authToken?: string;
  runtimeId: string;
  /** Box label; defaults to the hostname. Never used for authorization. */
  host?: string;
  requestTimeoutMs?: number;
  fetchImpl?: typeof fetch;
}

export interface RuntimePresenceView {
  runtimeId: string;
  host?: string;
  registeredAt: string;
  heartbeatAt: string;
  leaseExpiresAt: string;
  presence: "online" | "expired";
}

export interface SyncPushResult {
  projectId?: string;
  runId?: string;
  sequence?: number;
  outcome: "accepted" | "duplicate" | "conflict" | "rejected";
  code?: string;
}

export interface SyncPushResponse {
  results: SyncPushResult[];
  cursors: Array<{ projectId: string; runId: string; cursor: number }>;
}

/**
 * The Runtime's machine client for one hub project (P5). It registers
 * presence and pushes derived sync events outbound; the hub never calls the
 * Runtime back. No agent identity, no message verbs.
 */
export class RuntimeHubClient {
  readonly options: RuntimeHubClientOptions;

  constructor(options: RuntimeHubClientOptions) {
    this.options = options;
  }

  async heartbeat(): Promise<RuntimePresenceView> {
    const result = await this.request<{ presence: RuntimePresenceView }>("/v1/runtime/presence", {
      project: this.options.project,
      runtimeId: this.options.runtimeId,
      host: this.options.host ?? defaultHostLabel(),
    });
    return result.presence;
  }

  /** Push already-derived `kxm.sync-event.v1` objects, in outbox order. */
  async pushSyncEvents(events: readonly unknown[]): Promise<SyncPushResponse> {
    return await this.request<SyncPushResponse>("/v1/sync/events", {
      project: this.options.project,
      runtimeId: this.options.runtimeId,
      events,
    });
  }

  private async request<T>(path: string, body: unknown): Promise<T> {
    const headers: Record<string, string> = { "content-type": "application/json" };
    if (this.options.authToken) headers.authorization = `Bearer ${this.options.authToken}`;
    return await hubJsonRequest<T>(this.options, path, { method: "POST", body: JSON.stringify(body) }, headers);
  }
}
