/**
 * Session persistence layer backed by JSON files on the local file system.
 *
 * Each session is serialized as a {@link PersistedSession} and written to an
 * individual `{sessionId}.json` file inside the data directory (default:
 * `~/.zencode/sessions/`). A separate `launcher.json` file stores launcher state.
 *
 * Write strategy:
 * - Rapid updates (e.g., during streaming) are coalesced using a throttle pattern:
 *   the first write starts a 150ms timer; subsequent writes within that window
 *   update the pending data without resetting the timer.
 * - Async writes are chained per-session to prevent overlapping I/O.
 * - A `flushAll()` method drains all pending writes during graceful shutdown.
 * - Critical state changes can use `saveImmediate()` for instant async writes.
 *
 * File permissions are enforced via `fs-permissions.ts` helpers to ensure
 * session data is readable only by the owning user.
 */

import { readFileSync, unlinkSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import { ensureSecureDir, writeSecureFile, writeSecureFileAsync } from "./fs-permissions.js";
import { join } from "node:path";
import { ZENCODE_DIR } from "./config.js";
import type {
  SessionState,
  BrowserIncomingMessage,
  PermissionRequest,
  BufferedBrowserEvent,
  QueuedMessage,
} from "./session-types.js";
import { createLogger } from "./logger.js";

const log = createLogger("session-store");

// ─── Serializable session shape ─────────────────────────────────────────────

/**
 * Serializable session shape written to and read from disk.
 * Contains the full session state, message history, pending permissions,
 * event buffer for browser replay, and message queue.
 */
export interface PersistedSession {
  id: string;
  state: SessionState;
  messageHistory: BrowserIncomingMessage[];
  pendingMessages: string[];
  pendingPermissions: [string, PermissionRequest][];
  eventBuffer?: BufferedBrowserEvent[];
  nextEventSeq?: number;
  lastAckSeq?: number;
  processedClientMessageIds?: string[];
  archived?: boolean;
  messageQueue?: QueuedMessage[];
  queuePaused?: boolean;
}

// ─── Store ──────────────────────────────────────────────────────────────────

const DEFAULT_DIR = join(ZENCODE_DIR, "sessions");

/**
 * File-system persistence store for session data.
 * Reads and writes `{sessionId}.json` files to a configurable directory.
 */
export class SessionStore {
  private dir: string;
  private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
  private pendingSessions = new Map<string, PersistedSession>();
  private inflightWrites = new Map<string, Promise<void>>();

  /**
   * @param dir - Directory for session JSON files. Defaults to `~/.zencode/sessions/`.
   *   The directory is created with secure permissions if it does not exist.
   */
  constructor(dir?: string) {
    this.dir = dir || DEFAULT_DIR;
    ensureSecureDir(this.dir);
  }

  private filePath(sessionId: string): string {
    return join(this.dir, `${sessionId}.json`);
  }

  /**
   * Async file write using Bun.write — non-blocking I/O.
   * Errors are caught and logged, never thrown to callers.
   */
  private async writeToFile(session: PersistedSession): Promise<void> {
    try {
      await writeSecureFileAsync(this.filePath(session.id), JSON.stringify(session));
    } catch (err) {
      log.error({ sessionId: session.id, err }, "Failed to save session");
    }
  }

  /**
   * Schedule an async write, chained after any in-flight write for the same
   * session to prevent overlapping writes.
   */
  private scheduleWrite(session: PersistedSession): Promise<void> {
    const prev = this.inflightWrites.get(session.id) ?? Promise.resolve();
    const next = prev.then(() => this.writeToFile(session));
    this.inflightWrites.set(session.id, next);
    // Clean up when this write is still the latest for this session
    next.then(() => {
      if (this.inflightWrites.get(session.id) === next) {
        this.inflightWrites.delete(session.id);
      }
    });
    return next;
  }

  /**
   * Coalescing write — batches rapid changes (e.g. multiple stream events).
   * Uses a throttle pattern: the first call starts a 150ms timer; subsequent
   * calls within that window update the pending data but do NOT reset the
   * timer, avoiding constant timer churn during streaming.
   */
  save(session: PersistedSession): void {
    this.pendingSessions.set(session.id, session);

    // If a timer is already running, just update pending data — don't reset.
    if (this.debounceTimers.has(session.id)) return;

    const timer = setTimeout(() => {
      this.debounceTimers.delete(session.id);
      const latest = this.pendingSessions.get(session.id);
      this.pendingSessions.delete(session.id);
      if (latest) this.scheduleWrite(latest);
    }, 150);
    this.debounceTimers.set(session.id, timer);
  }

  /**
   * Flush all pending debounced writes and await any in-flight async writes.
   * Used during graceful shutdown to ensure no session data is lost.
   */
  async flushAll(): Promise<void> {
    const pendingCount = this.debounceTimers.size;
    if (pendingCount === 0 && this.inflightWrites.size === 0) return;

    if (pendingCount > 0) {
      log.info({ count: pendingCount }, "Flushing pending session writes");
    }
    for (const [sessionId, timer] of this.debounceTimers) {
      clearTimeout(timer);
      const session = this.pendingSessions.get(sessionId);
      if (session) {
        this.scheduleWrite(session);
      }
    }
    this.debounceTimers.clear();
    this.pendingSessions.clear();

    await Promise.all(this.inflightWrites.values());
    this.inflightWrites.clear();
  }

  /** Immediate async write — use for critical state changes that cannot be deferred. */
  async saveImmediate(session: PersistedSession): Promise<void> {
    await this.scheduleWrite(session);
  }

  /**
   * Load a single session from disk synchronously.
   *
   * @param sessionId - Session to load.
   * @returns The parsed session, or `null` if the file does not exist or is unreadable.
   */
  load(sessionId: string): PersistedSession | null {
    try {
      const raw = readFileSync(this.filePath(sessionId), "utf-8");
      return JSON.parse(raw) as PersistedSession;
    } catch {
      return null;
    }
  }

  /**
   * Load all session files from disk using parallel async reads.
   * Corrupt or unreadable files are logged and skipped.
   *
   * @returns An array of all successfully parsed sessions.
   */
  async loadAll(): Promise<PersistedSession[]> {
    let files: string[];
    try {
      const entries = await readdir(this.dir);
      files = entries.filter((f) => f.endsWith(".json") && f !== "launcher.json");
    } catch {
      // Dir doesn't exist yet
      return [];
    }

    if (files.length === 0) return [];

    const results = await Promise.allSettled(
      files.map((file) => readFile(join(this.dir, file), "utf-8")),
    );

    const sessions: PersistedSession[] = [];
    for (let i = 0; i < results.length; i++) {
      const result = results[i];
      if (result.status === "rejected") {
        log.warn({ file: files[i], err: result.reason }, "Failed to read session file, skipping");
        continue;
      }
      try {
        sessions.push(JSON.parse(result.value) as PersistedSession);
      } catch (err) {
        log.warn({ file: files[i], err }, "Corrupt session file, skipping");
      }
    }
    return sessions;
  }

  /**
   * Set the archived flag on a persisted session and write immediately.
   *
   * @param sessionId - Session to update.
   * @param archived - Whether the session should be marked archived.
   * @returns `true` if the session was found and updated, `false` otherwise.
   */
  async setArchived(sessionId: string, archived: boolean): Promise<boolean> {
    const session = this.pendingSessions.get(sessionId) ?? this.load(sessionId);
    if (!session) return false;
    session.archived = archived;
    await this.saveImmediate(session);
    return true;
  }

  /**
   * Remove a session file from disk and cancel any pending writes.
   *
   * @param sessionId - Session to delete.
   */
  remove(sessionId: string): void {
    const timer = this.debounceTimers.get(sessionId);
    if (timer) {
      clearTimeout(timer);
      this.debounceTimers.delete(sessionId);
    }
    this.pendingSessions.delete(sessionId);
    try {
      unlinkSync(this.filePath(sessionId));
    } catch {
      // File may not exist
    }
  }

  /** Persist launcher state (separate file). */
  saveLauncher(data: unknown): void {
    try {
      writeSecureFile(join(this.dir, "launcher.json"), JSON.stringify(data));
    } catch (err) {
      log.error({ err }, "Failed to save launcher state");
    }
  }

  /** Load launcher state. */
  loadLauncher<T>(): T | null {
    try {
      const raw = readFileSync(join(this.dir, "launcher.json"), "utf-8");
      return JSON.parse(raw) as T;
    } catch {
      return null;
    }
  }

  get directory(): string {
    return this.dir;
  }
}
