import { createDb, eq, organizations } from '@bmm/db';
import type { FastifyInstance } from 'fastify';
import type Stripe from 'stripe';
import { z } from 'zod';
import { config } from '../config.js';
import { audit } from '../lib/audit.js';
import {
  type PriceTier,
  clearProcessedEvent,
  isDuplicateEvent,
  planFromPriceId,
  priceIdForTier,
  stripe,
} from '../lib/stripe.js';
import { requireAuth } from '../plugins/session.js';

const db = createDb();

const TierBody = z.object({
  tier: z.enum(['pro_monthly', 'pro_yearly', 'team_monthly', 'team_yearly']),
});

export async function billingRoutes(app: FastifyInstance): Promise<void> {
  // ─── Checkout ────────────────────────────────────────────────────────────
  app.post('/v1/billing/checkout-session', { preHandler: requireAuth }, async (req, reply) => {
    if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
    const user = req.user!;
    const parsed = TierBody.safeParse(req.body);
    if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });

    const priceId = priceIdForTier(parsed.data.tier as PriceTier);
    if (!priceId) {
      return reply.code(503).send({ error: 'price_not_configured', tier: parsed.data.tier });
    }

    const [org] = await db
      .select({ stripeCustomerId: organizations.stripeCustomerId })
      .from(organizations)
      .where(eq(organizations.id, user.orgId))
      .limit(1);
    if (!org) return reply.code(404).send({ error: 'org_not_found' });

    try {
      const session = await stripe.checkout.sessions.create({
        // Embedded UI: the payment form mounts INSIDE our dashboard via Stripe.js
        // instead of redirecting to checkout.stripe.com. Keeps the flow in-app
        // (critical for the installed PWA, which otherwise pops out to the
        // system browser). Embedded mode uses return_url, not success/cancel_url.
        // NOTE: stripe-node v22 / API 2025-10 renamed this enum 'embedded' →
        // 'embedded_page'; it returns a client_secret for @stripe/react-stripe-js
        // EmbeddedCheckout. ('hosted' is now 'hosted_page'.)
        ui_mode: 'embedded_page',
        mode: 'subscription',
        payment_method_types: ['card', 'sepa_debit'],
        line_items: [{ price: priceId, quantity: 1 }],
        // Reuse Stripe customer if we have one — keeps invoices on one account
        // even when the user upgrades/downgrades repeatedly.
        ...(org.stripeCustomerId
          ? { customer: org.stripeCustomerId }
          : { customer_email: user.email ?? undefined }),
        client_reference_id: user.orgId,
        metadata: { orgId: user.orgId, userId: user.userId, tier: parsed.data.tier },
        subscription_data: {
          metadata: { orgId: user.orgId, userId: user.userId },
        },
        return_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`,
        automatic_tax: { enabled: true },
        tax_id_collection: { enabled: true },
        billing_address_collection: 'required',
        allow_promotion_codes: true,
      });

      await audit({
        orgId: user.orgId,
        userId: user.userId,
        action: 'billing.checkout_initiated',
        resourceType: 'subscription',
        metadata: { tier: parsed.data.tier },
        ipAddress: req.ip,
      });

      // client_secret drives the embedded form; sessionId for optional verification.
      return reply.send({ clientSecret: session.client_secret, sessionId: session.id });
    } catch (err) {
      app.log.error({ err }, 'checkout session create failed');
      const msg = err instanceof Error ? err.message : 'unknown_error';
      return reply.code(502).send({ error: 'checkout_failed', detail: msg });
    }
  });

  // ─── Customer Portal ─────────────────────────────────────────────────────
  app.post('/v1/billing/portal', { preHandler: requireAuth }, async (req, reply) => {
    if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
    const user = req.user!;
    const [org] = await db
      .select({ stripeCustomerId: organizations.stripeCustomerId })
      .from(organizations)
      .where(eq(organizations.id, user.orgId))
      .limit(1);
    if (!org?.stripeCustomerId) {
      return reply.code(409).send({
        error: 'no_customer_yet',
        detail: 'Subscribe first to access the billing portal.',
      });
    }

    try {
      const session = await stripe.billingPortal.sessions.create({
        customer: org.stripeCustomerId,
        return_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing`,
      });
      return reply.send({ url: session.url });
    } catch (err) {
      app.log.error({ err }, 'portal session create failed');
      return reply.code(502).send({ error: 'portal_failed' });
    }
  });

  // ─── Billing status — drives the /settings/billing UI ────────────────────
  // Combines our DB state (plan, suspension) with a live Stripe lookup of the
  // subscription + recent invoices, so the page can render cancel buttons +
  // invoice links inline without a second round-trip.
  app.get('/v1/billing/status', { preHandler: requireAuth }, async (req, reply) => {
    const user = req.user!;
    const [org] = await db
      .select({
        plan: organizations.plan,
        stripeCustomerId: organizations.stripeCustomerId,
        stripeSubscriptionId: organizations.stripeSubscriptionId,
        suspended: organizations.suspended,
        suspendedReason: organizations.suspendedReason,
      })
      .from(organizations)
      .where(eq(organizations.id, user.orgId))
      .limit(1);
    if (!org) return reply.code(404).send({ error: 'org_not_found' });

    const base = {
      plan: org.plan,
      hasCustomer: Boolean(org.stripeCustomerId),
      hasSubscription: Boolean(org.stripeSubscriptionId),
      suspended: org.suspended,
      suspendedReason: org.suspendedReason,
    };

    if (!stripe || !org.stripeSubscriptionId || !org.stripeCustomerId) {
      return reply.send(base);
    }

    try {
      const [sub, invoices] = await Promise.all([
        stripe.subscriptions.retrieve(org.stripeSubscriptionId),
        stripe.invoices.list({ customer: org.stripeCustomerId, limit: 5 }),
      ]);
      const item = sub.items.data[0];
      const price = item?.price;
      // Stripe v2024-09 moved period boundaries onto subscription items;
      // for our single-item subs they're equivalent to the old sub-level field.
      const currentPeriodEnd = item?.current_period_end ?? 0;
      return reply.send({
        ...base,
        subscription: {
          id: sub.id,
          status: sub.status,
          currentPeriodEnd,
          cancelAtPeriodEnd: sub.cancel_at_period_end,
          priceId: price?.id ?? null,
          amount: price?.unit_amount ?? null,
          currency: price?.currency ?? null,
          interval: price?.recurring?.interval ?? null,
        },
        invoices: invoices.data.map((inv) => ({
          id: inv.id,
          number: inv.number,
          status: inv.status,
          amountPaid: inv.amount_paid,
          currency: inv.currency,
          created: inv.created,
          pdfUrl: inv.invoice_pdf,
          hostedUrl: inv.hosted_invoice_url,
        })),
      });
    } catch (err) {
      app.log.warn({ err }, 'stripe status fetch failed — returning db-only');
      return reply.send({ ...base, _stripeError: true });
    }
  });

  // ─── In-app cancellation (no portal redirect) ────────────────────────────
  // Schedules cancellation at period end — user keeps paid features until the
  // billing date already paid for, and Stripe automatically deletes the sub
  // after that. The webhook handler converts that to plan='hobby'.
  app.post('/v1/billing/cancel', { preHandler: requireAuth }, async (req, reply) => {
    if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
    const user = req.user!;
    const [org] = await db
      .select({ stripeSubscriptionId: organizations.stripeSubscriptionId })
      .from(organizations)
      .where(eq(organizations.id, user.orgId))
      .limit(1);
    if (!org?.stripeSubscriptionId) {
      return reply.code(409).send({ error: 'no_active_subscription' });
    }
    try {
      const sub = await stripe.subscriptions.update(org.stripeSubscriptionId, {
        cancel_at_period_end: true,
      });
      const cancelAt = sub.items.data[0]?.current_period_end ?? null;
      await audit({
        orgId: user.orgId,
        userId: user.userId,
        action: 'billing.cancel_scheduled',
        resourceType: 'subscription',
        resourceId: org.stripeSubscriptionId,
        metadata: { cancelAt },
        ipAddress: req.ip,
      });
      return reply.send({ ok: true, cancelAt });
    } catch (err) {
      app.log.error({ err }, 'cancel failed');
      return reply.code(502).send({ error: 'cancel_failed' });
    }
  });

  // ─── Reactivate (undo scheduled cancellation) ────────────────────────────
  app.post('/v1/billing/reactivate', { preHandler: requireAuth }, async (req, reply) => {
    if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
    const user = req.user!;
    const [org] = await db
      .select({ stripeSubscriptionId: organizations.stripeSubscriptionId })
      .from(organizations)
      .where(eq(organizations.id, user.orgId))
      .limit(1);
    if (!org?.stripeSubscriptionId) {
      return reply.code(409).send({ error: 'no_active_subscription' });
    }
    try {
      await stripe.subscriptions.update(org.stripeSubscriptionId, {
        cancel_at_period_end: false,
      });
      await audit({
        orgId: user.orgId,
        userId: user.userId,
        action: 'billing.reactivated',
        resourceType: 'subscription',
        resourceId: org.stripeSubscriptionId,
        ipAddress: req.ip,
      });
      return reply.send({ ok: true });
    } catch (err) {
      app.log.error({ err }, 'reactivate failed');
      return reply.code(502).send({ error: 'reactivate_failed' });
    }
  });

  // ─── In-app plan change (upgrade/downgrade between Pro/Team monthly/yearly)
  app.post('/v1/billing/change-plan', { preHandler: requireAuth }, async (req, reply) => {
    if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
    const user = req.user!;
    const parsed = TierBody.safeParse(req.body);
    if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });

    const newPriceId = priceIdForTier(parsed.data.tier as PriceTier);
    if (!newPriceId) {
      return reply.code(503).send({ error: 'price_not_configured', tier: parsed.data.tier });
    }

    const [org] = await db
      .select({ stripeSubscriptionId: organizations.stripeSubscriptionId })
      .from(organizations)
      .where(eq(organizations.id, user.orgId))
      .limit(1);
    if (!org?.stripeSubscriptionId) {
      return reply.code(409).send({ error: 'no_active_subscription' });
    }
    try {
      const current = await stripe.subscriptions.retrieve(org.stripeSubscriptionId);
      const itemId = current.items.data[0]?.id;
      if (!itemId) return reply.code(500).send({ error: 'subscription_item_missing' });
      await stripe.subscriptions.update(org.stripeSubscriptionId, {
        items: [{ id: itemId, price: newPriceId }],
        proration_behavior: 'create_prorations',
      });
      // Reconcile the local plan immediately instead of waiting for the
      // customer.subscription.updated webhook — otherwise quota enforcement
      // reads a stale tier in the gap between this call and webhook delivery.
      // Idempotent: the webhook will set the same value. (BILL-001)
      await db
        .update(organizations)
        .set({ plan: planFromPriceId(newPriceId) })
        .where(eq(organizations.id, user.orgId));
      await audit({
        orgId: user.orgId,
        userId: user.userId,
        action: 'billing.plan_changed',
        resourceType: 'subscription',
        resourceId: org.stripeSubscriptionId,
        metadata: { tier: parsed.data.tier },
        ipAddress: req.ip,
      });
      return reply.send({ ok: true });
    } catch (err) {
      app.log.error({ err }, 'plan change failed');
      return reply.code(502).send({ error: 'plan_change_failed' });
    }
  });

  // ─── Webhook ─────────────────────────────────────────────────────────────
  // Stripe signs the raw body — our index.ts content parser stashes the
  // buffer on req.rawBody before JSON-parsing it for normal handlers.
  app.post('/v1/billing/webhook', async (req, reply) => {
    if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
    if (!config.STRIPE_WEBHOOK_SECRET) {
      app.log.error('webhook called without STRIPE_WEBHOOK_SECRET configured');
      return reply.code(503).send({ error: 'webhook_not_configured' });
    }
    const signature = req.headers['stripe-signature'];
    if (typeof signature !== 'string') {
      return reply.code(400).send({ error: 'no_signature' });
    }
    const rawBody = (req as { rawBody?: Buffer }).rawBody;
    if (!rawBody) {
      app.log.error('webhook called without rawBody — content parser missing');
      return reply.code(500).send({ error: 'no_raw_body' });
    }

    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(
        rawBody,
        signature,
        config.STRIPE_WEBHOOK_SECRET,
      );
    } catch (err) {
      app.log.warn({ err }, 'webhook signature verify failed');
      return reply.code(400).send({ error: 'bad_signature' });
    }

    if (await isDuplicateEvent(event.id)) {
      app.log.info({ eventId: event.id, type: event.type }, 'webhook duplicate, skipped');
      return reply.send({ ok: true, deduped: true });
    }

    try {
      await handleStripeEvent(app, event);
      return reply.send({ ok: true });
    } catch (err) {
      // Roll back the idempotency marker so the retry actually re-runs the
      // handler instead of being skipped as a duplicate. Handlers are
      // idempotent (they SET state, not increment), so a rare double-process
      // on concurrent retries is safe. (BILL-003)
      await clearProcessedEvent(event.id);
      // Return 5xx so Stripe retries with exponential backoff.
      app.log.error(
        { err, eventId: event.id, type: event.type },
        'webhook handler failed — Stripe will retry',
      );
      return reply.code(500).send({ error: 'handler_failed' });
    }
  });
}

// ─── Event dispatch ──────────────────────────────────────────────────────────

async function handleStripeEvent(app: FastifyInstance, event: Stripe.Event): Promise<void> {
  switch (event.type) {
    case 'checkout.session.completed':
      await handleCheckoutCompleted(app, event.data.object as Stripe.Checkout.Session);
      break;
    case 'customer.subscription.created':
    case 'customer.subscription.updated':
      await handleSubscriptionChange(app, event.data.object as Stripe.Subscription);
      break;
    case 'customer.subscription.deleted':
      await handleSubscriptionDeleted(app, event.data.object as Stripe.Subscription);
      break;
    case 'invoice.paid':
      await handleInvoicePaid(app, event.data.object as Stripe.Invoice);
      break;
    case 'invoice.payment_failed':
      await handlePaymentFailed(app, event.data.object as Stripe.Invoice);
      break;
    default:
      app.log.debug({ type: event.type }, 'unhandled stripe event type');
  }
}

async function findOrgIdForSubscription(sub: Stripe.Subscription): Promise<string | null> {
  // Prefer the metadata we set at checkout — but DON'T blindly trust it. A
  // webhook signature proves the event came from Stripe, not that
  // sub.metadata.orgId is honest (metadata is editable in the dashboard/portal).
  // Only honour the metadata orgId if the subscription's customer actually
  // matches that org's stored stripeCustomerId; otherwise fall back to the
  // customer lookup. This prevents a sub with a forged metadata.orgId from
  // re-planning a victim org. (BILL-004)
  const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
  const metaOrgId = sub.metadata?.orgId;
  if (typeof metaOrgId === 'string' && metaOrgId.length > 0) {
    const [byMeta] = await db
      .select({ id: organizations.id, customer: organizations.stripeCustomerId })
      .from(organizations)
      .where(eq(organizations.id, metaOrgId))
      .limit(1);
    if (byMeta && (byMeta.customer === null || byMeta.customer === customerId)) {
      return byMeta.id;
    }
    // metadata orgId does not own this customer — ignore it and fall through.
  }
  const [row] = await db
    .select({ id: organizations.id })
    .from(organizations)
    .where(eq(organizations.stripeCustomerId, customerId))
    .limit(1);
  return row?.id ?? null;
}

async function findOrgIdForInvoice(invoice: Stripe.Invoice): Promise<string | null> {
  const customerId =
    typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
  if (!customerId) return null;
  const [row] = await db
    .select({ id: organizations.id })
    .from(organizations)
    .where(eq(organizations.stripeCustomerId, customerId))
    .limit(1);
  return row?.id ?? null;
}

async function handleCheckoutCompleted(
  app: FastifyInstance,
  session: Stripe.Checkout.Session,
): Promise<void> {
  const orgId = session.metadata?.orgId ?? session.client_reference_id ?? null;
  if (!orgId) {
    app.log.warn({ sessionId: session.id }, 'checkout completed without orgId');
    return;
  }
  const customerId =
    typeof session.customer === 'string' ? session.customer : session.customer?.id;
  if (!customerId) return;
  await db
    .update(organizations)
    .set({ stripeCustomerId: customerId })
    .where(eq(organizations.id, orgId));
  await audit({
    orgId,
    action: 'billing.checkout_completed',
    resourceType: 'subscription',
    metadata: { customerId, sessionId: session.id },
  });
}

async function handleSubscriptionChange(
  app: FastifyInstance,
  sub: Stripe.Subscription,
): Promise<void> {
  const orgId = await findOrgIdForSubscription(sub);
  if (!orgId) {
    app.log.warn({ subId: sub.id, customer: sub.customer }, 'sub change for unknown org');
    return;
  }
  const priceId = sub.items.data[0]?.price.id;
  const plan = planFromPriceId(priceId);
  const active = sub.status === 'active' || sub.status === 'trialing';
  const suspended = sub.status === 'past_due' || sub.status === 'unpaid';

  await db
    .update(organizations)
    .set({
      plan: active ? plan : 'hobby',
      stripeSubscriptionId: sub.id,
      suspended,
      suspendedReason: suspended ? `subscription_${sub.status}` : null,
    })
    .where(eq(organizations.id, orgId));

  await audit({
    orgId,
    action: 'billing.subscription_changed',
    resourceType: 'subscription',
    metadata: { plan, status: sub.status, subId: sub.id, priceId: priceId ?? null },
  });
}

async function handleSubscriptionDeleted(
  app: FastifyInstance,
  sub: Stripe.Subscription,
): Promise<void> {
  const orgId = await findOrgIdForSubscription(sub);
  if (!orgId) {
    app.log.warn({ subId: sub.id }, 'sub delete for unknown org');
    return;
  }
  await db
    .update(organizations)
    .set({
      plan: 'hobby',
      stripeSubscriptionId: null,
      suspended: false,
      suspendedReason: null,
    })
    .where(eq(organizations.id, orgId));
  await audit({
    orgId,
    action: 'billing.subscription_cancelled',
    resourceType: 'subscription',
    metadata: { subId: sub.id },
  });
}

async function handleInvoicePaid(_app: FastifyInstance, invoice: Stripe.Invoice): Promise<void> {
  const orgId = await findOrgIdForInvoice(invoice);
  if (!orgId) return;
  // Only the actual monthly renewal (`subscription_cycle`) resets the usage
  // counter. Stripe also sends `invoice.paid` for proration/manual/one-off
  // invoices (e.g. every plan up/downgrade); resetting on those would let a
  // user zero their call quota on demand by churning plan changes. For
  // non-cycle invoices we only clear a past-due suspension. (BILL-002)
  const isRenewal = invoice.billing_reason === 'subscription_cycle';
  await db
    .update(organizations)
    .set({
      suspended: false,
      suspendedReason: null,
      ...(isRenewal ? { callsThisPeriod: 0, periodStartsAt: new Date() } : {}),
    })
    .where(eq(organizations.id, orgId));
  await audit({
    orgId,
    action: 'billing.invoice_paid',
    resourceType: 'invoice',
    metadata: { invoiceId: invoice.id ?? null, amountPaid: invoice.amount_paid ?? 0 },
  });
}

async function handlePaymentFailed(
  _app: FastifyInstance,
  invoice: Stripe.Invoice,
): Promise<void> {
  const orgId = await findOrgIdForInvoice(invoice);
  if (!orgId) return;
  const attempts = invoice.attempt_count ?? 0;
  // Only suspend after the 3rd failed attempt — Stripe Smart Retries will keep
  // trying for several days, so the user has time to update their card.
  if (attempts >= 3) {
    await db
      .update(organizations)
      .set({ suspended: true, suspendedReason: 'payment_failed' })
      .where(eq(organizations.id, orgId));
  }
  await audit({
    orgId,
    action: 'billing.payment_failed',
    resourceType: 'invoice',
    metadata: { invoiceId: invoice.id ?? null, attempts },
  });
}
