import type { Item, Panel } from "./schema";
import type { Journal } from "./journal";

export type Delta =
  | { type: "item"; item: Item }
  | { type: "panel"; panel: Panel }
  | { type: "item-resolved"; id: string };

interface AdapterMeta {
  interval: number;
  lastAttempt?: number;
  lastSuccess?: number;
  lastError?: string;
  lastPollDurationMs?: number;
  consecutiveErrors: number;
}

export interface AdapterStatus {
  id: string;
  interval: number;
  lastAttempt?: number;
  lastSuccess?: number;
  lastError?: string;
  lastPollDurationMs?: number;
  consecutiveErrors: number;
  stale: boolean;
}

export class CollectorState {
  private readonly items = new Map<string, Item>();
  private readonly panels = new Map<string, Panel>();
  private readonly adapterMeta = new Map<string, AdapterMeta>();
  private readonly listeners = new Set<(delta: Delta) => void>();
  /** ids owned by each adapter and persisted reconciliation scope. */
  private readonly ownedIds = new Map<string, Map<string | undefined, Set<string>>>();
  private readonly snoozedUntil = new Map<string, number>();

  constructor(
    private readonly journal: Journal,
    private readonly now: () => number = Date.now,
  ) {
    for (const replayed of journal.load()) {
      const item = migrateGhciRunFailure(replayed);
      if (item !== replayed) journal.append(item);
      this.items.set(item.id, item);
      this.scopeIds(item.source, item.reconciliationScope).add(item.id);
    }
  }

  registerAdapter(id: string, interval: number): void {
    this.adapterMeta.set(id, { interval, consecutiveErrors: 0 });
  }

  recordAttempt(adapterId: string, atTs: number): void {
    const meta = this.adapterMeta.get(adapterId);
    if (!meta) return;
    meta.lastAttempt = atTs;
  }

  /**
   * Reconciles adapterId's item set against `items` — the adapter's complete current
   * snapshot. Ids the adapter owned after its last successful poll but did not emit
   * this time are resolved: removed from state, tombstoned in the journal, and
   * delta'd as item-resolved. Ownership is tracked per-adapter from what each poll
   * actually produced, not from item.source.
   */
  recordSuccess(
    adapterId: string,
    atTs: number,
    items: Item[],
    panels: Panel[],
    durationMs?: number,
    completeScopes: string[] = [],
  ): void {
    const meta = this.adapterMeta.get(adapterId);
    if (!meta) return;
    meta.lastAttempt = atTs;
    meta.lastSuccess = atTs;
    meta.lastError = undefined;
    meta.lastPollDurationMs = durationMs;
    meta.consecutiveErrors = 0;
    this.reconcile(adapterId, atTs, items, panels, completeScopes);
  }

  /**
   * Reconciliation for push-driven sources that have no poll loop. Same item
   * semantics as `recordSuccess` without adapter freshness bookkeeping — an
   * event-driven source that is simply idle is not stale.
   */
  publish(sourceId: string, items: Item[], panels: Panel[] = []): void {
    this.reconcile(sourceId, this.now(), items, panels, []);
  }

  private reconcile(
    adapterId: string,
    atTs: number,
    items: Item[],
    panels: Panel[],
    completeScopes: string[],
  ): void {
    const emittedByScope = new Map<string | undefined, Set<string>>();
    const allEmittedIds = new Set<string>();
    for (const item of items) {
      let emittedIds = emittedByScope.get(item.reconciliationScope);
      if (!emittedIds) {
        emittedIds = new Set<string>();
        emittedByScope.set(item.reconciliationScope, emittedIds);
      }
      emittedIds.add(item.id);
      allEmittedIds.add(item.id);
      this.items.set(item.id, item);
      this.journal.append(item);
      this.emit({ type: "item", item });
    }

    const resolvedTs = new Date(atTs).toISOString();
    const scopes = new Set<string | undefined>([undefined, ...completeScopes]);
    for (const scope of scopes) {
      const emittedIds = emittedByScope.get(scope) ?? new Set<string>();
      const previouslyOwned = this.scopeIds(adapterId, scope);
      for (const id of previouslyOwned) {
        if (emittedIds.has(id)) continue;
        if (allEmittedIds.has(id)) continue;
        this.items.delete(id);
        this.journal.resolve(id, resolvedTs);
        this.emit({ type: "item-resolved", id });
      }
      this.adapterScopes(adapterId).set(scope, emittedIds);
    }
    for (const [scope, emittedIds] of emittedByScope) {
      if (scopes.has(scope)) continue;
      const owned = this.scopeIds(adapterId, scope);
      for (const id of emittedIds) owned.add(id);
    }

    for (const panel of panels) {
      this.panels.set(panel.id, panel);
      this.emit({ type: "panel", panel });
    }
  }

  recordFailure(adapterId: string, atTs: number, error: unknown, durationMs?: number): void {
    const meta = this.adapterMeta.get(adapterId);
    if (!meta) return;
    meta.lastAttempt = atTs;
    meta.lastError = error instanceof Error ? error.message : String(error);
    meta.lastPollDurationMs = durationMs;
    meta.consecutiveErrors += 1;
  }

  isStale(adapterId: string): boolean {
    const meta = this.adapterMeta.get(adapterId);
    if (!meta) return false;
    if (meta.lastSuccess === undefined) return true;
    return this.now() - meta.lastSuccess > meta.interval * 2;
  }

  adapterStatuses(): AdapterStatus[] {
    return [...this.adapterMeta.entries()].map(([id, meta]) => ({
      id,
      interval: meta.interval,
      lastAttempt: meta.lastAttempt,
      lastSuccess: meta.lastSuccess,
      lastError: meta.lastError,
      lastPollDurationMs: meta.lastPollDurationMs,
      consecutiveErrors: meta.consecutiveErrors,
      stale: this.isStale(id),
    }));
  }

  getPanels(): Panel[] {
    return [...this.panels.values()];
  }

  getPanel(id: string): Panel | undefined {
    return this.panels.get(id);
  }

  getItems(kind?: string): Item[] {
    const now = this.now();
    const all = [...this.items.values()].filter((item) => {
      const until = this.snoozedUntil.get(item.id);
      return until === undefined || until <= now;
    });
    return kind ? all.filter((item) => item.kind === kind) : all;
  }

  /** Collector-local snooze — hides item from getItems until `untilMs`. */
  snoozeItem(itemId: string, untilMs: number): boolean {
    if (!this.items.has(itemId)) return false;
    this.snoozedUntil.set(itemId, untilMs);
    return true;
  }

  /** Appends an action-failure note to the item detail and re-journals it. */
  annotateActionError(itemId: string, note: string): void {
    const item = this.items.get(itemId);
    if (!item) return;
    const marker = " — action error: ";
    const baseDetail = item.detail.includes(marker) ? item.detail.split(marker)[0]! : item.detail;
    const updated: Item = { ...item, detail: `${baseDetail}${marker}${note}` };
    this.items.set(itemId, updated);
    this.journal.append(updated);
    this.emit({ type: "item", item: updated });
  }

  subscribe(listener: (delta: Delta) => void): () => void {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }

  private emit(delta: Delta): void {
    for (const listener of this.listeners) listener(delta);
  }

  private adapterScopes(adapterId: string): Map<string | undefined, Set<string>> {
    let scopes = this.ownedIds.get(adapterId);
    if (!scopes) {
      scopes = new Map<string | undefined, Set<string>>();
      this.ownedIds.set(adapterId, scopes);
    }
    return scopes;
  }

  private scopeIds(adapterId: string, scope: string | undefined): Set<string> {
    const scopes = this.adapterScopes(adapterId);
    let ids = scopes.get(scope);
    if (!ids) {
      ids = new Set<string>();
      scopes.set(scope, ids);
    }
    return ids;
  }
}

function migrateGhciRunFailure(item: Item): Item {
  if (item.reconciliationScope || item.source !== "ghci" || item.kind !== "ci" || !item.project) {
    return item;
  }
  const match = /^ghci:([^:]+\/[^:]+):[1-9]\d*:[1-9]\d*$/.exec(item.id);
  if (!match || match[1] !== item.project) return item;
  return { ...item, reconciliationScope: `run-failure:${item.project}` };
}
