// Cross-channel entitlement check.
//
// Goal: given a customer DID and a product_id, return whether the customer
// currently has the entitlement, regardless of which channel funded it
// (Stripe / on-chain / Google Play / App Store / one-time credit purchase).
//
// MVP scope (A3-MVP):
//   - Subscription-funded entitlements are fully supported across all channels
//   - One-time-purchase credit grants are supported when CreditGrant.metadata
//     carries a `product_id` field (existing flows already do this for
//     credit-grant-type Products)
//   - When multiple subscriptions cover the same product (rare — e.g. user
//     buys on iOS and on web), we pick by status priority (active > trialing
//     > paused > past_due) and tie-break by latest current_period_end
//
// Channel inference:
//   - Subscription.channel (A0 column) wins when set
//   - Falls back to PaymentMethod.type — for legacy subscriptions written
//     before A0, this maps stripe/arcblock/ethereum/base correctly

import { Op } from 'sequelize';

import { CreditGrant, Customer, PaymentMethod, Price, Product, Subscription, SubscriptionItem } from '../store/models';

// Subscriptions in `active`/`trialing` should still grant entitlement only
// while the current period hasn't elapsed. Without this, a row whose
// EXPIRED webhook never landed (network drop, RTDN race onto a duplicate
// row, etc.) stays status=active forever and the customer keeps Pro long
// after the platform has stopped billing them. `past_due`/`paused` are
// allowed to outrun current_period_end — those statuses are themselves
// the "should-have-renewed-but-hasn't" markers and the grace window is
// platform-controlled, not period-bounded.
const stillInActivePeriod = () => ({
  [Op.or]: [
    { status: ['past_due', 'paused'] as any },
    {
      status: ['active', 'trialing'] as any,
      current_period_end: { [Op.gt]: Math.floor(Date.now() / 1000) },
    },
  ],
});

export type Channel = 'stripe' | 'app_store' | 'google_play' | 'arcblock' | 'ethereum' | 'base' | 'bitcoin' | null;

export type EntitlementCheckResult = {
  active: boolean;
  channel: Channel;
  expires_at: number | null;
  subscription_id: string | null;
  source: 'subscription' | 'one_time' | null;
  credit_remaining?: string;
};

const SUBSCRIPTION_STATUS_PRIORITY: Record<string, number> = {
  active: 0,
  trialing: 1,
  paused: 2,
  past_due: 3,
};
const ACTIVE_STATUSES = new Set(['active', 'trialing']);

function inactiveResult(): EntitlementCheckResult {
  return { active: false, channel: null, expires_at: null, subscription_id: null, source: null };
}

async function inferChannelFromSubscription(sub: Subscription): Promise<Channel> {
  const ch = (sub as any).channel as Channel | undefined;
  if (ch) return ch;
  if (!sub.default_payment_method_id) return null;
  const method = await PaymentMethod.findByPk(sub.default_payment_method_id);
  return (method?.type as Channel) ?? null;
}

async function inferChannelFromGrant(grant: CreditGrant): Promise<Channel> {
  // Most CreditGrant rows store the originating payment_method_id in metadata.
  const pmId = grant.metadata?.payment_method_id || grant.metadata?.paymentMethodId;
  if (!pmId) return null;
  const method = await PaymentMethod.findByPk(pmId);
  return (method?.type as Channel) ?? null;
}

function pickBestSubscription(subs: Subscription[]): Subscription | undefined {
  return [...subs].sort((a, b) => {
    const pa = SUBSCRIPTION_STATUS_PRIORITY[a.status as string] ?? 99;
    const pb = SUBSCRIPTION_STATUS_PRIORITY[b.status as string] ?? 99;
    if (pa !== pb) return pa - pb;
    // tie-break: later expiry wins
    return (b.current_period_end ?? 0) - (a.current_period_end ?? 0);
  })[0];
}

/**
 * The channel SKU stored on an IAP subscription (Apple/Google product id).
 * Its presence marks a subscription whose product MUST be resolved live via the
 * Product↔SKU mapping rather than via its (possibly stale) SubscriptionItem.
 */
function channelSkuOf(sub: any): string | undefined {
  const pd = sub?.payment_details as
    | { app_store?: { product_id?: string }; google_play?: { product_id?: string } }
    | undefined;
  return pd?.app_store?.product_id ?? pd?.google_play?.product_id;
}

/**
 * Resolve customer DID → Customer row; returns null if not found.
 * Exposed so listEntitlements can reuse the same lookup.
 */
function resolveCustomer(customer_did: string, livemode: boolean): Promise<Customer | null> {
  return Customer.findOne({ where: { did: customer_did, livemode } });
}

/**
 * Find all active subscriptions that grant `product_id` to this customer.
 * Walks each subscription's items → price → product to do the match.
 */
async function findSubscriptionsCoveringProduct(
  customer: Customer,
  productId: string,
  livemode: boolean
): Promise<Subscription[]> {
  const subs = await Subscription.findAll({
    where: {
      customer_id: customer.id,
      livemode,
      ...stillInActivePeriod(),
    } as any,
    include: [
      {
        model: SubscriptionItem,
        as: 'items',
        include: [{ model: Price, as: 'price', include: [{ model: Product, as: 'product' }] }],
      },
    ],
  });
  // Resolve the queried Product's channel SKUs from its Prices (Stripe-style:
  // SKU binding lives on Price.metadata, not Product.metadata). One Product
  // can have N Prices (monthly / yearly / promo), each bound to its own
  // App Store SKU + Google Play SKU. Matching live against Prices means
  // entitlement always reflects the bindings as configured NOW — does not
  // depend on a product_id snapshot frozen into the subscription. A SKU
  // rebind grants only the new product, not both (PR #1381 review P2).
  //
  // Multi-tenant scoping: the key includes the tenant (bundle_id /
  // package_name) so a sub from App A doesn't accidentally satisfy a
  // Price configured for App B that happens to share the SKU string —
  // App Store / Play Console SKU namespaces are per-app, so two apps
  // owning the literal string "pro_monthly" is the expected case.
  const prices = await Price.findAll({ where: { product_id: productId, livemode } as any });
  const appKeys = new Set<string>(); // ${bundle_id}:${sku}
  const gpKeys = new Set<string>(); // ${package_name}:${sku}
  for (const p of prices) {
    const m = ((p as any).metadata as any) || {};
    if (m.app_store_product_id && m.bundle_id) appKeys.add(`${m.bundle_id}:${m.app_store_product_id}`);
    if (m.google_play_product_id && m.package_name) {
      gpKeys.add(`${m.package_name}:${m.google_play_product_id}`);
    }
  }

  return subs.filter((sub) => {
    const pd = (sub as any).payment_details as
      | {
          app_store?: { product_id?: string; bundle_id?: string };
          google_play?: { product_id?: string; package_name?: string };
        }
      | undefined;

    // IAP subs that carry a channel SKU: resolve ONLY via the live channel-SKU ↔
    // current Price metadata mapping. Deliberately ignore the (possibly stale)
    // SubscriptionItem snapshot AND the legacy metadata.product_id — otherwise a
    // SKU rebind would grant both the old item's product and the new mapping
    // (PR #1381 review P2). This is the single authoritative rule for IAP, and
    // listEntitlements applies the same mapping for consistency.
    if (channelSkuOf(sub)) {
      const appSku = pd?.app_store?.product_id;
      const appBundle = pd?.app_store?.bundle_id;
      const gpSku = pd?.google_play?.product_id;
      const gpPkg = pd?.google_play?.package_name;
      if (appSku && appBundle && appKeys.has(`${appBundle}:${appSku}`)) return true;
      if (gpSku && gpPkg && gpKeys.has(`${gpPkg}:${gpSku}`)) return true;
      return false;
    }

    // Stripe / legacy (no channel SKU stored): walk items → price → product,
    // then the metadata snapshot.
    const items = (sub as any).items as Array<{ price?: { product_id?: string } }> | undefined;
    if (items?.some((it) => it.price?.product_id === productId)) return true;
    return (sub.metadata as any)?.product_id === productId;
  });
}

/**
 * Find a CreditGrant tied to this customer + product, if any.
 * Convention: CreditGrant.metadata.product_id matches.
 */
async function findGrantCoveringProduct(customer: Customer, productId: string): Promise<CreditGrant | null> {
  const grants = await CreditGrant.findAll({
    where: { customer_id: customer.id, status: 'granted' as any },
  });
  return grants.find((g) => g.metadata?.product_id === productId) ?? null;
}

function isGrantActive(grant: CreditGrant): boolean {
  if (grant.expires_at && grant.expires_at * 1000 < Date.now()) return false;
  // remaining_amount is stored as string (BN-friendly). Treat anything ≠ "0" as positive.
  const remaining = grant.remaining_amount ?? '0';
  return remaining !== '0' && remaining !== '0.0';
}

/**
 * Check whether the customer currently holds the entitlement for `product_id`.
 */
export async function checkEntitlement({
  customer_did,
  product_id,
  livemode = true,
}: {
  customer_did: string;
  product_id: string;
  livemode?: boolean;
}): Promise<EntitlementCheckResult> {
  const customer = await resolveCustomer(customer_did, livemode);
  if (!customer) return inactiveResult();

  // 1. Subscription path
  const matching = await findSubscriptionsCoveringProduct(customer, product_id, livemode);
  const best = pickBestSubscription(matching);
  if (best) {
    return {
      active: ACTIVE_STATUSES.has(best.status as string),
      channel: await inferChannelFromSubscription(best),
      expires_at: best.current_period_end ?? null,
      subscription_id: best.id,
      source: 'subscription',
    };
  }

  // 2. One-time credit-grant path
  const grant = await findGrantCoveringProduct(customer, product_id);
  if (grant) {
    return {
      active: isGrantActive(grant),
      channel: await inferChannelFromGrant(grant),
      expires_at: grant.expires_at ?? null,
      subscription_id: null,
      source: 'one_time',
      credit_remaining: grant.remaining_amount,
    };
  }

  return inactiveResult();
}

export type EntitlementListItem = {
  product_id: string;
  active: boolean;
  channel: Channel;
  expires_at: number | null;
  subscription_id: string | null;
  source: 'subscription' | 'one_time';
  credit_remaining?: string;
};

/**
 * List every entitlement this customer holds (one row per distinct product).
 * Subscription items contribute first; CreditGrants add anything not already
 * covered by a subscription.
 */
export async function listEntitlements({
  customer_did,
  livemode = true,
}: {
  customer_did: string;
  livemode?: boolean;
}): Promise<EntitlementListItem[]> {
  const customer = await resolveCustomer(customer_did, livemode);
  if (!customer) return [];

  const subs = await Subscription.findAll({
    where: {
      customer_id: customer.id,
      livemode,
      ...stillInActivePeriod(),
    } as any,
    include: [
      {
        model: SubscriptionItem,
        as: 'items',
        include: [{ model: Price, as: 'price', include: [{ model: Product, as: 'product' }] }],
      },
    ],
  });

  // Group by product_id, picking best subscription per product. IAP subs that
  // carry a channel SKU resolve via the live SKU→Product mapping (NOT their
  // possibly-stale SubscriptionItem) so list agrees with checkEntitlement
  // (PR #1381 review P2); everything else groups by items.
  const productToSubs = new Map<string, Subscription[]>();
  const addToGroup = (pid: string, sub: Subscription) => {
    if (!productToSubs.has(pid)) productToSubs.set(pid, []);
    productToSubs.get(pid)!.push(sub);
  };

  const skuSubs = subs.filter((s) => channelSkuOf(s));
  if (skuSubs.length) {
    // Build (tenant, SKU)→productId from the Price catalogue. Stripe-style
    // schema: Price.metadata.{app_store,google_play}_product_id binds the
    // channel SKU to a specific Price; that Price's product_id is the
    // entitlement key. One Product with N Prices (monthly / yearly / promo)
    // all roll up to the same entitlement — exactly the behaviour
    // `entitlements.check(productId)` expects on the client side.
    //
    // Multi-tenant scoping: same SKU string can live in independent App
    // Store / Play Console namespaces (two iOS or two Android apps wired
    // into one Payment Kit), so the key includes the tenant
    // (bundle_id / package_name). Without this, Map.set would silently
    // overwrite collisions and route all subs to whichever Price was
    // iterated last. Each sub reads its own tenant from payment_details
    // (persisted at create time, backfilled for legacy rows).
    const prices = await Price.findAll({ where: { livemode } as any });
    const appKeyToPid = new Map<string, string>();
    const gpKeyToPid = new Map<string, string>();
    for (const p of prices) {
      const m = ((p as any).metadata as any) || {};
      if (m.app_store_product_id && m.bundle_id) {
        appKeyToPid.set(`${m.bundle_id}:${m.app_store_product_id}`, p.product_id);
      }
      if (m.google_play_product_id && m.package_name) {
        gpKeyToPid.set(`${m.package_name}:${m.google_play_product_id}`, p.product_id);
      }
    }
    for (const sub of skuSubs) {
      const pd = (sub as any).payment_details as
        | {
            app_store?: { product_id?: string; bundle_id?: string };
            google_play?: { product_id?: string; package_name?: string };
          }
        | undefined;
      const appSku = pd?.app_store?.product_id;
      const appBundle = pd?.app_store?.bundle_id;
      const gpSku = pd?.google_play?.product_id;
      const gpPkg = pd?.google_play?.package_name;
      const pid =
        (appSku && appBundle && appKeyToPid.get(`${appBundle}:${appSku}`)) ||
        (gpSku && gpPkg && gpKeyToPid.get(`${gpPkg}:${gpSku}`));
      if (pid) addToGroup(pid, sub);
    }
  }

  for (const sub of subs) {
    // eslint-disable-next-line no-continue -- IAP-with-SKU handled above via live mapping
    if (channelSkuOf(sub)) continue;
    const items = (sub as any).items as Array<{ price?: { product_id?: string } }> | undefined;
    const productIds = new Set(items?.map((it) => it.price?.product_id).filter(Boolean) as string[]);
    for (const pid of productIds) addToGroup(pid, sub);
  }

  const subscriptionProductIds = new Set<string>();
  // Run channel inference in parallel — each is a Sequelize lookup, no shared state.
  const subscriptionRows = await Promise.all(
    Array.from(productToSubs.entries()).map(async ([productId, candidates]) => {
      const best = pickBestSubscription(candidates)!;
      subscriptionProductIds.add(productId);
      const item: EntitlementListItem = {
        product_id: productId,
        active: ACTIVE_STATUSES.has(best.status as string),
        channel: await inferChannelFromSubscription(best),
        expires_at: best.current_period_end ?? null,
        subscription_id: best.id,
        source: 'subscription',
      };
      return item;
    })
  );

  // Add CreditGrant-funded entitlements for products not already covered
  const grants = await CreditGrant.findAll({
    where: { customer_id: customer.id, status: 'granted' as any },
  });
  const uncoveredGrants = grants.filter((g) => {
    const pid = g.metadata?.product_id;
    return pid && !subscriptionProductIds.has(pid);
  });
  const grantRows = await Promise.all(
    uncoveredGrants.map(async (grant) => {
      const item: EntitlementListItem = {
        product_id: grant.metadata!.product_id,
        active: isGrantActive(grant),
        channel: await inferChannelFromGrant(grant),
        expires_at: grant.expires_at ?? null,
        subscription_id: null,
        source: 'one_time',
        credit_remaining: grant.remaining_amount,
      };
      return item;
    })
  );

  return [...subscriptionRows, ...grantRows];
}
