// src/lib/mcp/auth.ts — bearer-token resolution + add-on gating for the MCP route.
// Factored out of the route handler so it is unit-testable without the transport.
// Resolves an `Authorization: Bearer eli_…` header to the owning org, enforcing:
//   missing/invalid/revoked key → 401, valid key but add-on disabled → 403.
import "server-only";
import { and, eq, isNull, sql } from "drizzle-orm";
import { env } from "@/env";
import { db } from "@/lib/db/client";
import { mcpApiKeys } from "@/lib/db/domain-schema";
import { isAddonEnabled } from "./entitlements";
import { hashApiKey } from "./keys";
import {
  assertMcpQuota,
  currentMonth,
  getOrgMcpQuotaSnapshot,
  McpQuotaError,
} from "./quota";

/** The add-on an org must have enabled to use the MCP server. */
export const MCP_ADDON = "checklist_mcp";

/** Public base URL of the app, for actionable error links (settings pages).
 *  Same resolution as the API-keys settings page. */
const APP_URL = env.BETTER_AUTH_URL ?? "https://dash.seoinsight.app";

/** Human, actionable message for an org over its monthly MCP quota — quotes the
 *  live used/limit, the reset month, and the billing link. */
function quotaExceededMessage(snapshot: {
  used: number;
  limit: number;
  resetMonth: string;
}): string {
  return (
    `Monthly MCP request quota exceeded (${snapshot.used}/${snapshot.limit} requests used this month, resets ${snapshot.resetMonth}). ` +
    `Every MCP request counts toward this quota, not only tool calls. ` +
    `Upgrade your plan or buy a credit pack at ${APP_URL}/settings/billing`
  );
}

export type McpAuthResult =
  | { ok: true; orgId: string; keyId: string }
  | {
      ok: false;
      status: 401 | 403 | 429;
      error: string;
      retryAfterSec?: number;
    };

/**
 * Optional per-key rate-limit hook. Called AFTER the key + add-on are resolved
 * but BEFORE the quota UPDATE, so a client that loops cannot burst thousands of
 * writes on the hot `organization` row: rejected requests never reach
 * assertMcpQuota. Receives the DB key id (never the clear key). Injected by the
 * route; unset in unit tests, which keeps the resolution path untouched.
 */
export type McpKeyRateCheck = (keyId: string) => {
  ok: boolean;
  retryAfterSec: number;
};

/** Extract the bearer token from an `Authorization` header value. */
export function parseBearer(
  authHeader: string | null | undefined,
): string | null {
  if (!authHeader) return null;
  const match = authHeader.match(/^Bearer\s+(.+)$/i);
  const token = match?.[1]?.trim();
  return token ? token : null;
}

/**
 * Resolve a request's `Authorization` header to an org id, or an error+status.
 *  - no/blank bearer → 401
 *  - unknown or revoked key → 401
 *  - valid key, add-on disabled → 403
 *  - valid key, add-on enabled → { ok, orgId, keyId }
 * Best-effort updates `lastUsedAt` (never blocks the result).
 */
export async function resolveMcpOrg(
  authHeader: string | null | undefined,
  opts?: { checkKeyRate?: McpKeyRateCheck },
): Promise<McpAuthResult> {
  const token = parseBearer(authHeader);
  if (!token) {
    return {
      ok: false,
      status: 401,
      error: `Missing API key. Add an \`Authorization: Bearer <your-key>\` header. Create a key at ${APP_URL}/settings/api-keys`,
    };
  }

  const keyHash = hashApiKey(token);
  const [row] = await db
    .select({ id: mcpApiKeys.id, orgId: mcpApiKeys.orgId })
    .from(mcpApiKeys)
    .where(and(eq(mcpApiKeys.keyHash, keyHash), isNull(mcpApiKeys.revokedAt)))
    .limit(1);

  if (!row) {
    return {
      ok: false,
      status: 401,
      error: `Invalid or revoked API key. Check the key, or create a new one at ${APP_URL}/settings/api-keys`,
    };
  }

  if (!(await isAddonEnabled(row.orgId, MCP_ADDON))) {
    return {
      ok: false,
      status: 403,
      error: `The Checklist MCP add-on is not enabled for this organization. It is included from the Solo plan and up — manage your plan at ${APP_URL}/settings/billing`,
    };
  }

  // Per-key HTTP rate limit (débit) — after the key is known, before the quota
  // UPDATE, so a looping client's overflow requests are cut here and never hit
  // the hot `organization` row. 429 with Retry-After (seconds).
  if (opts?.checkKeyRate) {
    const rl = opts.checkKeyRate(row.id);
    if (!rl.ok) {
      return {
        ok: false,
        status: 429,
        error: `Too many requests — slow down and retry in ${rl.retryAfterSec}s.`,
        retryAfterSec: rl.retryAfterSec,
      };
    }
  }

  // Monthly quota — read-before-serve at the single chokepoint (ELEVA-110).
  // Over quota → 403 with the same JSON-error mechanism as the add-on gate.
  try {
    await assertMcpQuota(row.orgId);
  } catch (err) {
    if (err instanceof McpQuotaError) {
      // Enrich the generic quota error with the live used/limit + reset month +
      // billing link (read-only snapshot; the atomic gate above already ran).
      const snapshot = await getOrgMcpQuotaSnapshot(row.orgId);
      return { ok: false, status: 403, error: quotaExceededMessage(snapshot) };
    }
    throw err;
  }

  // Best-effort PER-KEY usage bump (cumulative count, monthly count with
  // month rollover, lastUsedAt) — fire-and-forget, never blocks or fails the
  // request. Analytics/display only (QW2-B): the actual quota gate above
  // (assertMcpQuota) already checked-and-incremented the authoritative
  // org-level counter atomically; this one is purely for the per-key
  // breakdown and can lag/drift without any security consequence.
  const month = currentMonth();
  void db
    .update(mcpApiKeys)
    .set({
      lastUsedAt: new Date(),
      requestCount: sql`${mcpApiKeys.requestCount} + 1`,
      monthlyCount: sql`CASE WHEN ${mcpApiKeys.requestMonth} = ${month} THEN ${mcpApiKeys.monthlyCount} + 1 ELSE 1 END`,
      requestMonth: month,
    })
    .where(eq(mcpApiKeys.id, row.id))
    .catch(() => {});

  return { ok: true, orgId: row.orgId, keyId: row.id };
}
