import { LayerCancelledError } from "./errors";
import type { LayerCancelReason } from "./errors";
import { Layer } from "./layer";
import { createLayerGcCache } from "./layerGcCache";
import { notifyManager } from "./notifyManager";
import { Subscribable } from "./subscribable";
import type {
  DefaultLayerError,
  DismissAllOptions,
  DismissOptions,
  LayerKey,
  LayerNotifyView,
  LayerState,
  StackBlockerFn,
  StackNotifyAction,
  StackNotifyEvent,
  StackOptions,
} from "./types";
import { keySignature } from "./utils";
import { validatePayload } from "./validators";
import type { Validator } from "./validators";

interface OpenOpts<P, D> {
  key: LayerKey;
  payload: P;
  component?: unknown;
  enteringDelay?: number;
  exitingDelay?: number;
  upsert?: boolean;
  loadFn?: (ctx: { payload: P; signal: AbortSignal }) => Promise<D> | D;
  validate?: Validator<P>;
}

interface QueuedEntry<P, R, E, D> {
  layer: Layer<P, R, E, D>;
  commit: () => void;
}

/**
 * Manages the ordered layers for one surface.
 * Snapshots retain their references across batched notifications until their contents change.
 */
export class LayerStack<
  P = unknown,
  R = void,
  E = DefaultLayerError,
  D = unknown,
> extends Subscribable {
  readonly id: string;
  readonly options: StackOptions;

  /** @internal Allows LayerClient to `cancelAll` child stacks on parent dismissal. */
  onLayerDismiss?: (layer: Layer<P, R, E, D>) => void;

  /** @internal Fan-out hook for {@link LayerClient#subscribeNotify}. */
  onNotify?: (event: StackNotifyEvent) => void;

  #layers: Layer<P, R, E, D>[] = [];
  #snapshot: LayerState<P, R, E, D>[] = [];
  #queuedSnapshot: LayerState<P, R, E, D>[] = [];
  #scopeQueue: QueuedEntry<P, R, E, D>[] = [];
  #notifySeq = 0;
  #pendingNotifyAction?: StackNotifyAction;
  #gcCache = createLayerGcCache<P, R, E, D>({
    gcTime: () => this.options.gcTime ?? 0,
    onBeforeStore: (layer) =>
      layer.setPartial({ phase: "dismissed", transition: "settled" }),
  });
  #blockers = new Set<StackBlockerFn>();

  constructor(id: string, options: StackOptions = {}) {
    super();
    this.id = id;
    this.options = options;
  }

  /** Returns a referentially stable snapshot between mutations. */
  getSnapshot = (): LayerState<P, R, E, D>[] => this.#snapshot;

  /** Returns serially queued layers, which are excluded from `getSnapshot`. */
  getQueuedSnapshot = (): LayerState<P, R, E, D>[] => this.#queuedSnapshot;

  /** @internal First materialization ping for devtools registry. */
  emitRegisterNotify(): void {
    this.#emitNotify("register");
  }

  getLayer(id: string): Layer<P, R, E, D> | undefined {
    return this.#layers.find((l) => l.id === id);
  }

  find(key: LayerKey): Layer<P, R, E, D> | undefined {
    const sig = keySignature(key);
    return this.#layers.findLast((l) => keySignature(l.key) === sig);
  }

  addBlocker(fn: StackBlockerFn): () => void {
    this.#blockers.add(fn);
    return () => this.#blockers.delete(fn);
  }

  get #serial(): boolean {
    return this.options.scope?.strategy === "serial";
  }

  /** Serial occupancy — pending, active, or mounted error (block policy). */
  #hasActive(): boolean {
    return this.#layers.some(
      (l) =>
        l.state.phase === "pending" ||
        l.state.phase === "active" ||
        l.state.phase === "error",
    );
  }

  open(opts: OpenOpts<P, D>): Layer<P, R, E, D> {
    return notifyManager.batch(() => {
      let payload = opts.payload;
      if (opts.validate) {
        try {
          payload = validatePayload(opts.validate, opts.payload);
        } catch (error) {
          const layer = new Layer<P, R, E, D>({
            key: opts.key,
            payload: opts.payload,
            index: this.#layers.length,
            stackSize: this.#layers.length,
            component: opts.component,
            enteringDelay: opts.enteringDelay,
            exitingDelay: opts.exitingDelay,
          });
          layer.reject(error as E);
          return layer;
        }
      }

      if (opts.upsert) {
        const existing = this.find(opts.key);
        if (existing) {
          this.#dispatch("update", () => {
            existing.setPartial({ payload });
            this.#flush();
          });
          return existing;
        }
      }

      // Cached data suppresses a second load when the same key reopens.
      const cached = this.#gcCache.take(opts.key);
      const loadFn = cached ? undefined : opts.loadFn;
      const data = cached?.state.data;

      const layer = new Layer<P, R, E, D>({
        key: opts.key,
        payload,
        index: this.#layers.length,
        stackSize: this.#layers.length + 1,
        component: opts.component,
        enteringDelay: opts.enteringDelay,
        exitingDelay: opts.exitingDelay,
        data,
      });

      const commit = () => this.#commit(layer, loadFn);

      if (this.#serial && this.#hasActive()) {
        this.#dispatch("queue", () => {
          this.#scopeQueue.push({ layer, commit });
          layer.setPartial({ phase: "queued" });
          this.#flush();
        });
        return layer;
      }

      this.#dispatch("open", () => {
        commit();
      });
      return layer;
    });
  }

  #commit(layer: Layer<P, R, E, D>, loadFn: OpenOpts<P, D>["loadFn"]): void {
    this.#layers = [...this.#layers, layer];
    this.#reindex();
    const entering = layer.enteringDelay > 0;
    layer.setPartial({
      phase: loadFn ? "pending" : "active",
      transition: entering ? "entering" : "settled",
    });
    this.#flush();
    if (entering) {
      layer.enterTimer = setTimeout(
        () => this.#settleEnter(layer),
        layer.enteringDelay,
      );
    }
    if (loadFn) {
      void this.#runLoad(layer, loadFn);
    }
  }

  #settleEnter(layer: Layer<P, R, E, D>): void {
    layer.enterTimer = undefined;
    notifyManager.batch(() => {
      this.#dispatch("settle", () => {
        layer.setPartial({ transition: "settled" });
        this.#flush();
      });
    });
  }

  async #runLoad(
    layer: Layer<P, R, E, D>,
    loadFn: NonNullable<OpenOpts<P, D>["loadFn"]>,
  ): Promise<void> {
    try {
      const data = await loadFn({
        payload: layer.state.payload,
        signal: layer.abortController.signal,
      });
      if (layer.aborted) {
        return;
      }
      layer.setPartial({ data, phase: "active" });
      this.#dispatch("phase", () => {
        this.#flush();
      });
    } catch (error) {
      if (layer.aborted) {
        return;
      }
      const isAdvance =
        this.#serial &&
        (this.options.scope?.onLoadError ?? "block") === "advance";
      if (isAdvance) {
        // Drop the failed occupant and drain — no lasting error UI.
        // Mirror #commitDismiss: mark dismissed, then onLayerDismiss, then remove.
        layer.reject(error as E);
        layer.setPartial({
          error: error as E,
          phase: "dismissed",
          transition: "settled",
          ended: true,
        });
        this.onLayerDismiss?.(layer);
        this.#remove(layer);
        return;
      }
      layer.setPartial({ error: error as E, phase: "error" });
      layer.reject(error as E);
      this.#dispatch("phase", () => {
        this.#flush();
      });
    }
  }

  /**
   * Resolves the caller and aborts in-flight loading.
   * Exiting layers remain mounted until their transition settles.
   */
  dismiss(
    layer: Layer<P, R, E, D>,
    response: R,
    opts?: DismissOptions,
  ): Promise<boolean> {
    if (opts?.force) {
      this.#commitDismiss(layer, response);
      return Promise.resolve(true);
    }
    if (layer.dismissPending) {
      return layer.dismissPending;
    }
    layer.dismissPending = this.#guardedDismiss(layer, response).finally(() => {
      layer.dismissPending = undefined;
    });
    return layer.dismissPending;
  }

  async #guardedDismiss(
    layer: Layer<P, R, E, D>,
    response: R,
  ): Promise<boolean> {
    // Fresh read — concurrent force may dismiss while we await blockers.
    const alreadyDismissed = () => layer.state.phase === "dismissed";
    // Already exiting / dismissed — do not flip `dismissing` (Soft dismiss during exit).
    if (alreadyDismissed()) {
      return true;
    }
    notifyManager.batch(() => {
      this.#dispatch("phase", () => {
        layer.setPartial({ dismissing: true });
        this.#flush();
      });
    });
    const vetoed = await this.#vetoed(layer);
    if (alreadyDismissed()) {
      return true;
    }
    if (vetoed) {
      notifyManager.batch(() => {
        this.#dispatch("dismissVetoed", () => {
          layer.setPartial({ dismissing: false });
          this.#flush();
        });
      });
      return false;
    }
    this.#commitDismiss(layer, response);
    return true;
  }

  async #vetoed(layer: Layer<P, R, E, D>): Promise<boolean> {
    const checks: Array<() => boolean | Promise<boolean>> = [
      ...[...layer.blockers].map((fn) => () => fn()),
      ...[...this.#blockers].map((fn) => () => fn(layer.state as LayerState)),
    ];
    for (const check of checks) {
      let allowed: boolean;
      try {
        allowed = await check();
      } catch (error) {
        // Fail-closed: an errored predicate vetoes (protect user data).
        console.warn("[layers] blocker threw; treating as veto", error);
        allowed = false;
      }
      if (!allowed) {
        return true;
      }
    }
    return false;
  }

  #commitDismiss(layer: Layer<P, R, E, D>, response: R): void {
    notifyManager.batch(() => {
      this.#dispatch("dismiss", () => {
        layer.abort();
        layer.resolve(response);
        layer.setPartial({
          phase: "dismissed",
          transition: "exiting",
          ended: true,
          response,
          dismissing: false,
        });
        this.#flush();
        this.onLayerDismiss?.(layer);
      });
    });
    if (layer.exitingDelay > 0) {
      layer.exitTimer = setTimeout(
        () => this.#remove(layer),
        layer.exitingDelay,
      );
    } else {
      this.#remove(layer);
    }
  }

  settle(layer: Layer<P, R, E, D>): void {
    const t = layer.state.transition;
    if (t === "entering") {
      if (layer.enterTimer) {
        clearTimeout(layer.enterTimer);
        layer.enterTimer = undefined;
      }
      notifyManager.batch(() => {
        this.#dispatch("settle", () => {
          layer.setPartial({ transition: "settled" });
          this.#flush();
        });
      });
    } else if (t === "exiting") {
      if (layer.exitTimer) {
        clearTimeout(layer.exitTimer);
        layer.exitTimer = undefined;
      }
      this.#remove(layer);
    }
  }

  /**
   * Bulk-dismisses active and queued layers, completing every `open()` with
   * `response` (including `undefined` when `R` is `void`). Honors
   * {@link DismissAllMode}; does not reject — use {@link cancelAll} for
   * teardown without a completion value.
   */
  async dismissAll(response: R, opts?: DismissAllOptions): Promise<void> {
    const mode = opts?.mode ?? this.options.dismissAllMode ?? "skipBlocked";
    // Final labeled snapshot: active-only stacks only emit per-layer `"dismiss"`.
    const shouldEmit = this.#scopeQueue.length > 0 || this.#layers.length > 0;
    try {
      notifyManager.batch(() => {
        // Drain the queue FIRST so a dismissed active layer's #remove does
        // not activate a queued one. Queued callers get their response
        // without ever mounting.
        for (const entry of this.#scopeQueue) {
          entry.layer.abort();
          entry.layer.resolve(response);
          entry.layer.setPartial({
            phase: "dismissed",
            transition: "settled",
            ended: true,
            response,
          });
        }
        this.#scopeQueue = [];
        this.#flush();
      });
      for (const l of this.#layers) {
        if (mode === "force") {
          await this.dismiss(l, response, { force: true });
          continue;
        }
        const ok = await this.dismiss(l, response);
        if (!ok && mode === "stopAtBlocked") {
          return;
        }
      }
    } finally {
      if (shouldEmit) {
        this.#emitNotify("dismissAll");
      }
    }
  }

  /**
   * Force-clears the stack and rejects every open/queued caller with
   * {@link LayerCancelledError}. Skips blockers. System teardown path —
   * use {@link dismissAll} when completing with a response.
   *
   * @param opts.reason - Propagated on each rejection.
   * @default opts.reason `"cancelAll"`
   */
  async cancelAll(opts?: { reason?: LayerCancelReason }): Promise<void> {
    const error = new LayerCancelledError(opts?.reason ?? "cancelAll");
    const shouldEmit = this.#scopeQueue.length > 0 || this.#layers.length > 0;
    try {
      notifyManager.batch(() => {
        for (const entry of this.#scopeQueue) {
          this.#rejectCancel(entry.layer, error);
        }
        this.#scopeQueue = [];
        const mounted = [...this.#layers];
        this.#layers = [];
        // Reject + flush before hooks so a throwing onLayerDismiss cannot
        // leave later open() pending or a stale non-empty snapshot.
        for (const layer of mounted) {
          this.#rejectCancel(layer, error);
        }
        this.#flush();
        for (const layer of mounted) {
          this.onLayerDismiss?.(layer);
          this.#gcCache.maybeStore(layer);
        }
      });
    } finally {
      if (shouldEmit) {
        this.#emitNotify("cancelAll");
      }
    }
  }

  /** Abort, reject open(), mark dismissed — no completion response. */
  #rejectCancel(layer: Layer<P, R, E, D>, error: LayerCancelledError): void {
    if (layer.enterTimer) {
      clearTimeout(layer.enterTimer);
      layer.enterTimer = undefined;
    }
    if (layer.exitTimer) {
      clearTimeout(layer.exitTimer);
      layer.exitTimer = undefined;
    }
    layer.abort();
    layer.reject(error as E);
    // Prevent unhandledrejection when callers used void open().
    void layer.promise.promise.catch(() => {});
    layer.setPartial({
      phase: "dismissed",
      transition: "settled",
      ended: true,
    });
  }

  /**
   * Resolves and removes a serially queued layer without mounting it (skips blockers).
   * No `id` → FIFO head for the key; `{ id }` → exact queued match.
   */
  cancelQueued(key: LayerKey, response: R, opts?: { id?: string }): boolean {
    return notifyManager.batch(() => {
      return this.#dispatch("cancelQueued", () => {
        const sig = keySignature(key);
        const idx = this.#scopeQueue.findIndex((entry) => {
          if (keySignature(entry.layer.key) !== sig) return false;
          if (opts?.id !== undefined) return entry.layer.id === opts.id;
          return true;
        });
        if (idx === -1) {
          return false;
        }
        const entry = this.#scopeQueue[idx]!;
        entry.layer.abort();
        entry.layer.resolve(response);
        entry.layer.setPartial({
          phase: "dismissed",
          transition: "settled",
          ended: true,
          response,
        });
        this.#scopeQueue.splice(idx, 1);
        this.#flush();
        return true;
      });
    });
  }

  update(layer: Layer<P, R, E, D>, patch: Partial<P>): void {
    notifyManager.batch(() => {
      this.#dispatch("update", () => {
        layer.setPartial({ payload: { ...layer.state.payload, ...patch } });
        this.#flush();
      });
    });
  }

  setRunning(layer: Layer<P, R, E, D>, running: boolean): void {
    notifyManager.batch(() => {
      this.#dispatch("setRunning", () => {
        layer.setRunning(running);
        this.#flush();
      });
    });
  }

  #remove(layer: Layer<P, R, E, D>): void {
    notifyManager.batch(() => {
      this.#dispatch("remove", () => {
        this.#layers = this.#layers.filter((l) => l.id !== layer.id);
        this.#reindex();
        this.#flush();
        this.#gcCache.maybeStore(layer);
        if (this.#serial && !this.#hasActive()) {
          const next = this.#scopeQueue.shift();
          if (next) {
            this.#dispatch("open", () => {
              next.commit();
            });
          }
        }
      });
    });
  }

  #reindex(): void {
    this.#layers.forEach((l, i) =>
      l.setPartial({ index: i, stackSize: this.#layers.length }),
    );
  }

  /** Preserves snapshot identity when batching produces no observable change. */
  #flush(): void {
    const next = this.#layers.map((l) => l.state);
    const nextQueued = this.#scopeQueue.map((entry) => entry.layer.state);
    const snapshotUnchanged =
      next.length === this.#snapshot.length &&
      next.every((s, i) => s === this.#snapshot[i]);
    const queuedUnchanged =
      nextQueued.length === this.#queuedSnapshot.length &&
      nextQueued.every((s, i) => s === this.#queuedSnapshot[i]);
    if (snapshotUnchanged && queuedUnchanged) {
      return;
    }
    if (!snapshotUnchanged) {
      this.#snapshot = next;
    }
    if (!queuedUnchanged) {
      this.#queuedSnapshot = nextQueued;
    }
    this.notify();
    const action = this.#pendingNotifyAction;
    if (action !== undefined) {
      this.#pendingNotifyAction = undefined;
      this.#emitNotify(action);
    }
  }

  #dispatch<T>(action: StackNotifyAction, run: () => T): T {
    this.#pendingNotifyAction = action;
    try {
      return run();
    } finally {
      this.#pendingNotifyAction = undefined;
    }
  }

  #emitNotify(action: StackNotifyAction): void {
    if (!this.onNotify) {
      return;
    }
    this.#notifySeq += 1;
    this.onNotify({
      stackId: this.id,
      seq: this.#notifySeq,
      ts: Date.now(),
      action,
      active: this.#snapshot.map((state) => projectLayerNotifyView(state)),
      queued: this.#queuedSnapshot.map((state) =>
        projectLayerNotifyView(state),
      ),
    });
  }
}

function projectLayerNotifyView(
  state: LayerState<unknown, unknown, unknown, unknown>,
): LayerNotifyView {
  const { payload, payloadTruncated } = projectJsonSafePayload(state.payload);
  const view: LayerNotifyView = {
    id: state.id,
    key: keySignature(state.key),
    phase: state.phase,
    transition: state.transition,
    actionStatus: state.actionStatus,
    dismissing: state.dismissing,
    ended: state.ended,
    index: state.index,
    stackSize: state.stackSize,
  };
  if (payload !== undefined) {
    view.payload = payload;
  }
  if (payloadTruncated) {
    view.payloadTruncated = true;
  }
  return view;
}

function projectJsonSafePayload(raw: unknown): {
  payload?: unknown;
  payloadTruncated?: boolean;
} {
  if (raw === undefined) {
    return {};
  }
  if (typeof raw === "number" && !Number.isFinite(raw)) {
    return { payloadTruncated: true };
  }
  try {
    return { payload: JSON.parse(JSON.stringify(raw)) as unknown };
  } catch {
    return { payloadTruncated: true };
  }
}
