// src/lib/admin/org-actions.ts — God-Mode (super-admin only) organization /
// multi-tenancy mutations.
//
// ORG-PLUGIN vs DIRECT-DB DECISION (verified against /docs/plugins/organization):
//   • The org plugin has NO "transfer ownership" endpoint at all.
//   • `updateMemberRole` / `removeMember` ARE exposed, but they are MEMBER-SCOPED:
//     the plugin authorises the call against the *caller's* membership/permission
//     in that org. A super-admin acting globally is NOT a member of an arbitrary
//     target org, so those plugin calls would be rejected (or silently act on the
//     caller's active org instead).
//   → Every mutation here is therefore a DIRECT, transactional DB write keyed on
//     (organizationId, userId) — the same precedent as resetUserTwoFactorAction.
//
// Pattern (mirrors src/lib/admin/actions.ts, Task 8): assertSysPermission(perm)
// → validate (zod) → load target → mutate + recordAdminAction(…, tx) in ONE
// db.transaction → revalidate the org surfaces. Every gate here maps to the
// "orgs" resource group (spec §4): orgs:members for membership mutations,
// orgs:limits for the override editor. A Staff SYS holding the right grant can
// now reach these — the 2FA wall lives INSIDE assertSysPermission itself, so
// there is no separate 2FA gate to layer on top (Task 9 review note: do not
// stack assertSuperAdminWith2FA on top of assertSysPermission).
"use server";
import { and, eq, inArray, sql } from "drizzle-orm";
import { revalidatePath, updateTag } from "next/cache";
import { z } from "zod";
import {
  assertSysPermission,
  getSysOperatorSession,
} from "@/lib/auth/sys-permissions";
import { orgLimitsTag } from "@/lib/billing/quotas";
import { syncOrgSeatsLimit } from "@/lib/billing/seats-limit";
import { orgLimitOverrides } from "@/lib/db/admin-schema";
import { member, organization, user } from "@/lib/db/auth-schema";
import { db } from "@/lib/db/client";
import { recordAdminAction } from "./audit";

const idSchema = z.string().min(1);
const memberRoleSchema = z.enum(["owner", "admin", "member"]);
// Adding a member never grants `owner` — promote via transferOrgOwnershipAction
// afterwards so the single-owner invariant can never be violated by an add.
const addMemberRoleSchema = z.enum(["admin", "member"]);
const emailSchema = z.string().trim().toLowerCase().pipe(z.string().email());

/** Org-member roles that count as "owner-level" today (only `owner`). Demoted to
 *  `admin` on transfer so there is always exactly one owner. */
const OWNER_ROLE = "owner";

function revalidateOrgPaths(orgId: string): void {
  revalidatePath("/admin/orgs");
  revalidatePath(`/admin/orgs/${orgId}`);
}

/** Load an org row (id + name) or throw. The name is the audit label. */
async function loadOrg(orgId: string): Promise<{ id: string; name: string }> {
  const [org] = await db
    .select({ id: organization.id, name: organization.name })
    .from(organization)
    .where(eq(organization.id, orgId))
    .limit(1);
  if (!org) throw new Error("Organization not found");
  return org;
}

/** Load a single membership (id + role) for (orgId, userId) or throw. */
async function loadMembership(
  orgId: string,
  userId: string,
): Promise<{ id: string; role: string }> {
  const [row] = await db
    .select({ id: member.id, role: member.role })
    .from(member)
    .where(and(eq(member.organizationId, orgId), eq(member.userId, userId)))
    .limit(1);
  if (!row) throw new Error("User is not a member of this organization");
  return row;
}

/**
 * Transfer org ownership. The new owner MUST already be a member. In one
 * transaction: every current `owner` member is demoted to `admin`, then the
 * target member is promoted to `owner` — guaranteeing a single owner.
 * Audited `org.transfer_owner`.
 */
export async function transferOrgOwnershipAction(
  orgId: string,
  newOwnerUserId: string,
): Promise<void> {
  await assertSysPermission("orgs:members");
  const id = idSchema.parse(orgId);
  const newOwnerId = idSchema.parse(newOwnerUserId);
  const org = await loadOrg(id);
  // Validate the target is a member (throws otherwise).
  await loadMembership(id, newOwnerId);

  const currentOwners = await db
    .select({ userId: member.userId })
    .from(member)
    .where(and(eq(member.organizationId, id), eq(member.role, OWNER_ROLE)));
  const oldOwnerUserIds = currentOwners
    .map((o) => o.userId)
    .filter((uid) => uid !== newOwnerId);

  await db.transaction(async (tx) => {
    if (oldOwnerUserIds.length > 0) {
      await tx
        .update(member)
        .set({ role: "admin" })
        .where(
          and(
            eq(member.organizationId, id),
            inArray(member.userId, oldOwnerUserIds),
          ),
        );
    }
    await tx
      .update(member)
      .set({ role: OWNER_ROLE })
      .where(and(eq(member.organizationId, id), eq(member.userId, newOwnerId)));
    await recordAdminAction(
      {
        action: "org.transfer_owner",
        targetType: "organization",
        targetId: id,
        targetLabel: org.name,
        metadata: { oldOwnerUserIds, newOwnerUserId: newOwnerId },
      },
      tx,
    );
  });
  revalidateOrgPaths(id);
}

/**
 * Set a member's role. When the new role is `owner` this routes through the
 * transfer logic (demote existing owners) so the single-owner invariant holds.
 * Otherwise it's a direct role update. Audited `org.set_member_role`.
 */
export async function setMemberRoleAction(
  orgId: string,
  userId: string,
  role: "owner" | "admin" | "member",
): Promise<void> {
  await assertSysPermission("orgs:members");
  const id = idSchema.parse(orgId);
  const uid = idSchema.parse(userId);
  const newRole = memberRoleSchema.parse(role);

  // Promotion to owner = ownership transfer (keeps exactly one owner). Its own
  // gate + transaction (transferOrgOwnershipAction re-asserts orgs:members —
  // harmless redundant check, same operator/session).
  if (newRole === OWNER_ROLE) {
    await transferOrgOwnershipAction(id, uid);
    return;
  }

  const org = await loadOrg(id);
  const membership = await loadMembership(id, uid);

  // Refuse demoting the sole owner — an org must always keep an owner.
  if (membership.role === OWNER_ROLE) {
    const owners = await db
      .select({ userId: member.userId })
      .from(member)
      .where(and(eq(member.organizationId, id), eq(member.role, OWNER_ROLE)));
    if (owners.length <= 1) {
      throw new Error("Cannot demote the sole owner; transfer ownership first");
    }
  }

  await db.transaction(async (tx) => {
    await tx
      .update(member)
      .set({ role: newRole })
      .where(and(eq(member.organizationId, id), eq(member.userId, uid)));
    await recordAdminAction(
      {
        action: "org.set_member_role",
        targetType: "organization",
        targetId: id,
        targetLabel: org.name,
        metadata: { userId: uid, oldRole: membership.role, newRole },
      },
      tx,
    );
  });
  revalidateOrgPaths(id);
}

/**
 * Remove a member from an org. Refuses removing the sole owner (an org must
 * always keep an owner). Direct DB delete + audit `org.remove_member`.
 *
 * NOTE: this does NOT auto-adjust Stripe seats — seat reconciliation stays with
 * billing (the subscription's seat count is managed in the billing surfaces).
 */
export async function removeMemberAction(
  orgId: string,
  userId: string,
): Promise<void> {
  await assertSysPermission("orgs:members");
  const id = idSchema.parse(orgId);
  const uid = idSchema.parse(userId);
  const org = await loadOrg(id);
  const membership = await loadMembership(id, uid);

  if (membership.role === OWNER_ROLE) {
    const owners = await db
      .select({ userId: member.userId })
      .from(member)
      .where(and(eq(member.organizationId, id), eq(member.role, OWNER_ROLE)));
    if (owners.length <= 1) {
      throw new Error("Cannot remove the sole owner; transfer ownership first");
    }
  }

  await db.transaction(async (tx) => {
    await tx
      .delete(member)
      .where(and(eq(member.organizationId, id), eq(member.userId, uid)));
    await recordAdminAction(
      {
        action: "org.remove_member",
        targetType: "organization",
        targetId: id,
        targetLabel: org.name,
        metadata: { userId: uid, role: membership.role },
      },
      tx,
    );
  });
  revalidateOrgPaths(id);
}

/**
 * Force-add an EXISTING user (resolved by email) to an org as `admin` or
 * `member`. Super-admin global action — a direct, fail-closed DB insert (same
 * rationale as the other org mutations: the org plugin's addMember is
 * member-scoped to the caller). Refuses an unknown email or an existing member;
 * never grants `owner` (use transfer for that). Audited `org.add_member`.
 *
 * NOTE: like removeMember, this does NOT reconcile Stripe seats — seat counts
 * stay with the billing surfaces.
 */
export async function addMemberAction(
  orgId: string,
  email: string,
  role: "admin" | "member",
): Promise<void> {
  await assertSysPermission("orgs:members");
  const id = idSchema.parse(orgId);
  const targetEmail = emailSchema.parse(email);
  const newRole = addMemberRoleSchema.parse(role);
  const org = await loadOrg(id);

  // Resolve the user by email (case-insensitive). Throws if no such user.
  const [u] = await db
    .select({ id: user.id, email: user.email, role: user.role })
    .from(user)
    .where(eq(sql`lower(${user.email})`, targetEmail))
    .limit(1);
  if (!u) throw new Error("No user found with that email");

  // SYS population is orgless by construction (spec §2.bis): a Staff SYS
  // account (user.role === "staff") must never become a customer-org member —
  // that would give it BOTH a system-permission grant AND an org-level role,
  // blurring the two planes this whole refactor exists to separate. Refuse
  // explicitly rather than silently creating a hybrid identity.
  if (u.role === "staff") {
    throw new Error("Cannot add a SYS staff account to an organization");
  }

  // Refuse if already a member of this org.
  const [existing] = await db
    .select({ id: member.id })
    .from(member)
    .where(and(eq(member.organizationId, id), eq(member.userId, u.id)))
    .limit(1);
  if (existing) {
    throw new Error("User is already a member of this organization");
  }

  await db.transaction(async (tx) => {
    await tx.insert(member).values({
      id: crypto.randomUUID(),
      organizationId: id,
      userId: u.id,
      role: newRole,
      createdAt: new Date(),
    });
    await recordAdminAction(
      {
        action: "org.add_member",
        targetType: "organization",
        targetId: id,
        targetLabel: org.name,
        metadata: { userId: u.id, email: u.email, role: newRole },
      },
      tx,
    );
  });
  revalidateOrgPaths(id);
}

// ─── Limit overrides ─────────────────────────────────────────────────────────

// Each limit: integer ≥ -1 (-1 = unlimited), or null to clear that field.
const limitField = z.number().int().min(-1).nullable().optional();
const overrideInputSchema = z.object({
  keywords: limitField,
  projects: limitField,
  seats: limitField,
  integrations: limitField,
  backlinkRows: limitField,
  mcpMonthly: limitField,
  note: z.string().trim().max(500).nullable().optional(),
  expiresAt: z.date().nullable().optional(),
});

export type OrgLimitOverrideInput = z.input<typeof overrideInputSchema>;

const LIMIT_KEYS = [
  "keywords",
  "projects",
  "seats",
  "integrations",
  "backlinkRows",
  "mcpMonthly",
] as const;

/**
 * Upsert the org's limit-override row. The input is treated as the FULL desired
 * state: any limit field passed as a number sets it; null/omitted clears it
 * (the override no longer applies to that limit). `note` / `expiresAt` likewise.
 * Audited `org.set_limit_override` with the before→after diff.
 */
export async function setOrgLimitOverrideAction(
  orgId: string,
  input: OrgLimitOverrideInput,
): Promise<void> {
  await assertSysPermission("orgs:limits");
  const id = idSchema.parse(orgId);
  const parsed = overrideInputSchema.parse(input);
  const org = await loadOrg(id);

  // Task 9: a Staff SYS can now hold orgs:limits, so `createdBy` must resolve
  // through the SYS operator session (admin-sys OR staff-sys) — the old
  // super-admin-only session lookup silently recorded `null` for a staff actor.
  const op = await getSysOperatorSession();
  const createdBy = op?.userId ?? null;

  const values = {
    keywords: parsed.keywords ?? null,
    projects: parsed.projects ?? null,
    seats: parsed.seats ?? null,
    integrations: parsed.integrations ?? null,
    backlinkRows: parsed.backlinkRows ?? null,
    mcpMonthly: parsed.mcpMonthly ?? null,
    note: parsed.note ?? null,
    expiresAt: parsed.expiresAt ?? null,
  };

  // Snapshot the previous row for the audit diff.
  const [before] = await db
    .select()
    .from(orgLimitOverrides)
    .where(eq(orgLimitOverrides.organizationId, id))
    .limit(1);

  // Compact diff: only the limit fields that actually changed.
  const diff: Record<string, { from: number | null; to: number | null }> = {};
  for (const key of LIMIT_KEYS) {
    const from = before?.[key] ?? null;
    const to = values[key];
    if (from !== to) diff[key] = { from, to };
  }

  await db.transaction(async (tx) => {
    await tx
      .insert(orgLimitOverrides)
      .values({ organizationId: id, ...values, createdBy })
      .onConflictDoUpdate({
        target: orgLimitOverrides.organizationId,
        set: { ...values, createdBy, updatedAt: new Date() },
      });
    await recordAdminAction(
      {
        action: "org.set_limit_override",
        targetType: "organization",
        targetId: id,
        targetLabel: org.name,
        metadata: {
          diff,
          note: values.note,
          expiresAt: values.expiresAt?.toISOString() ?? null,
        },
      },
      tx,
    );
  });
  revalidateOrgPaths(id);
  // getOrgQuotaLimits (quotas.ts) is unstable_cache'd 60s, tagged per-org —
  // an override write must take effect immediately, not up to a minute later
  // (QW2-A).
  updateTag(orgLimitsTag(id));
  // Sec audit A2 backstop: a seats override changes the ceiling the `member`
  // insert trigger enforces (organization.seats_limit) — keep it fresh.
  await syncOrgSeatsLimit(id);
}

/** Delete the org's limit-override row entirely (revert to pure plan limits).
 *  Audited `org.clear_limit_override`. */
export async function clearOrgLimitOverrideAction(
  orgId: string,
): Promise<void> {
  await assertSysPermission("orgs:limits");
  const id = idSchema.parse(orgId);
  const org = await loadOrg(id);

  await db.transaction(async (tx) => {
    await tx
      .delete(orgLimitOverrides)
      .where(eq(orgLimitOverrides.organizationId, id));
    await recordAdminAction(
      {
        action: "org.clear_limit_override",
        targetType: "organization",
        targetId: id,
        targetLabel: org.name,
      },
      tx,
    );
  });
  revalidateOrgPaths(id);
  updateTag(orgLimitsTag(id));
  // Sec audit A2 backstop: clearing the override can also shift the ceiling
  // (back to the plain plan limit) — resync organization.seats_limit.
  await syncOrgSeatsLimit(id);
}
