const Stripe = require('stripe');
const { getSupabaseServiceClient } = require('../utils/supabase');

let _stripe = null;
function getStripe() {
  if (!_stripe) _stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
  return _stripe;
}

// Session 15 — fallback strings like 'price_analyst_monthly' would
// 400 from Stripe in production (they're not real `price_xxx` IDs).
// All maps now fall back to null; getPriceId then returns the
// PRICE_UNCONFIGURED sentinel for unset values. Founder prices
// additionally fall back to the standard tier price so a user with
// a valid founder code on a deploy that doesn't yet have founder
// prices wired still gets a successful checkout at standard rate.
const PRICE_MAP = {
  analyst: process.env.STRIPE_PRICE_ANALYST || null,
  analyst_founder: process.env.STRIPE_PRICE_ANALYST_FOUNDER || null,
  desk: process.env.STRIPE_PRICE_DESK || null,
  desk_founder: process.env.STRIPE_PRICE_DESK_FOUNDER || null,
  africa: process.env.STRIPE_PRICE_AFRICA || null,
};

// Sentinel marker — getPriceId returns this when the tier is valid
// but the Stripe price hasn't been provisioned yet. The route layer
// checks for it and returns 503 with a friendly message rather than
// passing "null" to Stripe.
const PRICE_UNCONFIGURED = '__unconfigured__';

// VYNDR is the canonical brand promo. BETONBLK stays in the default list so
// codes distributed before the rebrand keep redeeming during the transition.
const VALID_FOUNDER_CODES = (process.env.FOUNDER_CODES || 'FOUNDER2026,VYNDR,BETONBLK,EARLYBIRD').split(',');
// Session 55 — founder pricing is still an active launch lever (the ClaimMeter
// scarcity meter + the $14.99/$34.99 founder tiers advertise it), so the default
// window runs through 2026. The 2026-06-30 default had silently lapsed (current
// date 2026-07-10), disabling every founder code. Operators override via env.
const FOUNDER_EXPIRY = new Date(process.env.FOUNDER_CODE_EXPIRY || '2026-12-31');

function isFounderCodeValid(code) {
  if (!code) return false;
  if (new Date() > FOUNDER_EXPIRY) return false;
  return VALID_FOUNDER_CODES.includes(code.toUpperCase());
}

function getPriceId(tier, founderCode) {
  const isFounder = isFounderCodeValid(founderCode);
  if (tier === 'analyst') {
    // Session 15 — if a valid founder code is presented but the
    // founder price ID isn't wired (env unset), gracefully fall
    // back to the standard analyst price rather than 503'ing the
    // user. The founder discount is operator-controlled; the
    // checkout itself shouldn't break.
    if (isFounder && PRICE_MAP.analyst_founder) return PRICE_MAP.analyst_founder;
    return PRICE_MAP.analyst || PRICE_UNCONFIGURED;
  }
  if (tier === 'desk') {
    if (isFounder && PRICE_MAP.desk_founder) return PRICE_MAP.desk_founder;
    return PRICE_MAP.desk || PRICE_UNCONFIGURED;
  }
  if (tier === 'africa') {
    // Africa tier has no founder discount — it IS the discount.
    return PRICE_MAP.africa || PRICE_UNCONFIGURED;
  }
  throw new Error(`Invalid tier: ${tier}`);
}

async function createCheckoutSession(userId, email, tier, founderCode) {
  const supabase = getSupabaseServiceClient();
  // Item B — the price is SEAT-GATED, not code-gated: while founder seats
  // remain (< FOUNDER_SEATS_TOTAL, same truth as the meter) checkout attaches
  // the founder price automatically; at seat 100 it flips to standard. The old
  // founderCode param is accepted for back-compat but no longer drives price.
  const { priceId, isFounder } = await resolveCheckoutPrice(tier);
  // Session 14 — tier is valid but the upstream Stripe product hasn't been
  // provisioned. Surface a clean 503 with `code: 'tier_unconfigured'`.
  if (priceId === PRICE_UNCONFIGURED) {
    const err = new Error(`Pricing for "${tier}" is not configured yet.`);
    err.code = 'tier_unconfigured';
    err.statusCode = 503;
    throw err;
  }

  // Get or create Stripe customer
  const { data: user } = await supabase
    .from('users')
    .select('stripe_customer_id')
    .eq('id', userId)
    .single();

  let customerId = user?.stripe_customer_id;
  if (!customerId) {
    const customer = await getStripe().customers.create({
      email,
      metadata: { user_id: userId },
    });
    customerId = customer.id;
    await supabase
      .from('users')
      .update({ stripe_customer_id: customerId })
      .eq('id', userId);
  }

  // Stripe sends the user to a FRONTEND URL after checkout — not the
  // Express API. NEXT_PUBLIC_SITE_URL is the canonical frontend origin
  // (defaults to https://vyndr.app per the email templates), with
  // BASE_URL as a fallback for legacy deploys that only set the API
  // origin. localhost:3000 is the Next dev server default; Express
  // dev runs on 3001 so we never want to send users there.
  const frontendUrl = process.env.NEXT_PUBLIC_SITE_URL
    || process.env.BASE_URL
    || 'http://localhost:3000';
  const session = await getStripe().checkout.sessions.create({
    customer: customerId,
    line_items: [{ price: priceId, quantity: 1 }],
    mode: 'subscription',
    success_url: `${frontendUrl}/upgrade/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${frontendUrl}/upgrade/cancel`,
    metadata: { user_id: userId, tier, is_founder: String(isFounder) },
  });

  return { checkout_url: session.url, session_id: session.id };
}

const GRACE_PERIOD_MS = 48 * 60 * 60 * 1000;
// Item B — a payment DECLINE is not a cancellation: Stripe Smart Retries run
// for ~2 weeks (8 retries) before giving up. The grace on a failed invoice must
// span that window so a transient decline doesn't revoke access mid-retry —
// access is revoked only when Stripe actually cancels (customer.subscription
// .deleted, which keeps the 48h grace). If a retry succeeds first, the
// subscription.updated -> active handler clears the grace.
const PAYMENT_RETRY_GRACE_MS = 14 * 24 * 60 * 60 * 1000;

// Mirror writes to `user_profiles` so Next.js-side reads stay in sync. The
// Express side traditionally writes to `users`; user_profiles is the
// Next.js-facing copy. Failures here are logged but don't fail the webhook —
// dropping a mirror write is recoverable, dropping the webhook isn't.
async function mirrorToUserProfile(supabase, userId, patch) {
  if (!userId) return;
  try {
    await supabase.from('user_profiles').update(patch).eq('id', userId);
  } catch (err) {
    console.warn('[VYNDR] user_profiles mirror failed:', err.message);
  }
}

async function handleWebhookEvent(event) {
  const supabase = getSupabaseServiceClient();

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object;
      const userId = session.metadata?.user_id;
      const tier = session.metadata?.tier;
      const isFounder = session.metadata?.is_founder === 'true';

      if (userId && tier) {
        await supabase
          .from('users')
          .update({
            tier,
            stripe_customer_id: session.customer,
            founder_status: isFounder,
            grace_period_until: null,
            mfa_setup_prompted: false,
          })
          .eq('id', userId);
        await mirrorToUserProfile(supabase, userId, {
          tier,
          subscription_status: 'active',
          grace_period_until: null,
          mfa_setup_prompted: false,
          founder_pricing: isFounder,
        });
      }
      break;
    }

    case 'customer.subscription.updated': {
      // Reflect plan changes (upgrade/downgrade) coming from the customer
      // portal. Stripe encodes the new price on items.data[0].price.id; we
      // map that back to a tier via PRICE_MAP.
      const subscription = event.data.object;
      const priceId = subscription.items?.data?.[0]?.price?.id;
      const status = subscription.status;
      const customerId = subscription.customer;
      let nextTier = null;
      if (priceId === PRICE_MAP.analyst || priceId === PRICE_MAP.analyst_founder) nextTier = 'analyst';
      else if (priceId === PRICE_MAP.desk || priceId === PRICE_MAP.desk_founder) nextTier = 'desk';

      const { data: user } = await supabase
        .from('users')
        .select('id')
        .eq('stripe_customer_id', customerId)
        .single();
      if (user && nextTier && status === 'active') {
        await supabase
          .from('users')
          .update({ tier: nextTier, grace_period_until: null })
          .eq('id', user.id);
        await mirrorToUserProfile(supabase, user.id, {
          tier: nextTier,
          subscription_status: 'active',
          grace_period_until: null,
        });
      }
      break;
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object;
      const customerId = subscription.customer;

      // 48hr grace before revoking access. The user keeps paid features
      // during the window so a cancellation mid-Read doesn't yank the rug.
      const graceUntil = new Date(Date.now() + GRACE_PERIOD_MS).toISOString();
      const { data: user } = await supabase
        .from('users')
        .select('id')
        .eq('stripe_customer_id', customerId)
        .single();

      if (user) {
        await supabase
          .from('users')
          .update({ grace_period_until: graceUntil })
          .eq('id', user.id);
        await mirrorToUserProfile(supabase, user.id, {
          subscription_status: 'grace_period',
          grace_period_until: graceUntil,
        });
      }
      break;
    }

    case 'invoice.payment_failed': {
      const invoice = event.data.object;
      const customerId = invoice.customer;
      // 14-day grace (spans Stripe's retry window) — a decline is not a cancel.
      const graceUntil = new Date(Date.now() + PAYMENT_RETRY_GRACE_MS).toISOString();

      const { data: user } = await supabase
        .from('users')
        .select('id')
        .eq('stripe_customer_id', customerId)
        .single();
      if (user) {
        await supabase
          .from('users')
          .update({ grace_period_until: graceUntil })
          .eq('id', user.id);
        await mirrorToUserProfile(supabase, user.id, {
          subscription_status: 'grace_period',
          grace_period_until: graceUntil,
        });
      }
      console.warn('[VYNDR] Payment failed for customer:', customerId, '— grace until', graceUntil);
      break;
    }
  }
}

async function createPortalSession(stripeCustomerId) {
  const baseUrl = process.env.BASE_URL || 'http://localhost:3001';
  const session = await getStripe().billingPortal.sessions.create({
    customer: stripeCustomerId,
    return_url: `${baseUrl}/tracker`,
  });
  return { portal_url: session.url };
}

async function getSubscriptionStatus(stripeCustomerId) {
  const subscriptions = await getStripe().subscriptions.list({
    customer: stripeCustomerId,
    status: 'active',
    limit: 1,
  });

  if (subscriptions.data.length === 0) {
    return { subscription_status: 'none', current_period_end: null, cancel_at_period_end: false };
  }

  const sub = subscriptions.data[0];
  return {
    subscription_status: sub.status,
    current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
    cancel_at_period_end: sub.cancel_at_period_end,
  };
}

function constructWebhookEvent(body, signature) {
  return getStripe().webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET);
}

// Security follow-up item 0 — the REAL founder-seat count is the number of
// ACTIVE Stripe subscriptions on a founder price. It counts Stripe's OWN truth
// (subscription status on the live account), NOT a tier/founder_pricing field on
// a DB profile — a comped or manually-tiered account can set those without ever
// paying, which is exactly the phantom "1" the ClaimMeter was showing. Returns
// null when Stripe or the founder prices aren't configured → the counter hides.
async function countFounderSeats() {
  const founderPrices = [PRICE_MAP.analyst_founder, PRICE_MAP.desk_founder].filter(Boolean);
  if (!process.env.STRIPE_SECRET_KEY || founderPrices.length === 0) return null;
  const stripe = getStripe();
  let count = 0;
  for (const price of founderPrices) {
    // Only 'active' (paid + current) subscriptions are a claimed paid seat —
    // trialing / past_due / canceled are not. Manual pagination (works with the
    // real SDK's {data, has_more} AND simple test mocks); 20-page cap is a
    // runaway guard far above the founder seat total.
    let startingAfter;
    for (let page = 0; page < 20; page += 1) {
      // eslint-disable-next-line no-await-in-loop
      const res = await stripe.subscriptions.list({
        price, status: 'active', limit: 100, ...(startingAfter ? { starting_after: startingAfter } : {}),
      });
      const data = (res && res.data) || [];
      count += data.length;
      if (!res || !res.has_more || data.length === 0) break;
      startingAfter = data[data.length - 1].id;
    }
  }
  return count;
}

const FOUNDER_SEATS_TOTAL = Number(process.env.FOUNDER_SEATS_TOTAL || 100);

// Founder-checkout seat gate (item B) — founder pricing is live ONLY while
// founder seats remain (< FOUNDER_SEATS_TOTAL), read from the SAME truth as the
// ClaimMeter (countFounderSeats). Sold out → standard prices. When the count
// can't be verified we honor the ADVERTISED founder price (never charge more
// than advertised) — the founder branch below only fires if a founder price ID
// is actually configured, so an unconfigured founder price still falls through
// to standard. Injectable for tests.
async function founderSeatsAvailable(deps = {}) {
  const counter = deps.countFounderSeats || countFounderSeats;
  try {
    const claimed = await counter();
    if (claimed == null) return true;
    return Number(claimed) < FOUNDER_SEATS_TOTAL;
  } catch {
    return true;
  }
}

// The price a checkout attaches for a tier RIGHT NOW, seat-gated. Returns the
// price id + whether it's the founder price (for the founder_pricing webhook flag).
async function resolveCheckoutPrice(tier, deps = {}) {
  const founder = await founderSeatsAvailable(deps);
  const t = String(tier || '').toLowerCase();
  if (t === 'analyst') {
    if (founder && PRICE_MAP.analyst_founder) return { priceId: PRICE_MAP.analyst_founder, isFounder: true };
    return { priceId: PRICE_MAP.analyst || PRICE_UNCONFIGURED, isFounder: false };
  }
  if (t === 'desk') {
    if (founder && PRICE_MAP.desk_founder) return { priceId: PRICE_MAP.desk_founder, isFounder: true };
    return { priceId: PRICE_MAP.desk || PRICE_UNCONFIGURED, isFounder: false };
  }
  if (t === 'africa') return { priceId: PRICE_MAP.africa || PRICE_UNCONFIGURED, isFounder: false };
  return { priceId: PRICE_UNCONFIGURED, isFounder: false };
}

module.exports = {
  createCheckoutSession,
  handleWebhookEvent,
  createPortalSession,
  getSubscriptionStatus,
  constructWebhookEvent,
  isFounderCodeValid,
  getPriceId,
  countFounderSeats,
  founderSeatsAvailable,
  resolveCheckoutPrice,
  FOUNDER_SEATS_TOTAL,
  PAYMENT_RETRY_GRACE_MS,
  // Session 14 — exposed so the route layer + tests can recognize
  // the "tier valid but Stripe price not provisioned" state.
  PRICE_UNCONFIGURED,
};
