import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { currentShop } from './shop-context';
import { isDemoReq } from './demo';
import { PrismaService } from './prisma.service';
import { ShopifyAuthService } from '../auth/shopify-auth.service';
import { ShopifyService } from '../integrations/shopify.service';

/**
 * Locks the merchant admin surface. Any request under these prefixes must carry
 * a valid Shopify session token (resolved into a shop by the shop-context
 * middleware) — i.e. it must come from inside the embedded Shopify admin.
 * Opening /admin directly in a browser has no token, so these APIs return 401
 * instead of silently serving the demo merchant.
 *
 * It also keeps the store CONNECTED with a valid EXPIRING offline token. Shopify
 * no longer accepts non-expiring tokens on the Admin API, so we exchange the
 * (short-lived) session token for an expiring offline token via OAuth token
 * exchange, cache its expiry in-process, and re-exchange before it lapses. The
 * first time a store connects we also register webhooks and back-fill real
 * Shopify discount codes for existing coupons.
 *
 * Local dev: set DEV_ALLOW_NO_SHOP=true to bypass (uses the demo merchant).
 */
const PROTECTED_PREFIXES = ['/api/admin', '/api/shopify'];

// In-memory expiry (ms since epoch) of the offline token we last stored per shop.
// Avoids a Shopify round-trip on every request and a DB schema change. Empty
// after a restart → the next admin request simply re-exchanges (fail-soft).
const tokenExpiry = new Map<string, number>();

@Injectable()
export class MerchantGuard implements CanActivate {
  constructor(
    private prisma: PrismaService,
    private auth: ShopifyAuthService,
    private shopify: ShopifyService,
  ) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const req = ctx.switchToHttp().getRequest();
    const path: string = req.path || req.originalUrl || '';
    const method = String(req.method || 'GET').toUpperCase();

    // GLOBAL, defence-in-depth: the public demo is strictly read-only on EVERY
    // route. Any write carrying the demo header is rejected outright, so a demo
    // visitor can never change or delete anything anywhere in the app.
    if (isDemoReq(req) && method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
      throw new ForbiddenException('This is a read-only demo — no changes are saved.');
    }

    const isProtected = PROTECTED_PREFIXES.some((p) => path === p || path.startsWith(p + '/'));
    if (!isProtected) return true;

    if (process.env.DEV_ALLOW_NO_SHOP === 'true') return true;

    // Demo reads (writes were already rejected globally above) → serve demo data.
    if (isDemoReq(req)) return true;

    const shop = currentShop();
    if (!shop) {
      throw new UnauthorizedException('Open this app from your Shopify admin.');
    }

    // Ensure the store has a valid (non-expired) offline token before the
    // controller runs, so anything it does with the Admin API uses a live token.
    // Awaited (one Shopify call, only when stale) but fully fail-soft.
    await this.ensureFreshToken(req, shop);
    return true;
  }

  /**
   * Guarantee a valid EXPIRING offline token for this shop. Re-exchanges the
   * current session token when the stored token is missing or near expiry.
   * All best-effort — never blocks or fails the request.
   */
  private async ensureFreshToken(req: any, shop: string): Promise<void> {
    try {
      const m = await this.prisma.merchant.findUnique({
        where: { shop },
        select: { id: true, shopifyAccessToken: true },
      });
      const exp = tokenExpiry.get(shop);
      const firstConnect = !m?.shopifyAccessToken;
      // Have a token we know is still fresh (>60s of life left)? keep it.
      if (m?.shopifyAccessToken && exp && Date.now() < exp - 60_000) return;

      const authz = req.headers?.['authorization'];
      const sessionToken = typeof authz === 'string' && authz.startsWith('Bearer ') ? authz.slice(7) : null;
      if (!sessionToken) return; // can't refresh without a live session token

      const { accessToken, scope, expiresIn } = await this.auth.exchangeSessionToken(shop, sessionToken);
      const merchant = await this.prisma.merchant.upsert({
        where: { shop },
        create: {
          shop,
          programName: shop.replace('.myshopify.com', ''),
          shopifyAccessToken: accessToken,
          shopifyScopes: scope,
          appInstalled: true,
          installedAt: new Date(),
        },
        update: { shopifyAccessToken: accessToken, shopifyScopes: scope, appInstalled: true, uninstalledAt: null },
      });
      tokenExpiry.set(shop, Date.now() + expiresIn * 1000);

      // First-ever connection for this store: subscribe webhooks + create real
      // Shopify discount codes for coupons issued before it had a token.
      if (firstConnect) {
        this.auth.registerWebhooks(shop, accessToken).catch(() => {});
        this.auth.registerScriptTag(shop, accessToken).catch(() => {});
        this.backfillCoupons(merchant.id, shop, accessToken).catch(() => {});
      }
    } catch {
      /* fail-soft — the request still serves DB data even if Shopify is down */
    }
  }

  private async backfillCoupons(merchantId: string, shop: string, token: string): Promise<void> {
    const affs = await this.prisma.affiliate.findMany({
      where: { merchantId, status: 'ACTIVE', couponCode: { not: null } },
    });
    for (const a of affs) {
      try {
        await this.shopify.createDiscountCode(
          a.couponCode!,
          a.couponDiscountType === 'FIXED' ? 'FIXED' : 'PERCENT',
          Number(a.couponDiscountValue ?? 10),
          {
            oncePerCustomer: a.couponUsageLimitPerCustomer != null && Number(a.couponUsageLimitPerCustomer) <= 1,
            minOrderValue: a.couponMinOrderValue != null ? Number(a.couponMinOrderValue) : null,
            shop,
            token,
          },
        );
      } catch {
        /* coupon may already exist in the store — ignore */
      }
    }
  }
}
