import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
import type { NextFunction, Request, Response } from "express";
import bcrypt from "bcryptjs";

import { config } from "./config.ts";
import type { Db } from "./db/index.ts";
import type { AuthenticatedRequest } from "./types.ts";

const SESSION_COOKIE_OPTIONS = () => ({
  httpOnly: true,
  secure: config.secureCookies,
  sameSite: "strict" as const,
  path: "/",
  maxAge: config.sessionTtlSeconds * 1000,
});

export const token = (bytes = 32) => randomBytes(bytes).toString("base64url");
export const hashToken = (value: string) => createHash("sha256").update(value).digest("hex");
export const ipHash = (request: Request) => hashToken(request.ip ?? "unknown").slice(0, 24);

export function readCookies(request: Request): Record<string, string> {
  const result: Record<string, string> = {};
  for (const part of (request.headers.cookie ?? "").split(";")) {
    const index = part.indexOf("=");
    if (index < 1) continue;
    result[decodeURIComponent(part.slice(0, index).trim())] = decodeURIComponent(part.slice(index + 1).trim());
  }
  return result;
}

export function validatePassword(password: string): string[] {
  const errors: string[] = [];
  if (password.length < 12) errors.push("Use at least 12 characters.");
  if (password.length > 128) errors.push("Use no more than 128 characters.");
  if (!/[a-z]/.test(password)) errors.push("Add a lowercase letter.");
  if (!/[A-Z]/.test(password)) errors.push("Add an uppercase letter.");
  if (!/[0-9]/.test(password)) errors.push("Add a number.");
  if (!/[^A-Za-z0-9]/.test(password)) errors.push("Add a symbol.");
  return errors;
}

export const hashPassword = (password: string) => bcrypt.hash(password, 12);
export const verifyPassword = (password: string, hash: string) => bcrypt.compare(password, hash);

export function createSession(db: Db, user: { id: string }, request: Request, response: Response) {
  const rawToken = token();
  const csrfToken = token(24);
  const id = randomUUID();
  const expiresAt = new Date(Date.now() + config.sessionTtlSeconds * 1000).toISOString();
  db.prepare(`INSERT INTO sessions
    (id, user_id, token_hash, csrf_token, user_agent, ip_hash, expires_at)
    VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
    id, user.id, hashToken(rawToken), csrfToken,
    (request.get("user-agent") ?? "").slice(0, 300), ipHash(request), expiresAt,
  );
  response.cookie(config.sessionCookie, rawToken, SESSION_COOKIE_OPTIONS());
  return { id, csrfToken, expiresAt };
}

export function clearSessionCookie(response: Response) {
  response.clearCookie(config.sessionCookie, { ...SESSION_COOKIE_OPTIONS(), maxAge: undefined });
}

export function sessionMiddleware(db: Db) {
  return (request: AuthenticatedRequest, response: Response, next: NextFunction) => {
    const raw = readCookies(request)[config.sessionCookie];
    if (!raw) return next();
    const row = db.prepare(`SELECT s.id session_id, s.user_id, s.csrf_token, s.expires_at, u.username
      FROM sessions s JOIN users u ON u.id = s.user_id
      WHERE s.token_hash = ? AND s.revoked_at IS NULL`).get(hashToken(raw)) as any;
    if (!row || Date.parse(row.expires_at) <= Date.now()) {
      if (row) db.prepare("UPDATE sessions SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?").run(row.session_id);
      clearSessionCookie(response);
      response.locals.sessionExpired = Boolean(row);
      return next();
    }
    request.auth = {
      sessionId: row.session_id,
      userId: row.user_id,
      username: row.username,
      csrfToken: row.csrf_token,
      expiresAt: row.expires_at,
    };
    db.prepare("UPDATE sessions SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ?").run(row.session_id);
    next();
  };
}

export function requireAuth(request: AuthenticatedRequest, response: Response, next: NextFunction) {
  if (!request.auth) {
    return response.status(401).json({ ok: false, error: { code: response.locals.sessionExpired ? "SESSION_EXPIRED" : "AUTH_REQUIRED", message: "Authentication is required." } });
  }
  next();
}

export function requireCsrf(request: AuthenticatedRequest, response: Response, next: NextFunction) {
  if (["GET", "HEAD", "OPTIONS"].includes(request.method)) return next();
  const supplied = request.get("x-ux7-csrf") ?? "";
  const expected = request.auth?.csrfToken ?? readCookies(request)[config.preauthCookie] ?? "";
  if (!supplied || !expected || supplied.length !== expected.length || !timingSafeEqual(Buffer.from(supplied), Buffer.from(expected))) {
    return response.status(403).json({ ok: false, error: { code: "CSRF_INVALID", message: "The security token is invalid. Refresh and try again." } });
  }
  next();
}

export function issuePreauth(response: Response): string {
  const value = token(24);
  response.cookie(config.preauthCookie, value, { ...SESSION_COOKIE_OPTIONS(), maxAge: 15 * 60 * 1000 });
  return value;
}

export function securityHeaders(_request: Request, response: Response, next: NextFunction) {
  response.set({
    "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'",
    "X-Content-Type-Options": "nosniff",
    "Referrer-Policy": "no-referrer",
    "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
    "Cross-Origin-Opener-Policy": "same-origin",
  });
  next();
}

export function redactSecrets(value: string): string {
  return value
    .replace(/\b(?:sk|ghp|github_pat|xox[baprs])[-_A-Za-z0-9]{12,}\b/gi, "[REDACTED]")
    .replace(/\b(password|secret|token|api[_-]?key)\s*[=:]\s*[^\s,;]+/gi, "$1=[REDACTED]")
    .replace(/-----BEGIN [^-]+ PRIVATE KEY-----[\s\S]*?-----END [^-]+ PRIVATE KEY-----/g, "[REDACTED PRIVATE KEY]")
    .slice(0, 20_000);
}

export function audit(db: Db, request: AuthenticatedRequest | Request, action: string, targetType?: string, targetId?: string | null, metadata: Record<string, unknown> = {}) {
  const userId = (request as AuthenticatedRequest).auth?.userId ?? null;
  db.prepare("INSERT INTO audit_logs (user_id, action, target_type, target_id, metadata_json, ip_hash) VALUES (?, ?, ?, ?, ?, ?)")
    .run(userId, action, targetType ?? null, targetId ?? null, JSON.stringify(metadata), ipHash(request));
}
