/*
 * Priority Forge — cross-forge SharedTask bridge
 *
 * Bridges PF's local storage with priority-forge-hub's OrchestrationHub
 * plane. Every shared-task lifecycle op (publish / claim / release / renew)
 * goes through here so the host-side projection logic stays in one place.
 *
 * The hub package never touches PF storage. PF imports it and wires up
 * what the projection needs:
 *
 *   claim   →   hub.claim() (atomic CAS)
 *               → on ok=true, materialize a local Task with sharedTaskId
 *               → claimTask() it to capture startedAt + selection event
 *
 *   release →   hub.release() (transition + audit)
 *               → archive the linked local Task via completeTask()
 *                 (status → complete/deferred, completedAt captured,
 *                  ML signals preserved with sharedTaskId still set)
 *
 *   publish →   hub.create() then immediate hub.claim() (originator
 *               holds initial lease)
 *               → set sharedTaskId on the existing local Task
 *
 * Auto-release: when handler.ts's complete_task path detects a task with
 * sharedTaskId set, it calls autoReleaseOnLocalComplete() to fire the
 * hub-side release without requiring an explicit shared_task_release call.
 */

import { v4 as uuidv4 } from 'uuid';
import {
  getCurrentIntegration,
  getMemoryHub,
  loadMemoryHubConfig,
  type ClaimInfo,
  type ClaimOutcome,
  type RenewResult,
  type CancelResult,
  type SharedTask,
  type SharedTaskStatus,
  type TeamMemoryEvent,
  type TeamMemoryEventType,
} from 'priority-forge-hub';
import { storage } from '../storage/jsonStorage';
import { syncEmitter } from '../sync';
import type { Effort, Priority, WeightedTask } from '../types/schema';

// 30-minute default lease. Heartbeat will renew at half-TTL.
export const DEFAULT_TTL_MS = 30 * 60 * 1000;

// Default project bucket for newly-materialized projections. Users can
// override per-claim. Kept distinct from any other project so projections
// are easy to find / filter.
const DEFAULT_PROJECT = 'shared-work';
const DEFAULT_PRIORITY: Priority = 'P2';
const DEFAULT_EFFORT: Effort = 'medium';

type PFLocalOutcome = 'completed' | 'deferred' | 'cancelled';

/** Map a PF-local complete_task outcome onto a hub ClaimOutcome. */
export function mapLocalOutcomeToHub(outcome: PFLocalOutcome): ClaimOutcome {
  // PF doesn't have a 'handed_back' outcome — 'deferred' is the closest
  // semantic match (released without completion).
  return outcome === 'completed'
    ? 'completed'
    : outcome === 'cancelled'
      ? 'cancelled'
      : 'deferred';
}

/** Map a hub ClaimOutcome to a PF-local complete_task outcome. */
function mapHubOutcomeToLocal(outcome: ClaimOutcome): PFLocalOutcome {
  if (outcome === 'completed') return 'completed';
  if (outcome === 'cancelled') return 'cancelled';
  // 'deferred' and 'handed_back' both map to PF deferred.
  return 'deferred';
}

function requireIdentity(): { teamId: string; agentId: string } {
  const cfg = loadMemoryHubConfig();
  if (!cfg.teamId || !cfg.agentId) {
    throw new Error(
      'memory-hub identity not configured — run xmem_enable_sync({backend, teamId, agentId, ...}) first',
    );
  }
  return { teamId: cfg.teamId, agentId: cfg.agentId };
}

// ──────────────────────────────────────────────────────────────────────
// Observation emission — make orchestration mutations visible on the
// memory plane.
//
// The bridge bypasses syncEmitter when it calls storage directly, so the
// existing memory-hub subscriber doesn't see those mutations. To keep
// the activity feed + xmem_get_current_focus accurate, the bridge
// explicitly emits two flavors of observation after each successful
// orchestration step:
//
//   (1) shared_task_* TeamMemoryEvents — written directly via
//       MemoryHub.write(). These are orchestration-plane lifecycle
//       events with no syncEmitter equivalent.
//
//   (2) syncEmitter.emitTask* — for the local Task projection's
//       create/select/complete transitions. The existing subscriber
//       picks these up and propagates them as task_* TeamMemoryEvents.
//
// Both paths are fire-and-forget. Failures log + swallow so the
// orchestration mutation that succeeded isn't undone by an observation
// emission hiccup.
// ──────────────────────────────────────────────────────────────────────

async function emitSharedTaskObservation(
  eventType: TeamMemoryEventType,
  payload: Record<string, unknown>,
  summary: string,
): Promise<void> {
  try {
    const cfg = loadMemoryHubConfig();
    if (!cfg.enabled || !cfg.teamId || !cfg.agentId) return;
    const workspaceId = (await storage.getCurrentWorkspaceId()) || 'default';
    const event: TeamMemoryEvent = {
      eventId: uuidv4(),
      teamId: cfg.teamId,
      agentId: cfg.agentId,
      workspaceId,
      eventType,
      timestamp: new Date().toISOString(),
      payload,
      summary,
      metadata: {},
    };
    const hub = getMemoryHub();
    await hub.write(event);
  } catch (err) {
    console.warn(
      '[shared-tasks] failed to emit observation:',
      err instanceof Error ? err.message : err,
    );
  }
}

function summarizeShared(task: SharedTask, agentId: string, action: string): string {
  return `${agentId} ${action} shared task "${task.title}" (${task.id})`;
}

function requireOrchestrationHub() {
  const integration = getCurrentIntegration();
  if (!integration) {
    throw new Error('memory-hub integration not active — restart the backend');
  }
  const orch = integration.orchestrationHub;
  if (!orch.supportsOrchestration) {
    throw new Error(
      'active backend does not support orchestration — switch to pgvector or dryrun via xmem_enable_sync',
    );
  }
  return orch;
}

// ──────────────────────────────────────────────────────────────────────
// Lifecycle ops
// ──────────────────────────────────────────────────────────────────────

export async function createSharedTask(input: {
  title: string;
  description?: string;
  deadline?: string;
  priority?: string;
}): Promise<{ sharedTask: SharedTask }> {
  const { teamId, agentId } = requireIdentity();
  const orch = requireOrchestrationHub();
  const sharedTask = await orch.create({
    teamId,
    title: input.title,
    description: input.description,
    deadline: input.deadline,
    priority: input.priority,
    originatorAgentId: agentId,
    originatorTeamId: teamId,
  });
  await emitSharedTaskObservation(
    'shared_task_published',
    {
      sharedTaskId: sharedTask.id,
      title: sharedTask.title,
      description: sharedTask.description,
      originatorAgentId: sharedTask.originatorAgentId,
      originatorTeamId: sharedTask.originatorTeamId,
      status: sharedTask.status,
    },
    summarizeShared(sharedTask, agentId, 'published'),
  );
  return { sharedTask };
}

export interface PublishResult {
  sharedTask: SharedTask;
  claim: ClaimInfo;
  localTask: WeightedTask;
}

/**
 * Promote an existing local Task to a SharedTask. The publisher's forge
 * holds the initial lease, so the local Task stays in_progress and the
 * sharedTaskId field gets set.
 */
export async function publishSharedTask(
  localTaskId: string,
  opts?: { ttlMs?: number },
): Promise<PublishResult> {
  const { teamId, agentId } = requireIdentity();
  const orch = requireOrchestrationHub();
  const localTask = await storage.getTask(localTaskId);
  if (!localTask) {
    throw new Error(`local task ${localTaskId} not found`);
  }
  if (localTask.sharedTaskId) {
    throw new Error(
      `local task ${localTaskId} is already linked to shared task ${localTask.sharedTaskId}`,
    );
  }
  const sharedTask = await orch.create({
    teamId,
    title: localTask.task,
    description: localTask.notes,
    deadline: localTask.deadline,
    priority: localTask.priority,
    originatorAgentId: agentId,
    originatorTeamId: teamId,
  });
  const claimResult = await orch.claim(
    sharedTask.id,
    agentId,
    teamId,
    opts?.ttlMs ?? DEFAULT_TTL_MS,
  );
  if (!claimResult.ok) {
    throw new Error(`failed to claim freshly-published shared task: ${claimResult.reason}`);
  }
  const updated = await storage.linkSharedTask(localTaskId, sharedTask.id);
  if (!updated) {
    throw new Error(`failed to link local task ${localTaskId} to shared task ${sharedTask.id}`);
  }
  // Memory-plane observations: publish + claim, plus local task_updated
  // for the sharedTaskId stamping (so other forges see the link land).
  await emitSharedTaskObservation(
    'shared_task_published',
    {
      sharedTaskId: sharedTask.id,
      title: sharedTask.title,
      description: sharedTask.description,
      originatorAgentId: sharedTask.originatorAgentId,
      originatorTeamId: sharedTask.originatorTeamId,
      promotedFromLocalTaskId: localTaskId,
      status: sharedTask.status,
    },
    summarizeShared(sharedTask, agentId, 'published (promoted local task)'),
  );
  await emitSharedTaskObservation(
    'shared_task_claimed',
    {
      sharedTaskId: claimResult.sharedTask.id,
      claim: claimResult.claim,
      promotedFromLocalTaskId: localTaskId,
    },
    summarizeShared(claimResult.sharedTask, agentId, 'claimed (publisher self-claim)'),
  );
  syncEmitter.emitTaskUpdated(
    localTaskId,
    { sharedTaskId: sharedTask.id },
    updated as unknown as Record<string, unknown>,
  );
  return { sharedTask: claimResult.sharedTask, claim: claimResult.claim, localTask: updated };
}

export interface ClaimResultLocal {
  ok: true;
  sharedTask: SharedTask;
  claim: ClaimInfo;
  localTask: WeightedTask;
}

export type ClaimSharedTaskResult =
  | ClaimResultLocal
  | { ok: false; reason: string; currentClaim?: ClaimInfo };

/**
 * Atomically claim a SharedTask and materialize a local Task projection.
 * The local Task is marked in_progress with claimedBy + startedAt so the
 * PF ranker + ML pipeline treat it like any other in-flight work.
 */
export async function claimSharedTask(
  sharedTaskId: string,
  opts?: {
    ttlMs?: number;
    priority?: Priority;
    effort?: Effort;
    project?: string;
  },
): Promise<ClaimSharedTaskResult> {
  const { teamId, agentId } = requireIdentity();
  const orch = requireOrchestrationHub();
  const ttl = opts?.ttlMs ?? DEFAULT_TTL_MS;
  const result = await orch.claim(sharedTaskId, agentId, teamId, ttl);
  if (!result.ok) {
    if ('currentClaim' in result) {
      return { ok: false, reason: result.reason, currentClaim: result.currentClaim };
    }
    return { ok: false, reason: result.reason };
  }
  // Materialize local Task projection. Use the existing createTask so we
  // get a fresh local ID, proper version, and full integration with the
  // workspace mutex / heap / scoring pipeline.
  const localTask = await storage.createTask({
    priority:
      opts?.priority ??
      (result.sharedTask.priority as Priority | undefined) ??
      DEFAULT_PRIORITY,
    task: result.sharedTask.title,
    project: opts?.project ?? DEFAULT_PROJECT,
    effort: opts?.effort ?? DEFAULT_EFFORT,
    notes: result.sharedTask.description,
    deadline: result.sharedTask.deadline,
    sharedTaskId: result.sharedTask.id,
  });
  // Move to in_progress via claimTask — this captures startedAt and fires
  // a TaskSelectionEvent (auto-creates skip decisions for higher-ranked
  // tasks, the V4.2 safety net).
  await storage.claimTask(localTask.id, agentId);
  const final = (await storage.getTask(localTask.id)) ?? localTask;
  // Observations: shared_task_claimed (orchestration plane) +
  // task_created / task_selected (memory plane via syncEmitter so the
  // existing subscriber pipeline carries them). Without these, the
  // activity feed wouldn't see the projection materialize.
  await emitSharedTaskObservation(
    'shared_task_claimed',
    {
      sharedTaskId: result.sharedTask.id,
      claim: result.claim,
      localTaskId: final.id,
      projectionPriority: final.priority,
      projectionProject: final.project,
    },
    summarizeShared(result.sharedTask, agentId, 'claimed'),
  );
  syncEmitter.emitTaskCreated(final as unknown as Record<string, unknown>);
  syncEmitter.emitTaskSelected(final as unknown as Record<string, unknown>);
  return { ok: true, sharedTask: result.sharedTask, claim: result.claim, localTask: final };
}

export interface ReleaseSharedTaskResult {
  ok: boolean;
  reason?: string;
  sharedTask?: SharedTask;
  archivedLocalTask: WeightedTask | null;
}

/**
 * Release the lease on a SharedTask AND archive the local projection.
 * Outcome maps PF-side (completed/deferred/cancelled/handed_back).
 *
 * The local archival happens regardless of whether the hub release
 * succeeded — work that's done is done. The hub failure is surfaced via
 * `ok: false` so the caller can decide whether to retry / log.
 */
export async function releaseSharedTask(
  sharedTaskId: string,
  outcome: ClaimOutcome,
): Promise<ReleaseSharedTaskResult> {
  const { agentId } = requireIdentity();
  const orch = requireOrchestrationHub();
  const hubResult = await orch.release(sharedTaskId, agentId, outcome);
  // Always try to archive the local projection. If we lost the lease, we
  // still want to close out the local row so the user's queue isn't
  // polluted by a stale in-progress task.
  let archivedLocalTask: WeightedTask | null = null;
  const localTask = await storage.getTaskBySharedTaskId(sharedTaskId);
  if (localTask && localTask.status !== 'complete') {
    const pfOutcome = mapHubOutcomeToLocal(outcome);
    await storage.completeTask(localTask.id, pfOutcome);
    archivedLocalTask = await storage.getTask(localTask.id);
    // Fire task_completed on syncEmitter so the memory plane observes
    // the local archival (storage.completeTask doesn't fire syncEmitter
    // — that's a handler.ts-layer concern that the bridge bypasses).
    syncEmitter.emitTaskCompleted(
      (archivedLocalTask ?? localTask) as unknown as Record<string, unknown>,
      pfOutcome,
    );
  } else if (localTask) {
    archivedLocalTask = localTask;
  }
  if (hubResult.ok && hubResult.sharedTask) {
    await emitSharedTaskObservation(
      outcome === 'completed' ? 'shared_task_completed' : 'shared_task_released',
      {
        sharedTaskId,
        outcome,
        finalStatus: hubResult.sharedTask.status,
        archivedLocalTaskId: archivedLocalTask?.id,
      },
      summarizeShared(hubResult.sharedTask, agentId, `released (outcome=${outcome})`),
    );
  }
  return {
    ok: hubResult.ok,
    reason: hubResult.reason,
    sharedTask: hubResult.sharedTask,
    archivedLocalTask,
  };
}

/**
 * Retire a claimable (unclaimed) SharedTask you originated — e.g. to clean up a
 * duplicate. Originator-only; the hub rejects cancelling a currently-claimed
 * task (its holder must release instead). No local projection is involved.
 */
export async function cancelSharedTask(sharedTaskId: string): Promise<CancelResult> {
  const { agentId } = requireIdentity();
  const orch = requireOrchestrationHub();
  const result = await orch.cancel(sharedTaskId, agentId);
  if (result.ok && result.sharedTask) {
    await emitSharedTaskObservation(
      'shared_task_cancelled',
      {
        sharedTaskId,
        status: result.sharedTask.status,
        originatorAgentId: result.sharedTask.originatorAgentId,
      },
      summarizeShared(result.sharedTask, agentId, 'cancelled'),
    );
  }
  return result;
}

/**
 * Called from handler.ts's complete_task path when the just-completed
 * local Task has a sharedTaskId. Fires the hub-side release so users
 * don't have to remember to call shared_task_release separately.
 *
 * Failures here (e.g. lost lease) are LOGGED but NOT propagated — the
 * local completion already happened and is more important than the
 * hub-side sync. The user can recover via shared_task_release if needed.
 */
export async function autoReleaseOnLocalComplete(
  sharedTaskId: string,
  pfOutcome: PFLocalOutcome,
): Promise<void> {
  try {
    const { agentId } = requireIdentity();
    const orch = requireOrchestrationHub();
    const hubOutcome = mapLocalOutcomeToHub(pfOutcome);
    const result = await orch.release(sharedTaskId, agentId, hubOutcome);
    if (!result.ok) {
      console.warn(
        `[shared-tasks] auto-release of ${sharedTaskId} returned ok=false (reason=${result.reason}). Local completion stands; call shared_task_release manually if needed.`,
      );
      return;
    }
    if (result.sharedTask) {
      await emitSharedTaskObservation(
        hubOutcome === 'completed' ? 'shared_task_completed' : 'shared_task_released',
        {
          sharedTaskId,
          outcome: hubOutcome,
          finalStatus: result.sharedTask.status,
          autoReleased: true,
        },
        summarizeShared(result.sharedTask, agentId, `auto-released (outcome=${hubOutcome})`),
      );
    }
  } catch (err) {
    console.warn(
      `[shared-tasks] auto-release of ${sharedTaskId} threw:`,
      err instanceof Error ? err.message : err,
    );
  }
}

export async function renewSharedTaskLease(
  sharedTaskId: string,
  opts?: { ttlMs?: number },
): Promise<RenewResult> {
  const { agentId } = requireIdentity();
  const orch = requireOrchestrationHub();
  const result = await orch.renewLease(sharedTaskId, agentId, opts?.ttlMs ?? DEFAULT_TTL_MS);
  if (result.ok && result.claim) {
    await emitSharedTaskObservation(
      'shared_task_lease_renewed',
      {
        sharedTaskId,
        claim: result.claim,
        renewedAt: result.claim.claimedAt,
        leaseUntil: result.claim.leaseUntil,
      },
      `${agentId} renewed lease on ${sharedTaskId} (leaseUntil=${result.claim.leaseUntil})`,
    );
  }
  return result;
}

// ──────────────────────────────────────────────────────────────────────
// Read ops
// ──────────────────────────────────────────────────────────────────────

export async function getSharedTask(sharedTaskId: string): Promise<SharedTask | null> {
  const orch = requireOrchestrationHub();
  return orch.get(sharedTaskId);
}

export async function listSharedTasks(opts?: {
  status?: SharedTaskStatus;
  limit?: number;
}): Promise<SharedTask[]> {
  const { teamId } = requireIdentity();
  const orch = requireOrchestrationHub();
  return orch.list({ teamId, status: opts?.status, limit: opts?.limit });
}

export async function getMySharedTaskClaims(): Promise<SharedTask[]> {
  const { teamId, agentId } = requireIdentity();
  const orch = requireOrchestrationHub();
  return orch.getMyClaims(teamId, agentId);
}

/**
 * Text search over the team's live SharedTask pool (title + description) — for
 * spotting overlap / duplicates across teammates. Read-only.
 */
export async function searchSharedTasks(query: string, limit?: number): Promise<SharedTask[]> {
  const { teamId } = requireIdentity();
  const orch = requireOrchestrationHub();
  return orch.searchSharedTasks(teamId, query, limit);
}

// ──────────────────────────────────────────────────────────────────────
// Priority directives (in-place, agent-mediated lead reprioritization)
//
// A lead issues an advisory priority for a SharedTask. The owning forge
// surfaces pending directives (get_priority_directives) and applies them in
// place to the linked local projection (apply_priority_directives) — a normal
// updateTask priority change, NOT a destroy/recreate. No hub auto-apply, no
// id churn. Idempotent in practice: once applied, the directive shows as
// `satisfied` and is skipped.
// ──────────────────────────────────────────────────────────────────────

export interface PriorityDirective {
  eventId: string;
  sharedTaskId: string;
  suggestedPriority: Priority;
  byAgentId: string;
  note?: string;
  at: string;
  localTaskId: string;
  currentPriority: Priority;
  satisfied: boolean;
}

interface LocalTaskRef {
  id: string;
  priority: Priority;
  status: string;
}

/**
 * Pure core: from a stream of memory events + a sharedTaskId→local-task map,
 * resolve the directives that target tasks THIS forge owns. Keeps only the
 * latest directive per SharedTask, drops directives for unknown/completed
 * local tasks, and flags whether each is already `satisfied`.
 */
export function resolveDirectives(
  events: TeamMemoryEvent[],
  localByShared: Map<string, LocalTaskRef>,
): PriorityDirective[] {
  const latest = new Map<string, TeamMemoryEvent>();
  for (const ev of events) {
    if (ev.eventType !== 'priority_directive') continue;
    const stid = ev.payload?.sharedTaskId as string | undefined;
    const pri = ev.payload?.priority as Priority | undefined;
    if (!stid || !pri) continue;
    const prev = latest.get(stid);
    if (!prev || ev.timestamp > prev.timestamp) latest.set(stid, ev);
  }
  const out: PriorityDirective[] = [];
  for (const ev of latest.values()) {
    const stid = ev.payload.sharedTaskId as string;
    const local = localByShared.get(stid);
    if (!local || local.status === 'complete') continue;
    const suggested = ev.payload.priority as Priority;
    out.push({
      eventId: ev.eventId,
      sharedTaskId: stid,
      suggestedPriority: suggested,
      byAgentId: ev.agentId,
      note: ev.payload.note as string | undefined,
      at: ev.timestamp,
      localTaskId: local.id,
      currentPriority: local.priority,
      satisfied: local.priority === suggested,
    });
  }
  return out;
}

/** Lead side: emit an advisory priority directive for a SharedTask. */
export async function issuePriorityDirective(input: {
  sharedTaskId: string;
  priority: Priority;
  note?: string;
}): Promise<{ ok: true; sharedTaskId: string; priority: Priority }> {
  const { agentId } = requireIdentity();
  await emitSharedTaskObservation(
    'priority_directive',
    { sharedTaskId: input.sharedTaskId, priority: input.priority, note: input.note },
    `${agentId} set advisory priority ${input.priority} on shared task ${input.sharedTaskId}`,
  );
  return { ok: true, sharedTaskId: input.sharedTaskId, priority: input.priority };
}

/** Owner side: list pending directives targeting this forge's local tasks. */
export async function getPriorityDirectives(): Promise<PriorityDirective[]> {
  const { teamId } = requireIdentity();
  const hub = getMemoryHub();
  const events = await hub.read({ teamId, eventTypes: ['priority_directive'], limit: 200 });
  const ids = new Set<string>();
  for (const ev of events) {
    const s = ev.payload?.sharedTaskId as string | undefined;
    if (s) ids.add(s);
  }
  const localByShared = new Map<string, LocalTaskRef>();
  for (const sid of ids) {
    const t = await storage.getTaskBySharedTaskId(sid);
    if (t) localByShared.set(sid, { id: t.id, priority: t.priority, status: t.status });
  }
  return resolveDirectives(events, localByShared);
}

/**
 * Owner side: apply all unsatisfied directives in place via updateTask (which
 * logs a normal priority change — useful override signal, no id churn).
 */
export async function applyPriorityDirectives(): Promise<{
  applied: Array<{ localTaskId: string; sharedTaskId: string; from: Priority; to: Priority }>;
  skipped: number;
}> {
  const directives = await getPriorityDirectives();
  const applied: Array<{ localTaskId: string; sharedTaskId: string; from: Priority; to: Priority }> = [];
  for (const d of directives) {
    if (d.satisfied) continue;
    await storage.updateTask(d.localTaskId, { priority: d.suggestedPriority });
    applied.push({
      localTaskId: d.localTaskId,
      sharedTaskId: d.sharedTaskId,
      from: d.currentPriority,
      to: d.suggestedPriority,
    });
  }
  return { applied, skipped: directives.length - applied.length };
}

export interface SharedTaskTimelineEntry {
  /** ISO timestamp of the transition. */
  at: string;
  /** Relative time vs "now" — "5m ago", "2h ago", "yesterday", "in 3m" (future). */
  relative: string;
  /** Concise human-readable description of the transition. */
  description: string;
  /** Underlying claim history entry's event type. */
  event: SharedTask['claimHistory'][number]['event'];
  /** Underlying agent + outcome (raw, unformatted). */
  agentId: string;
  agentTeamId: string;
  outcome?: SharedTask['claimHistory'][number]['outcome'];
}

export interface SharedTaskTimeline {
  sharedTaskId: string;
  title: string;
  status: SharedTaskStatus;
  originatorAgentId: string;
  createdAt: string;
  currentHolder?: string;
  /** Chronological (oldest → newest). */
  timeline: SharedTaskTimelineEntry[];
  /** Raw claimHistory for callers that want the unformatted data too. */
  rawClaimHistory: SharedTask['claimHistory'];
}

/**
 * Format a duration in milliseconds as a short human-readable string.
 * Negative durations are rendered as "in X" (future). Granularity drops
 * adaptively: seconds → minutes → hours → days → weeks.
 */
function formatRelative(deltaMs: number): string {
  const future = deltaMs < 0;
  const abs = Math.abs(deltaMs);
  const sec = Math.round(abs / 1000);
  const min = Math.round(sec / 60);
  const hour = Math.round(min / 60);
  const day = Math.round(hour / 24);
  const week = Math.round(day / 7);
  let s: string;
  if (sec < 60) s = `${sec}s`;
  else if (min < 60) s = `${min}m`;
  else if (hour < 48) s = `${hour}h`;
  else if (day < 14) s = `${day}d`;
  else s = `${week}w`;
  return future ? `in ${s}` : `${s} ago`;
}

function describeTimelineEntry(
  entry: SharedTask['claimHistory'][number],
  originator: string,
  sharedTaskTitle: string,
): string {
  const who = entry.agentId === originator ? `${entry.agentId} (originator)` : entry.agentId;
  switch (entry.event) {
    case 'claimed':
      return `${who} claimed "${sharedTaskTitle}"`;
    case 'renewed':
      return `${who} renewed lease`;
    case 'released':
      return `${who} released (outcome: ${entry.outcome ?? 'unspecified'})`;
    case 'expired':
      return `lease expired (last holder: ${who})`;
    default:
      return `${who} ${entry.event}`;
  }
}

/**
 * Returns a formatted timeline view of a SharedTask's lifecycle. Use
 * this when you want a chronological narrative ("who did what when")
 * with relative timestamps for quick human consumption. Use
 * `getSharedTaskHistory` if you just want the raw claimHistory array.
 */
export async function getSharedTaskTimeline(
  sharedTaskId: string,
  opts?: { now?: Date },
): Promise<SharedTaskTimeline | null> {
  const task = await getSharedTask(sharedTaskId);
  if (!task) return null;
  const now = opts?.now ?? new Date();
  const entries: SharedTaskTimelineEntry[] = task.claimHistory.map((h) => ({
    at: h.at,
    relative: formatRelative(now.getTime() - new Date(h.at).getTime()),
    description: describeTimelineEntry(h, task.originatorAgentId, task.title),
    event: h.event,
    agentId: h.agentId,
    agentTeamId: h.agentTeamId,
    outcome: h.outcome,
  }));
  return {
    sharedTaskId: task.id,
    title: task.title,
    status: task.status,
    originatorAgentId: task.originatorAgentId,
    createdAt: task.createdAt,
    currentHolder: task.currentClaim?.holderAgentId,
    timeline: entries,
    rawClaimHistory: task.claimHistory,
  };
}

export async function getSharedTaskHistory(
  sharedTaskId: string,
): Promise<SharedTask['claimHistory']> {
  const task = await getSharedTask(sharedTaskId);
  return task?.claimHistory ?? [];
}

// ──────────────────────────────────────────────────────────────────────
// Heartbeat — automatic lease renewal for in-progress projections
// ──────────────────────────────────────────────────────────────────────
//
// Runs every `intervalMs` (default 15 min = half the 30-min default TTL)
// while the backend is up. For every local Task with sharedTaskId AND
// status='in_progress' AND claimedBy=this-agent, renew the lease. If
// renewLease returns not_claim_holder or expired, the lease was lost
// (we slept past TTL or someone admin'd around us) — mark the local
// task as 'blocked' with a note so the user sees the conflict.
//
// Lives in PF rather than the hub because deciding "which leases need
// renewal" is host policy. The hub provides renewLease; PF picks targets.

let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
const DEFAULT_HEARTBEAT_INTERVAL_MS = 15 * 60 * 1000;

export function startSharedTaskHeartbeat(intervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS): void {
  if (heartbeatTimer) return;
  heartbeatTimer = setInterval(() => {
    void runHeartbeatTick().catch((err) => {
      console.warn(
        '[shared-tasks:heartbeat] tick threw:',
        err instanceof Error ? err.message : err,
      );
    });
  }, intervalMs);
  console.log(`[shared-tasks:heartbeat] started (every ${intervalMs}ms)`);
}

export function stopSharedTaskHeartbeat(): void {
  if (heartbeatTimer) {
    clearInterval(heartbeatTimer);
    heartbeatTimer = null;
    console.log('[shared-tasks:heartbeat] stopped');
  }
}

async function runHeartbeatTick(): Promise<void> {
  const cfg = loadMemoryHubConfig();
  if (!cfg.enabled || !cfg.teamId || !cfg.agentId) return;
  const integration = getCurrentIntegration();
  if (!integration) return;
  const orch = integration.orchestrationHub;
  if (!orch.supportsOrchestration) return;
  const agentId = cfg.agentId;

  const tasks = await storage.getTasks();
  const renewTargets = tasks.filter(
    (t) => !!t.sharedTaskId && t.status === 'in_progress' && t.claimedBy === agentId,
  );
  if (renewTargets.length === 0) return;

  for (const task of renewTargets) {
    if (!task.sharedTaskId) continue;
    const result = await orch.renewLease(task.sharedTaskId, agentId, DEFAULT_TTL_MS);
    if (result.ok) {
      if (result.claim) {
        await emitSharedTaskObservation(
          'shared_task_lease_renewed',
          {
            sharedTaskId: task.sharedTaskId,
            claim: result.claim,
            renewedAt: result.claim.claimedAt,
            leaseUntil: result.claim.leaseUntil,
            heartbeatTriggered: true,
            localTaskId: task.id,
          },
          `${agentId} heartbeat-renewed lease on ${task.sharedTaskId}`,
        );
      }
      continue;
    }
    if (result.reason === 'not_claim_holder' || result.reason === 'expired') {
      const now = new Date().toISOString();
      const noteAddition = `\n[memory-hub] lost cross-forge lease on ${task.sharedTaskId} at ${now} (reason=${result.reason})`;
      console.warn(
        `[shared-tasks:heartbeat] lost lease on ${task.sharedTaskId} (local task ${task.id}); marking blocked`,
      );
      await storage.updateTask(task.id, {
        status: 'blocked',
        notes: (task.notes ?? '') + noteAddition,
      });
    } else {
      // Transient failure (network, db hiccup) — just log and retry next tick.
      console.warn(
        `[shared-tasks:heartbeat] renew ${task.sharedTaskId} failed (reason=${result.reason ?? 'unknown'}); will retry`,
      );
    }
  }
}
