/*
 * Priority Forge — dryrun memory-hub adapter
 *
 * Captures intended payloads to ~/.priority-forge/xmem-dryrun.jsonl
 * (append-only) so the full integration loop can be exercised end-to-end
 * without a working API. Reads/searches replay from the local log.
 *
 * The shape of every appended JSON line matches what the xmem adapter would
 * POST to /v1/memories — making it a faithful contract test before the flip
 * to live.
 */

import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
  BlockerEdge,
  ClaimInfo,
  ClaimOutcome,
  ClaimResult,
  CreateSharedTaskInput,
  MemoryHub,
  MemoryHubHealth,
  OrchestrationHub,
  ReleaseResult,
  CancelResult,
  RenewResult,
  SharedTask,
  SharedTaskFilter,
  TeamActivityFilter,
  TeamMemoryEvent,
} from '../types';
import { loadTeamHubConfig } from '../team-hub-config';
import {
  defaultSinceTs,
  isTombstoned,
  listDecayCandidates as filterDecayCandidates,
  rankMemoryResults,
} from '../salience';

const DEFAULT_DRYRUN_DIR = path.join(os.homedir(), '.priority-forge');
const DEFAULT_DRYRUN_FILE = path.join(DEFAULT_DRYRUN_DIR, 'xmem-dryrun.jsonl');

// Allow tests + ad-hoc tooling to redirect the dryrun log without touching
// the production ~/.priority-forge/xmem-dryrun.jsonl. Resolved lazily so a
// late-set env var (e.g. in a vitest beforeAll) still takes effect.
function getDryrunFile(): string {
  return process.env.MEMHUB_DRYRUN_LOG ?? DEFAULT_DRYRUN_FILE;
}
function getDryrunDir(): string {
  return path.dirname(getDryrunFile());
}

interface DryrunEntry {
  // Mirrors the xmem POST /v1/memories payload shape so we have a
  // contract-faithful capture, plus the original event for replay.
  recordedAt: string;
  wireFormat: {
    user_id: string;
    agent_id: string;
    memory: string;
    infer: false;
    messages: Array<{ role: 'system'; content: string }>;
    metadata: Record<string, unknown>;
  };
  event: TeamMemoryEvent;
}

function ensureDir(): void {
  const d = getDryrunDir();
  if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
}

function toWireFormat(event: TeamMemoryEvent): DryrunEntry['wireFormat'] {
  return {
    user_id: event.teamId,
    agent_id: event.agentId,
    memory: JSON.stringify(event.payload),
    infer: false,
    messages: [{ role: 'system', content: event.summary }],
    metadata: {
      event_type: event.eventType,
      event_id: event.eventId,
      workspace_id: event.workspaceId,
      timestamp: event.timestamp,
      ...event.metadata,
    },
  };
}

function rewriteAll(entries: DryrunEntry[]): void {
  ensureDir();
  const body = entries.map((e) => JSON.stringify(e)).join('\n');
  fs.writeFileSync(getDryrunFile(), body ? `${body}\n` : '', 'utf-8');
}

function readAll(): DryrunEntry[] {
  const f = getDryrunFile();
  if (!fs.existsSync(f)) return [];
  const raw = fs.readFileSync(f, 'utf-8');
  const lines = raw.split('\n').filter((l) => l.trim().length > 0);
  const entries: DryrunEntry[] = [];
  for (const line of lines) {
    try {
      entries.push(JSON.parse(line) as DryrunEntry);
    } catch {
      // skip malformed lines silently — dryrun must never throw on read
    }
  }
  return entries;
}

function matchesFilter(event: TeamMemoryEvent, filter: TeamActivityFilter): boolean {
  if (event.teamId !== filter.teamId) return false;
  if (filter.sinceTs && event.timestamp < filter.sinceTs) return false;
  if (filter.agentIds && filter.agentIds.length > 0 && !filter.agentIds.includes(event.agentId)) return false;
  if (filter.workspaceIds && filter.workspaceIds.length > 0 && !filter.workspaceIds.includes(event.workspaceId)) return false;
  if (filter.eventTypes && filter.eventTypes.length > 0 && !filter.eventTypes.includes(event.eventType)) return false;
  return true;
}

export class DryrunMemoryHub implements MemoryHub, OrchestrationHub {
  readonly backend = 'dryrun' as const;
  // Single-process in-memory state. Adequate for tests + single-machine
  // dev. NOT durable across restarts and NOT safe for multi-process use —
  // pgvector is the multi-process substrate.
  readonly supportsOrchestration = true;
  private sharedTasks = new Map<string, SharedTask>();

  async write(event: TeamMemoryEvent): Promise<{ accepted: boolean; remoteId?: string }> {
    ensureDir();
    const entry: DryrunEntry = {
      recordedAt: new Date().toISOString(),
      wireFormat: toWireFormat(event),
      event,
    };
    fs.appendFileSync(getDryrunFile(), JSON.stringify(entry) + '\n', 'utf-8');
    return { accepted: true, remoteId: `dryrun:${event.eventId}` };
  }

  async read(filter: TeamActivityFilter): Promise<TeamMemoryEvent[]> {
    const thresholds = loadTeamHubConfig().salience;
    const sinceTs = filter.sinceTs ?? defaultSinceTs(thresholds);
    const entries = readAll();
    let events = entries
      .map((e) => e.event)
      .filter((ev) => matchesFilter(ev, { ...filter, sinceTs }));
    if (!filter.includeTombstoned) {
      events = events.filter((ev) => !isTombstoned(ev));
    }
    const limit = filter.limit && filter.limit > 0 ? filter.limit : 200;
    return rankMemoryResults(events, thresholds).slice(0, limit);
  }

  async search(teamId: string, query: string, limit = 20): Promise<TeamMemoryEvent[]> {
    const thresholds = loadTeamHubConfig().salience;
    const events = await this.read({ teamId, limit: 500 });
    const q = query.toLowerCase();
    const tokens = q.split(/\s+/).filter(Boolean);
    const semanticScores = new Map<string, number>();
    for (const event of events) {
      const haystack = (event.summary + ' ' + JSON.stringify(event.payload)).toLowerCase();
      const score = tokens.reduce((n, t) => n + (haystack.includes(t) ? 1 : 0), 0);
      if (score > 0) semanticScores.set(event.eventId, score);
    }
    const ranked = rankMemoryResults(
      events.filter((e) => semanticScores.has(e.eventId)),
      thresholds,
      semanticScores,
    );
    return ranked.slice(0, limit);
  }

  async touchMemory(eventId: string, teamId: string): Promise<{ ok: boolean }> {
    const entries = readAll();
    let touched = false;
    for (const entry of entries) {
      if (entry.event.eventId !== eventId || entry.event.teamId !== teamId) continue;
      const count = Number(entry.event.metadata.accessCount ?? 0) + 1;
      entry.event.metadata = {
        ...entry.event.metadata,
        lastAccessedAt: new Date().toISOString(),
        accessCount: count,
      };
      touched = true;
      break;
    }
    if (touched) rewriteAll(entries);
    return { ok: touched };
  }

  async setMemoryFlags(
    eventId: string,
    teamId: string,
    flags: { pinned?: boolean; clearTombstoneCandidate?: boolean },
  ): Promise<{ ok: boolean }> {
    const entries = readAll();
    let ok = false;
    for (const entry of entries) {
      if (entry.event.eventId !== eventId || entry.event.teamId !== teamId) continue;
      if (flags.pinned !== undefined) entry.event.metadata.pinned = flags.pinned;
      if (flags.clearTombstoneCandidate) entry.event.metadata.tombstoneCandidate = false;
      ok = true;
      break;
    }
    if (ok) rewriteAll(entries);
    return { ok };
  }

  async archiveMemory(eventId: string, teamId: string): Promise<{ ok: boolean }> {
    const entries = readAll();
    let ok = false;
    for (const entry of entries) {
      if (entry.event.eventId !== eventId || entry.event.teamId !== teamId) continue;
      entry.event.metadata.tombstonedAt = new Date().toISOString();
      entry.event.metadata.tombstoneCandidate = true;
      ok = true;
      break;
    }
    if (ok) rewriteAll(entries);
    return { ok };
  }

  async listDecayCandidates(teamId: string): Promise<TeamMemoryEvent[]> {
    const events = await this.read({
      teamId,
      sinceTs: new Date(0).toISOString(),
      limit: 1000,
    });
    return filterDecayCandidates(events, loadTeamHubConfig().salience);
  }

  async blockersFor(teamId: string, agentId: string): Promise<BlockerEdge[]> {
    const blockerEvents = await this.read({ teamId, eventTypes: ['derived_blocker'] });
    const edges: BlockerEdge[] = [];
    for (const ev of blockerEvents) {
      const edge = ev.payload as Partial<BlockerEdge>;
      if (
        typeof edge.blockedTaskId === 'string' &&
        typeof edge.blockingTaskId === 'string' &&
        typeof edge.blockedAgentId === 'string' &&
        typeof edge.blockingAgentId === 'string' &&
        (edge.status === 'active' || edge.status === 'cleared') &&
        edge.blockedAgentId === agentId
      ) {
        edges.push({
          blockedTaskId: edge.blockedTaskId,
          blockedAgentId: edge.blockedAgentId,
          blockingTaskId: edge.blockingTaskId,
          blockingAgentId: edge.blockingAgentId,
          status: edge.status,
          observedAt: typeof edge.observedAt === 'string' ? edge.observedAt : ev.timestamp,
        });
      }
    }
    // Last status wins per (blockedTaskId, blockingTaskId) pair (events sorted desc).
    const latest = new Map<string, BlockerEdge>();
    for (const e of edges) {
      const key = `${e.blockedTaskId}::${e.blockingTaskId}`;
      if (!latest.has(key)) latest.set(key, e);
    }
    return [...latest.values()].filter((e) => e.status === 'active');
  }

  async health(): Promise<MemoryHubHealth> {
    ensureDir();
    return {
      ok: true,
      backend: 'dryrun',
      detail: `dryrun adapter writing to ${getDryrunFile()}`,
    };
  }

  // ── OrchestrationHub (in-process advisory) ───────────────────────────
  //
  // Pure JS Map-based CAS. Node's single-threaded event loop makes the
  // synchronous read-decide-write inside each method atomic by
  // construction (no awaits between Map reads and writes). Returned
  // values are defensive copies so callers can't mutate hub state through
  // them.

  private cloneTask(task: SharedTask): SharedTask {
    return {
      ...task,
      currentClaim: task.currentClaim ? { ...task.currentClaim } : null,
      claimHistory: task.claimHistory.map((e) => ({ ...e })),
    };
  }

  async create(input: CreateSharedTaskInput): Promise<SharedTask> {
    const id = `st_${randomUUID()}`;
    const task: SharedTask = {
      id,
      teamId: input.teamId,
      title: input.title,
      description: input.description,
      deadline: input.deadline,
      priority: input.priority,
      originatorAgentId: input.originatorAgentId,
      originatorTeamId: input.originatorTeamId,
      createdAt: new Date().toISOString(),
      currentClaim: null,
      claimHistory: [],
      status: 'claimable',
    };
    this.sharedTasks.set(id, task);
    return this.cloneTask(task);
  }

  async claim(
    sharedTaskId: string,
    agentId: string,
    agentTeamId: string,
    ttlMs: number,
  ): Promise<ClaimResult> {
    const task = this.sharedTasks.get(sharedTaskId);
    if (!task) return { ok: false, reason: 'not_found' };
    if (task.status === 'completed') return { ok: false, reason: 'completed' };
    if (task.status === 'cancelled') return { ok: false, reason: 'cancelled' };
    const now = Date.now();
    const existing = task.currentClaim;
    if (
      existing &&
      new Date(existing.leaseUntil).getTime() > now &&
      existing.holderAgentId !== agentId
    ) {
      return { ok: false, reason: 'already_claimed', currentClaim: { ...existing } };
    }
    const nowIso = new Date(now).toISOString();
    const claim: ClaimInfo = {
      holderAgentId: agentId,
      holderTeamId: agentTeamId,
      claimedAt: nowIso,
      leaseUntil: new Date(now + ttlMs).toISOString(),
    };
    task.currentClaim = claim;
    task.status = 'claimed';
    task.claimHistory.push({
      event: 'claimed',
      agentId,
      agentTeamId,
      at: nowIso,
    });
    return { ok: true, sharedTask: this.cloneTask(task), claim: { ...claim } };
  }

  async release(
    sharedTaskId: string,
    agentId: string,
    outcome: ClaimOutcome,
  ): Promise<ReleaseResult> {
    const task = this.sharedTasks.get(sharedTaskId);
    if (!task) return { ok: false, reason: 'not_found' };
    if (!task.currentClaim) return { ok: false, reason: 'not_currently_claimed' };
    if (task.currentClaim.holderAgentId !== agentId) {
      return { ok: false, reason: 'not_claim_holder' };
    }
    const holderTeamId = task.currentClaim.holderTeamId;
    task.claimHistory.push({
      event: 'released',
      agentId,
      agentTeamId: holderTeamId,
      at: new Date().toISOString(),
      outcome,
    });
    task.currentClaim = null;
    task.status =
      outcome === 'completed' ? 'completed' : outcome === 'cancelled' ? 'cancelled' : 'claimable';
    return { ok: true, sharedTask: this.cloneTask(task) };
  }

  async cancel(sharedTaskId: string, byAgentId: string): Promise<CancelResult> {
    const task = this.sharedTasks.get(sharedTaskId);
    if (!task) return { ok: false, reason: 'not_found' };
    if (task.originatorAgentId !== byAgentId) return { ok: false, reason: 'unauthorized_originator' };
    if (task.currentClaim) return { ok: false, reason: 'currently_claimed' };
    if (task.status !== 'claimable') return { ok: false, reason: 'already_terminal' };
    task.status = 'cancelled';
    return { ok: true, sharedTask: this.cloneTask(task) };
  }

  async renewLease(
    sharedTaskId: string,
    agentId: string,
    ttlMs: number,
  ): Promise<RenewResult> {
    const task = this.sharedTasks.get(sharedTaskId);
    if (!task) return { ok: false, reason: 'not_found' };
    if (!task.currentClaim || task.currentClaim.holderAgentId !== agentId) {
      return { ok: false, reason: 'not_claim_holder' };
    }
    const now = Date.now();
    if (new Date(task.currentClaim.leaseUntil).getTime() < now) {
      return { ok: false, reason: 'expired' };
    }
    task.currentClaim.leaseUntil = new Date(now + ttlMs).toISOString();
    task.claimHistory.push({
      event: 'renewed',
      agentId,
      agentTeamId: task.currentClaim.holderTeamId,
      at: new Date(now).toISOString(),
    });
    return { ok: true, claim: { ...task.currentClaim } };
  }

  async get(sharedTaskId: string): Promise<SharedTask | null> {
    const task = this.sharedTasks.get(sharedTaskId);
    return task ? this.cloneTask(task) : null;
  }

  async list(filter: SharedTaskFilter): Promise<SharedTask[]> {
    const out: SharedTask[] = [];
    for (const task of this.sharedTasks.values()) {
      if (task.teamId !== filter.teamId) continue;
      if (filter.status && task.status !== filter.status) continue;
      out.push(this.cloneTask(task));
    }
    out.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
    return filter.limit && filter.limit > 0 ? out.slice(0, filter.limit) : out;
  }

  async getMyClaims(teamId: string, agentId: string): Promise<SharedTask[]> {
    const now = Date.now();
    const out: SharedTask[] = [];
    for (const task of this.sharedTasks.values()) {
      if (task.teamId !== teamId) continue;
      if (!task.currentClaim || task.currentClaim.holderAgentId !== agentId) continue;
      if (new Date(task.currentClaim.leaseUntil).getTime() < now) continue;
      out.push(this.cloneTask(task));
    }
    return out;
  }

  async searchSharedTasks(teamId: string, query: string, limit = 20): Promise<SharedTask[]> {
    const q = query.toLowerCase();
    const out: SharedTask[] = [];
    for (const task of this.sharedTasks.values()) {
      if (task.teamId !== teamId) continue;
      const hay = `${task.title} ${task.description ?? ''}`.toLowerCase();
      if (hay.includes(q)) out.push(this.cloneTask(task));
    }
    out.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
    return limit && limit > 0 ? out.slice(0, limit) : out;
  }
}

/**
 * Path the dryrun adapter would write to with the current env. Exported for
 * tooling that wants to surface the path (e.g. `xmem_get_status`). Resolved
 * lazily so a late env-var change is reflected.
 */
export const DRYRUN_LOG_PATH = DEFAULT_DRYRUN_FILE;
