import type { Context, Middleware } from "./context.ts";

export interface CookieOptions {
  path?: string;
  domain?: string;
  maxAge?: number;
  expires?: Date | string;
  httpOnly?: boolean;
  secure?: boolean;
  sameSite?: "Strict" | "Lax" | "None" | "strict" | "lax" | "none";
}

export interface CookieStore {
  get(name: string): string | undefined;
  getAll(): Record<string, string>;
  has(name: string): boolean;
  set(name: string, value: string, options?: CookieOptions): void;
  delete(name: string, options?: CookieOptions): void;
  headers(): string[];
}

export interface SessionStore {
  id(): string;
  get<T = unknown>(key: string): T | undefined;
  getAll(): Record<string, unknown>;
  set(key: string, value: unknown): void;
  delete(key: string): void;
  /** Issue a fresh session id, keeping the data — defends against fixation. */
  regenerate(): void;
  clear(): void;
}

export interface LocalStorageSnapshot {
  get(key: string): string | undefined;
  getAll(): Record<string, string>;
  has(key: string): boolean;
}

const SESSION_COOKIE = "wrnexus.sid";
const SESSION_TTL_MS = 1000 * 60 * 60 * 24;
const SESSION_ABSOLUTE_TTL_MS = 1000 * 60 * 60 * 24 * 7;
const SESSION_GC_EVERY = 500;

export interface SessionPolicy {
  cookieName?: string;
  idleTimeoutMs?: number;
  absoluteTimeoutMs?: number;
  sameSite?: NonNullable<CookieOptions["sameSite"]>;
  secure?: boolean;
}

let sessionPolicy: Required<
  Pick<SessionPolicy, "cookieName" | "idleTimeoutMs" | "absoluteTimeoutMs" | "sameSite">
> &
  Pick<SessionPolicy, "secure"> = {
  cookieName: SESSION_COOKIE,
  idleTimeoutMs: SESSION_TTL_MS,
  absoluteTimeoutMs: SESSION_ABSOLUTE_TTL_MS,
  sameSite: "Lax",
};

export function setSessionPolicy(policy: SessionPolicy): void {
  sessionPolicy = {
    ...sessionPolicy,
    ...policy,
    idleTimeoutMs: Math.max(60_000, policy.idleTimeoutMs ?? sessionPolicy.idleTimeoutMs),
    absoluteTimeoutMs: Math.max(
      policy.idleTimeoutMs ?? sessionPolicy.idleTimeoutMs,
      policy.absoluteTimeoutMs ?? sessionPolicy.absoluteTimeoutMs,
    ),
  };
}

/** A stored session: its data plus an absolute expiry timestamp (ms). */
export interface SessionEntry {
  data: Record<string, unknown>;
  expiresAt: number;
  /** Creation time used for the absolute session lifetime. Optional for old backends. */
  createdAt?: number;
  lastAccessAt?: number;
}

/**
 * Pluggable session persistence. The default is process-local memory; swap in a
 * shared backend (Redis, SQL, etc.) via `setSessionBackend` so sessions survive
 * restarts and work across multiple instances. Methods are synchronous, so a
 * backend must be sync (e.g. `bun:sqlite`); async stores need a load/save
 * wrapper around the request (future work).
 */
export interface SessionBackend {
  get(id: string): SessionEntry | undefined;
  set(id: string, entry: SessionEntry): void;
  delete(id: string): void;
  /** Optional: drop expired entries. Called periodically by the store. */
  gc?(now: number): void;
}

function createMemorySessionBackend(): SessionBackend {
  const map = new Map<string, SessionEntry>();
  return {
    get: (id) => map.get(id),
    set: (id, entry) => void map.set(id, entry),
    delete: (id) => void map.delete(id),
    gc: (now) => {
      for (const [key, entry] of map) {
        if (
          entry.expiresAt <= now ||
          (entry.createdAt ?? now) + sessionPolicy.absoluteTimeoutMs <= now
        )
          map.delete(key);
      }
    },
  };
}

let sessionBackend: SessionBackend = createMemorySessionBackend();
let sessionsSinceGc = 0;

/** Replace the session persistence backend (call once at startup). */
export function setSessionBackend(backend: SessionBackend): void {
  sessionBackend = backend;
}

/**
 * An ASYNC session store (Redis, a remote DB). Use it via the `loadSession`
 * middleware, which loads the session before the request and saves it after —
 * keeping the `ctx.session` API synchronous while persistence is shared across
 * instances.
 */
export interface AsyncSessionBackend {
  load(id: string): Promise<SessionEntry | undefined>;
  save(id: string, entry: SessionEntry): Promise<void>;
  destroy(id: string): Promise<void>;
}

/**
 * Back `ctx.session` with an async store. Register early (before anything reads
 * `ctx.session`). Loads once at the start of the request and saves once at the
 * end; regenerate/clear destroy the old id.
 */
export function loadSession(
  backend: AsyncSessionBackend,
  options: {
    ttlMs?: number;
    absoluteTtlMs?: number;
    cookieName?: string;
    sameSite?: NonNullable<CookieOptions["sameSite"]>;
    secure?: boolean;
  } = {},
): Middleware {
  const ttlMs = options.ttlMs ?? sessionPolicy.idleTimeoutMs;
  const absoluteTtlMs = options.absoluteTtlMs ?? sessionPolicy.absoluteTimeoutMs;
  const cookieName = options.cookieName ?? sessionPolicy.cookieName;
  return async (ctx: Context, next) => {
    let id = ctx.cookies.get(cookieName);
    let entry = id ? await backend.load(id) : undefined;
    if (
      id &&
      entry &&
      (entry.expiresAt <= Date.now() ||
        (entry.createdAt ?? Date.now()) + absoluteTtlMs <= Date.now())
    ) {
      await backend.destroy(id);
      entry = undefined;
      id = undefined;
    } else if (id && !entry) {
      id = undefined; // unknown/expired id → anonymous
    }
    const destroys = new Set<string>();

    const ensure = (): Record<string, unknown> => {
      if (!id) {
        id = randomId();
        ctx.cookies.set(
          cookieName,
          id,
          sessionCookieOptions(options.secure ?? ctx.url.protocol === "https:", options.sameSite),
        );
      }
      if (!entry) {
        const now = Date.now();
        entry = { data: {}, expiresAt: now + ttlMs, createdAt: now, lastAccessAt: now };
      }
      return entry.data;
    };

    ctx.session = {
      id() {
        ensure();
        return id!;
      },
      get<T = unknown>(key: string): T | undefined {
        return (entry?.data[key] as T | undefined) ?? undefined;
      },
      getAll() {
        return entry ? { ...entry.data } : {};
      },
      set(key, value) {
        ensure()[key] = value;
      },
      delete(key) {
        if (entry) delete entry.data[key];
      },
      regenerate() {
        const data = entry?.data ?? {};
        if (id) destroys.add(id);
        id = randomId();
        const now = Date.now();
        entry = { data, expiresAt: now + ttlMs, createdAt: now, lastAccessAt: now };
        ctx.cookies.set(
          cookieName,
          id,
          sessionCookieOptions(options.secure ?? ctx.url.protocol === "https:", options.sameSite),
        );
      },
      clear() {
        if (id) destroys.add(id);
        entry = undefined;
        id = undefined;
        ctx.cookies.delete(
          cookieName,
          sessionCookieOptions(options.secure ?? ctx.url.protocol === "https:", options.sameSite),
        );
      },
    };

    try {
      return await next();
    } finally {
      for (const gone of destroys) if (gone !== id) await backend.destroy(gone);
      if (id && entry) {
        entry.expiresAt = Date.now() + ttlMs;
        entry.lastAccessAt = Date.now();
        await backend.save(id, entry);
      }
    }
  };
}

const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;

/** Read a live (non-expired) session entry, sliding its expiry forward. */
function readSessionEntry(id: string): SessionEntry | undefined {
  const entry = sessionBackend.get(id);
  if (!entry) return undefined;
  const now = Date.now();
  if (entry.expiresAt <= now || (entry.createdAt ?? now) + sessionPolicy.absoluteTimeoutMs <= now) {
    sessionBackend.delete(id);
    return undefined;
  }
  entry.lastAccessAt = now;
  entry.createdAt ??= now;
  entry.expiresAt = now + sessionPolicy.idleTimeoutMs; // sliding idle expiry
  sessionBackend.set(id, entry); // persist the slide (matters for external backends)
  return entry;
}

export function createCookieStore(req: Request): CookieStore {
  const incoming = parseCookieHeader(req.headers.get("cookie") ?? "");
  const outgoing: string[] = [];

  return {
    get(name) {
      return incoming[name];
    },
    getAll() {
      return { ...incoming };
    },
    has(name) {
      return Object.prototype.hasOwnProperty.call(incoming, name);
    },
    set(name, value, options) {
      incoming[name] = value;
      outgoing.push(serializeCookie(name, value, { path: "/", ...options }));
    },
    delete(name, options) {
      delete incoming[name];
      outgoing.push(
        serializeCookie(name, "", {
          path: "/",
          ...options,
          expires: new Date(0),
          maxAge: 0,
        }),
      );
    },
    headers() {
      return [...outgoing];
    },
  };
}

export function createSessionStore(
  cookies: CookieStore,
  req: Request,
  cookieName = sessionPolicy.cookieName,
  secure = sessionPolicy.secure ?? new URL(req.url).protocol === "https:",
): SessionStore {
  let id = cookies.get(cookieName);
  let entry = id ? readSessionEntry(id) : undefined;
  if (id && !entry) id = undefined; // expired or unknown → treat as anonymous

  const persist = (): void => {
    if (id && entry) sessionBackend.set(id, entry);
  };

  const ensure = (): Record<string, unknown> => {
    if (!id) {
      id = randomId();
      cookies.set(cookieName, id, sessionCookieOptions(secure));
    }
    entry = readSessionEntry(id);
    if (!entry) {
      if (++sessionsSinceGc >= SESSION_GC_EVERY) {
        sessionsSinceGc = 0;
        sessionBackend.gc?.(Date.now());
      }
      const now = Date.now();
      entry = {
        data: {},
        expiresAt: now + sessionPolicy.idleTimeoutMs,
        createdAt: now,
        lastAccessAt: now,
      };
      sessionBackend.set(id, entry);
    }
    return entry.data;
  };

  return {
    id() {
      ensure();
      return id!;
    },
    get<T = unknown>(key: string): T | undefined {
      return (entry?.data[key] as T | undefined) ?? undefined;
    },
    getAll() {
      return entry ? { ...entry.data } : {};
    },
    set(key, value) {
      ensure()[key] = value;
      persist();
    },
    delete(key) {
      if (entry) {
        delete entry.data[key];
        persist();
      }
    },
    regenerate() {
      // Session fixation defense: move existing data under a brand-new id and
      // reissue the cookie, so any pre-login id an attacker planted is void.
      const data = entry?.data ?? {};
      if (id) sessionBackend.delete(id);
      id = randomId();
      const now = Date.now();
      entry = {
        data,
        expiresAt: now + sessionPolicy.idleTimeoutMs,
        createdAt: now,
        lastAccessAt: now,
      };
      sessionBackend.set(id, entry);
      cookies.set(cookieName, id, sessionCookieOptions(secure));
    },
    clear() {
      if (id) sessionBackend.delete(id);
      entry = undefined;
      id = undefined;
      cookies.delete(cookieName, sessionCookieOptions(secure));
    },
  };
}

export function createLocalStorageSnapshot(req: Request): LocalStorageSnapshot {
  const values = parseLocalStorageHeader(req.headers.get("x-wrnexus-local-storage"));
  return {
    get(key) {
      return values[key];
    },
    getAll() {
      return { ...values };
    },
    has(key) {
      return Object.prototype.hasOwnProperty.call(values, key);
    },
  };
}

export function applyCookieHeaders(ctx: { cookies?: CookieStore }, headers: Headers): void {
  for (const value of ctx.cookies?.headers() ?? []) {
    headers.append("Set-Cookie", value);
  }
}

function parseCookieHeader(header: string): Record<string, string> {
  const out: Record<string, string> = {};
  for (const part of header.split(";")) {
    const index = part.indexOf("=");
    if (index < 0) continue;
    const name = part.slice(0, index).trim();
    if (!name) continue;
    out[name] = safeDecode(part.slice(index + 1).trim());
  }
  return out;
}

function serializeCookie(name: string, value: string, options: CookieOptions): string {
  if (!COOKIE_NAME.test(name)) throw new Error(`Invalid cookie name: ${name}`);

  const parts = [`${name}=${encodeURIComponent(value)}`];
  if (options.maxAge !== undefined) parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
  if (options.domain) parts.push(`Domain=${options.domain}`);
  if (options.path) parts.push(`Path=${options.path}`);
  if (options.expires) {
    const expires = options.expires instanceof Date ? options.expires : new Date(options.expires);
    parts.push(`Expires=${expires.toUTCString()}`);
  }
  if (options.httpOnly) parts.push("HttpOnly");
  if (options.secure) parts.push("Secure");
  if (options.sameSite) parts.push(`SameSite=${normalizeSameSite(options.sameSite)}`);
  return parts.join("; ");
}

function sessionCookieOptions(
  secure: boolean,
  sameSite: NonNullable<CookieOptions["sameSite"]> = sessionPolicy.sameSite,
): CookieOptions {
  return {
    httpOnly: true,
    path: "/",
    sameSite,
    secure,
  };
}

function parseLocalStorageHeader(header: string | null): Record<string, string> {
  if (!header) return {};
  try {
    const parsed = JSON.parse(decodeURIComponent(header)) as unknown;
    if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};

    const out: Record<string, string> = {};
    for (const [key, value] of Object.entries(parsed)) {
      if (key === "__proto__" || key === "prototype" || key === "constructor") continue;
      if (typeof value === "string") out[key] = value;
    }
    return out;
  } catch {
    return {};
  }
}

function normalizeSameSite(value: NonNullable<CookieOptions["sameSite"]>): string {
  const lower = value.toLowerCase();
  return lower === "strict" ? "Strict" : lower === "none" ? "None" : "Lax";
}

function safeDecode(value: string): string {
  try {
    return decodeURIComponent(value);
  } catch {
    return value;
  }
}

/** A 256-bit cryptographically-random session id (no weak fallback). */
function randomId(): string {
  const bytes = new Uint8Array(32);
  crypto.getRandomValues(bytes);
  let out = "";
  for (const b of bytes) out += b.toString(16).padStart(2, "0");
  return out;
}
