// src/lib/admin/billing-actions.ts — God-Mode Stripe supervision mutations
// (Slice 4). Each action follows the guarded pattern: assertSysPermission
// ("billing:manage") → zod-validate → load target → mutate → audit →
// revalidate. Gated per Task 9 (spec §4): Admin SYS always, Staff SYS if
// granted — the 2FA wall lives inside the gate itself, so there is no separate
// 2FA check to stack on top. The Stripe SDK is reached ONLY through
// getStripe() (a single mock-friendly seam that throws when Stripe isn't
// configured), so every Stripe-API call degrades cleanly when the key is
// missing. The two app-level gestures (extend trial, grant complimentary)
// need NO Stripe write and keep working even when Stripe is off.
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath, updateTag } from "next/cache";
import { z } from "zod";
import { assertSysPermission } from "@/lib/auth/sys-permissions";
import { getPlan } from "@/lib/billing/plans";
import { orgLimitsTag } from "@/lib/billing/quotas";
import { syncOrgSeatsLimit } from "@/lib/billing/seats-limit";
import { ACTIVE_SUB_STATUSES, orgBillingTag } from "@/lib/billing/status";
import { getStripe, isStripeConfigured } from "@/lib/billing/stripe-client";
import { syncSubscriptionRow } from "@/lib/billing/subscription-sync";
import { organization, subscription } from "@/lib/db/auth-schema";
import { db } from "@/lib/db/client";
import { recordAdminAction } from "./audit";
import { setOrgLimitOverrideAction } from "./org-actions";

const idSchema = z.string().min(1);
const planSchema = z.enum(["solo", "starter", "pro", "agency"]);

// Every action below writes trialEndsAt, subscription.status/plan, or forces a
// Stripe resync — any of which can shift getOrgBillingInfo/getOrgQuotaLimits
// (both unstable_cache'd, QW2-A). Invalidate both tags alongside the existing
// path revalidation so God-Mode changes take effect immediately instead of
// waiting out the 30-60s TTL. These actions are all "use server" Server
// Actions → updateTag (immediate, read-your-own-writes), not revalidateTag.
async function revalidateBillingPaths(orgId: string): Promise<void> {
  revalidatePath("/admin/billing");
  revalidatePath("/admin/orgs");
  revalidatePath(`/admin/orgs/${orgId}`);
  updateTag(orgBillingTag(orgId));
  updateTag(orgLimitsTag(orgId));
  // Sec audit A2 backstop: every action below can shift the resolved seats
  // ceiling (trial extension, coupon/plan resync, complimentary grant) — keep
  // organization.seats_limit (the `member`-insert trigger's source) fresh
  // alongside the read-path cache tags above. Grouped here rather than
  // per-action for the same reason the tags are: simplicity over precisely
  // targeting which of the four actually moved the limit (applyCoupon
  // usually doesn't — belt and suspenders costs one extra write).
  await syncOrgSeatsLimit(orgId);
}

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

/** The org's relevant Stripe-linked subscription row (prefer an access-granting
 *  one), or undefined when none carries a Stripe subscription id. */
async function loadStripeSubscription(orgId: string): Promise<
  | {
      id: string;
      status: string | null;
      stripeSubscriptionId: string;
    }
  | undefined
> {
  const rows = await db
    .select({
      id: subscription.id,
      status: subscription.status,
      stripeSubscriptionId: subscription.stripeSubscriptionId,
    })
    .from(subscription)
    .where(eq(subscription.referenceId, orgId));
  const linked = rows.filter(
    (r): r is typeof r & { stripeSubscriptionId: string } =>
      Boolean(r.stripeSubscriptionId),
  );
  return (
    linked.find((r) => r.status && ACTIVE_SUB_STATUSES.has(r.status)) ??
    linked[0]
  );
}

/**
 * Force-sync the org's subscription from Stripe — the "a webhook failed" button.
 * Retrieves the live Stripe.Subscription and re-applies it to the local row via
 * syncSubscriptionRow (the SAME mapping the webhook uses — no duplication).
 * Refuses cleanly when Stripe isn't configured or no Stripe-linked sub exists.
 * Audited `billing.resync` with {subscriptionId, newStatus}.
 */
export async function resyncOrgSubscriptionAction(
  orgId: string,
): Promise<void> {
  await assertSysPermission("billing:manage");
  const id = idSchema.parse(orgId);
  if (!isStripeConfigured()) throw new Error("Stripe is not configured");
  const org = await loadOrg(id);
  const sub = await loadStripeSubscription(id);
  if (!sub) throw new Error("No Stripe-linked subscription for this org");

  const stripe = getStripe();
  const fresh = await stripe.subscriptions.retrieve(sub.stripeSubscriptionId);
  await syncSubscriptionRow(fresh);

  await recordAdminAction({
    action: "billing.resync",
    targetType: "subscription",
    targetId: sub.stripeSubscriptionId,
    targetLabel: org.name,
    metadata: {
      subscriptionId: sub.stripeSubscriptionId,
      newStatus: fresh.status,
    },
  });
  await revalidateBillingPaths(id);
}

const extendTrialSchema = z.union([
  z.object({ days: z.number().int().positive().max(3650) }),
  z.object({ until: z.coerce.date() }),
]);

export type ExtendTrialInput = z.input<typeof extendTrialSchema>;

/**
 * Extend the org's app-level trial. `{ days }` adds days onto the later of now
 * and the current trial end (so it always extends, never shortens); `{ until }`
 * sets an explicit date. The result must be in the future. Audited
 * `billing.extend_trial` with {oldTrialEndsAt, newTrialEndsAt}.
 */
export async function extendOrgTrialAction(
  orgId: string,
  input: ExtendTrialInput,
): Promise<void> {
  await assertSysPermission("billing:manage");
  const id = idSchema.parse(orgId);
  const parsed = extendTrialSchema.parse(input);
  const org = await loadOrg(id);

  const now = new Date();
  let next: Date;
  if ("days" in parsed) {
    const base =
      org.trialEndsAt && org.trialEndsAt.getTime() > now.getTime()
        ? org.trialEndsAt
        : now;
    next = new Date(base.getTime() + parsed.days * 24 * 60 * 60 * 1000);
  } else {
    next = parsed.until;
  }
  if (next.getTime() <= now.getTime()) {
    throw new Error("New trial end must be in the future");
  }

  await db.transaction(async (tx) => {
    await tx
      .update(organization)
      .set({ trialEndsAt: next })
      .where(eq(organization.id, id));
    await recordAdminAction(
      {
        action: "billing.extend_trial",
        targetType: "organization",
        targetId: id,
        targetLabel: org.name,
        metadata: {
          oldTrialEndsAt: org.trialEndsAt?.toISOString() ?? null,
          newTrialEndsAt: next.toISOString(),
        },
      },
      tx,
    );
  });
  await revalidateBillingPaths(id);
}

/**
 * Apply an EXISTING Stripe coupon to the org's active subscription (preferred)
 * or, failing that, to its Stripe customer. The coupon must already exist in
 * Stripe — this only attaches it. Refuses cleanly when Stripe is off or the org
 * has neither an active sub nor a customer. Audited `billing.apply_coupon`
 * with {couponId, appliedTo}.
 */
export async function applyCouponAction(
  orgId: string,
  couponId: string,
): Promise<void> {
  await assertSysPermission("billing:manage");
  const id = idSchema.parse(orgId);
  const coupon = z.string().trim().min(1).parse(couponId);
  if (!isStripeConfigured()) throw new Error("Stripe is not configured");
  const org = await loadOrg(id);
  const sub = await loadStripeSubscription(id);

  const stripe = getStripe();
  let appliedTo: "subscription" | "customer";
  if (sub) {
    // Stripe 22 dropped the top-level `coupon` field on subscriptions — a
    // coupon is redeemed into a discount via `discounts: [{ coupon }]`.
    await stripe.subscriptions.update(sub.stripeSubscriptionId, {
      discounts: [{ coupon }],
    });
    appliedTo = "subscription";
  } else if (org.stripeCustomerId) {
    // CustomerUpdateParams no longer types `coupon`, but the REST API still
    // accepts it (redeems into a customer-level discount) — cast through the
    // method's own param type so we stay type-safe at the call site.
    await stripe.customers.update(org.stripeCustomerId, {
      coupon,
    } as unknown as Parameters<typeof stripe.customers.update>[1]);
    appliedTo = "customer";
  } else {
    throw new Error("Org has no active subscription or Stripe customer");
  }

  await recordAdminAction({
    action: "billing.apply_coupon",
    targetType: "organization",
    targetId: id,
    targetLabel: org.name,
    metadata: { couponId: coupon, appliedTo },
  });
  await revalidateBillingPaths(id);
}

const grantCompSchema = z.object({
  plan: planSchema.optional(),
  until: z.coerce.date(),
  note: z.string().trim().min(1).max(500),
});

export type GrantComplimentaryInput = z.input<typeof grantCompSchema>;

/**
 * App-level "complimentary / enterprise free" gesture — INDEPENDENT of Stripe
 * (no Stripe write, works when billing is off). It (a) extends trialEndsAt to
 * `until` (the billing-status layer then grants active access) and (b), when a
 * `plan` is given, writes that plan's limits as an org_limit_override expiring
 * at the same date — reusing setOrgLimitOverrideAction (the Slice-3 helper) so
 * the override mapping is never duplicated. Audited `billing.grant_comp` with
 * {plan, until, note}; the note is also persisted on the override row.
 *
 * Ordering note: the plan-override branch runs FIRST (its own gate + its own
 * db.transaction pairing the upsert with `org.set_limit_override`), THEN this
 * action's own trialEndsAt write + `billing.grant_comp` audit run together in
 * ONE transaction — so this action's primary mutation is still atomically
 * paired with its own audit entry, while `billing.grant_comp` stays the
 * chronologically LAST audit row for a composite grant (matches the existing
 * test expectations).
 */
export async function grantComplimentaryAction(
  orgId: string,
  input: GrantComplimentaryInput,
): Promise<void> {
  await assertSysPermission("billing:manage");
  const id = idSchema.parse(orgId);
  const parsed = grantCompSchema.parse(input);
  const now = new Date();
  if (parsed.until.getTime() <= now.getTime()) {
    throw new Error("Complimentary access must end in the future");
  }
  const org = await loadOrg(id);

  if (parsed.plan) {
    const limits = getPlan(parsed.plan).limits;
    // Reuse the Slice-3 override upsert (re-asserts orgs:limits + audits its
    // own change in its own transaction).
    await setOrgLimitOverrideAction(id, {
      ...limits,
      note: parsed.note,
      expiresAt: parsed.until,
    });
  }

  await db.transaction(async (tx) => {
    await tx
      .update(organization)
      .set({ trialEndsAt: parsed.until })
      .where(eq(organization.id, id));
    await recordAdminAction(
      {
        action: "billing.grant_comp",
        targetType: "organization",
        targetId: id,
        targetLabel: org.name,
        metadata: {
          plan: parsed.plan ?? null,
          until: parsed.until.toISOString(),
          note: parsed.note,
        },
      },
      tx,
    );
  });
  await revalidateBillingPaths(id);
}
