import type { Context, Next } from "hono";
import { randomBytes, timingSafeEqual } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { isatty } from "node:tty";
import { ensureSecureDir, writeSecureFile } from "./fs-permissions.js";
import { createLogger } from "./logger.js";
import { config, ZENCODE_DIR } from "./config.js";

const log = createLogger("auth");

/** Path to the file-backed auth token. */
const AUTH_TOKEN_PATH = join(ZENCODE_DIR, "auth-token");

/**
 * Returns `true` when the user has explicitly opted out of authentication
 * via the `--no-auth` CLI flag or the `ZENCODE_NO_AUTH` env var.
 */
export function isNoAuthMode(): boolean {
  if (config.noAuth) return true;
  return process.argv.includes("--no-auth");
}

/**
 * Error thrown when the server refuses to start due to an unsafe
 * `--no-auth` + non-localhost host configuration.
 */
export class UnsafeNoAuthError extends Error {
  override readonly name = "UnsafeNoAuthError";
}

/**
 * Initialises the auth token on server start-up.
 *
 * - If `--no-auth` / `ZENCODE_NO_AUTH=1` is set, logs an **error-level**
 *   warning and returns `null`.
 * - When `--no-auth` is combined with a non-localhost `HOST` (e.g. `0.0.0.0`),
 *   the server **refuses to start** unless `ZENCODE_UNSAFE_NO_AUTH=1` is
 *   explicitly set.  This prevents accidentally exposing an unauthenticated
 *   server on the network.
 * - If `ZENCODE_AUTH_TOKEN` is already in the environment, uses that.
 * - Otherwise reads `~/.zencode/auth-token`. If the file doesn't exist,
 *   generates a random token, writes it to disk, and prints it once.
 *
 * The resolved token is always written back to `process.env.ZENCODE_AUTH_TOKEN`
 * so the rest of the middleware can use `getAuthToken()` as before.
 *
 * @returns The active token, or `null` if auth is disabled.
 * @throws {UnsafeNoAuthError} When `--no-auth` is used with a non-localhost host
 *         and `ZENCODE_UNSAFE_NO_AUTH=1` is not set.
 */
export function initAuthToken(): string | null {
  if (isNoAuthMode()) {
    const isLocalhostHost = config.host === "localhost" || config.host === "127.0.0.1";

    if (!isLocalhostHost && !config.unsafeNoAuth) {
      throw new UnsafeNoAuthError(
        `Refusing to start: --no-auth with HOST=${config.host} exposes the server without authentication.\n` +
        "  To bind to a specific interface with auth, remove --no-auth.\n" +
        "  To accept the risk, set ZENCODE_UNSAFE_NO_AUTH=1.",
      );
    }

    log.error(
      "Authentication is DISABLED (--no-auth / ZENCODE_NO_AUTH=1). All endpoints are open.",
    );
    return null;
  }

  // Honour an explicit ENV token (e.g. Docker / CI)
  const envToken = config.authToken;
  if (envToken) {
    // Ensure process.env stays in sync for subprocess inheritance
    process.env.ZENCODE_AUTH_TOKEN = envToken;
    return envToken;
  }

  // Try reading the persisted token file
  const dir = ZENCODE_DIR;
  if (existsSync(AUTH_TOKEN_PATH)) {
    try {
      const stored = readFileSync(AUTH_TOKEN_PATH, "utf-8").trim();
      if (stored) {
        process.env.ZENCODE_AUTH_TOKEN = stored;
        return stored;
      }
    } catch {
      // File unreadable — fall through to generation
    }
  }

  // Generate a new token
  const token = randomBytes(32).toString("hex");
  try {
    if (!existsSync(dir)) {
      ensureSecureDir(dir);
    }
    writeSecureFile(AUTH_TOKEN_PATH, token);
  } catch (err) {
    log.error({ tokenPath: AUTH_TOKEN_PATH, err }, "Failed to write token file");
  }

  process.env.ZENCODE_AUTH_TOKEN = token;

  log.info(
    { tokenPrefix: token.slice(0, 8) + "...", tokenPath: AUTH_TOKEN_PATH },
    "New auth token created",
  );
  if (isatty(1)) {
    console.log("──────────────────────────────────────────────────");
    console.log(`Auth token: ${token}`);
    console.log(`Token file: ${AUTH_TOKEN_PATH}`);
    console.log("──────────────────────────────────────────────────");
  }

  return token;
}

/**
 * Returns the configured auth token from the environment, or `null` if
 * authentication is not enabled.
 */
export function getAuthToken(): string | null {
  if (isNoAuthMode()) return null;
  const token = process.env.ZENCODE_AUTH_TOKEN;
  return token && token.trim() ? token.trim() : null;
}

/**
 * Constant-time comparison of two token strings to prevent timing attacks.
 */
function tokensMatch(provided: string, expected: string): boolean {
  const a = Buffer.from(provided);
  const b = Buffer.from(expected);
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}

/**
 * Extracts a Bearer token from the Authorization header value.
 * Returns `null` if the header is missing or malformed.
 */
export function extractBearerToken(headerValue: string | undefined | null): string | null {
  if (!headerValue) return null;
  const match = headerValue.match(/^Bearer\s+(\S+)$/i);
  return match ? match[1] : null;
}

/**
 * Validates a token against the configured auth token.
 * Returns `true` if auth is disabled (no token configured) or the token matches.
 */
export function validateToken(token: string | null): boolean {
  const expected = getAuthToken();
  if (!expected) return true; // Auth not enabled
  if (!token) return false;
  return tokensMatch(token, expected);
}

/**
 * Extracts the auth token from the `Sec-WebSocket-Protocol` header.
 *
 * The browser sends protocols as a comma-separated list. We expect
 * `zencode-protocol` (the real sub-protocol) and `auth.<token>`.
 */
export function extractWsProtocolToken(
  protocolHeader: string | undefined | null,
): string | null {
  if (!protocolHeader) return null;
  const protocols = protocolHeader.split(",").map((p) => p.trim());
  for (const proto of protocols) {
    if (proto.startsWith("auth.")) {
      return proto.slice(5);
    }
  }
  return null;
}

/**
 * Validates a WebSocket upgrade request.
 *
 * Accepts the token via `Sec-WebSocket-Protocol: auth.<token>` header
 * (preferred) or legacy `?token=<token>` query parameter.
 */
export function validateWsToken(
  url: URL,
  protocolHeader: string | undefined | null,
): { valid: boolean; error?: string; protocols?: string[] } {
  const expected = getAuthToken();
  if (!expected) return { valid: true };

  // Preferred: Sec-WebSocket-Protocol header
  const headerToken = extractWsProtocolToken(protocolHeader);
  if (headerToken && tokensMatch(headerToken, expected)) {
    // Echo back the accepted sub-protocols (excluding the auth token)
    return { valid: true, protocols: ["zencode-protocol"] };
  }

  // Legacy fallback: query parameter (deprecated)
  const queryToken = url.searchParams.get("token");
  if (queryToken && tokensMatch(queryToken, expected)) {
    log.warn(
      { path: url.pathname, deprecation: "ws-query-token" },
      "WebSocket auth via ?token= query parameter is deprecated and will be removed in a future release. " +
        "Use the Sec-WebSocket-Protocol header instead.",
    );
    return { valid: true };
  }

  if (!headerToken && !queryToken) {
    return {
      valid: false,
      error: "Authentication required. Provide token via Sec-WebSocket-Protocol header.",
    };
  }

  return { valid: false, error: "Invalid authentication token." };
}

/**
 * Validates the `Origin` header on a WebSocket upgrade request.
 * Returns `{ valid: true }` when the origin is allowed, or an error otherwise.
 *
 * If no Origin header is present (e.g. non-browser clients like CLI), the
 * request is allowed — the auth token already gates access.
 */
export function validateWsOrigin(
  origin: string | undefined | null,
  allowedOrigins: string[],
): { valid: boolean; error?: string } {
  // Non-browser clients (curl, CLI) may not send an Origin header
  if (!origin) return { valid: true };

  if (allowedOrigins.includes(origin)) {
    return { valid: true };
  }

  return {
    valid: false,
    error: `WebSocket connection from origin '${origin}' is not allowed.`,
  };
}

/**
 * Hono middleware that enforces Bearer token authentication on API routes
 * when `ZENCODE_AUTH_TOKEN` is set. When the env var is not set, all
 * requests pass through without authentication.
 */
export function authMiddleware() {
  return async (c: Context, next: Next) => {
    const expected = getAuthToken();
    if (!expected) {
      return next();
    }

    const authHeader = c.req.header("Authorization");
    const token = extractBearerToken(authHeader);

    if (!token) {
      return c.json(
        { error: "Authentication required. Provide Authorization: Bearer <token> header." },
        401,
      );
    }

    if (!tokensMatch(token, expected)) {
      return c.json({ error: "Invalid authentication token." }, 401);
    }

    return next();
  };
}
