import type { View } from "../pages/registry";

export type FrameEntry = {
  view: View;
  instance: number;
  keepAlive: boolean;
  /**
   * Time at which this document became hidden, using the cache clock.
   *
   * Active and transient documents use `null`. Measuring from deactivation
   * prevents a page that has been visible for a long time from expiring as
   * soon as the user navigates away.
   */
  inactiveSince: number | null;
};

export type FrameCacheOptions = {
  /**
   * How long a keep-alive document may remain continuously hidden.
   */
  inactiveTtlMs: number;
  shouldKeepAlive: (view: View) => boolean;
  /** Injectable monotonic clock for deterministic tests. */
  now?: () => number;
};

export type FrameTransition = {
  entries: FrameEntry[];
  removed: FrameEntry[];
  activated: FrameEntry;
};

export type FrameExpiration = Omit<FrameTransition, "activated">;

/**
 * Owns iframe document identities and expires retained, hidden documents.
 *
 * Entries retain object identity between snapshots so Solid's `<For>` keeps
 * the corresponding iframe DOM node alive. `instance` is never reused, which
 * makes a recreated document observable in its URL and prevents stale browser
 * history or service-worker responses from masquerading as a retained frame.
 *
 * Primary pages may all opt into retention because their set is finite.
 * Transient/detail pages opt out and are removed immediately after navigation.
 * Retained pages are released only after a continuous inactive interval, not
 * because another primary page happened to be opened.
 */
export class FrameCache {
  readonly #options: FrameCacheOptions;
  readonly #entries = new Map<View, FrameEntry>();
  #active: View;
  #nextInstance = 1;

  constructor(initialView: View, options: FrameCacheOptions) {
    if (!Number.isFinite(options.inactiveTtlMs) || options.inactiveTtlMs <= 0) {
      throw new RangeError("inactiveTtlMs must be a positive finite number");
    }
    this.#options = options;
    this.#active = initialView;
    this.#entries.set(initialView, this.#create(initialView));
  }

  get active(): View {
    return this.#active;
  }

  snapshot(): FrameEntry[] {
    return [...this.#entries.values()];
  }

  activate(view: View): FrameTransition {
    const removed: FrameEntry[] = [];
    const previous = this.#entries.get(this.#active);
    const now = this.#now();

    if (previous && previous.view !== view) {
      if (previous.keepAlive) {
        previous.inactiveSince = now;
      } else {
        this.#entries.delete(previous.view);
        removed.push(previous);
      }
    }

    // Prune before activation so revisiting an already-expired page creates a
    // fresh iframe instead of reviving stale document state.
    removed.push(...this.#removeExpired(now));

    let activated = this.#entries.get(view);
    if (!activated) {
      activated = this.#create(view);
      this.#entries.set(view, activated);
    }
    activated.inactiveSince = null;
    this.#active = view;

    return {
      entries: this.snapshot(),
      removed,
      activated,
    };
  }

  /**
   * Removes retained documents whose continuous hidden interval has elapsed.
   *
   * Host calls this from a timer and sends `dispose` before applying the
   * returned snapshot to the DOM.
   */
  expireInactive(): FrameExpiration {
    return {
      removed: this.#removeExpired(this.#now()),
      entries: this.snapshot(),
    };
  }

  /**
   * Returns the delay until the next hidden document expires.
   *
   * `null` means no retained page is currently hidden. Recomputing the delay
   * after every navigation and expiration avoids one interval timer per page.
   */
  timeUntilExpiration(): number | null {
    const now = this.#now();
    let delay: number | null = null;
    for (const entry of this.#entries.values()) {
      if (!entry.keepAlive || entry.inactiveSince === null) continue;
      const remaining = Math.max(0, entry.inactiveSince + this.#options.inactiveTtlMs - now);
      if (delay === null || remaining < delay) delay = remaining;
    }
    return delay;
  }

  #create(view: View): FrameEntry {
    return {
      view,
      instance: this.#nextInstance++,
      keepAlive: this.#options.shouldKeepAlive(view),
      inactiveSince: null,
    };
  }

  #removeExpired(now: number): FrameEntry[] {
    const removed: FrameEntry[] = [];
    for (const entry of this.#entries.values()) {
      if (
        entry.view === this.#active ||
        !entry.keepAlive ||
        entry.inactiveSince === null ||
        now - entry.inactiveSince < this.#options.inactiveTtlMs
      ) {
        continue;
      }
      this.#entries.delete(entry.view);
      removed.push(entry);
    }
    return removed;
  }

  #now(): number {
    return this.#options.now?.() ?? performance.now();
  }
}
