import {
  Body,
  Controller,
  Get,
  NotFoundException,
  Param,
  Post,
  Query,
  BadRequestException,
  UseGuards,
} from '@nestjs/common';
import { PrismaService } from '../common/prisma.service';
import { genCode, getMerchant, hashPassword, verifyPassword, payableBalance, verifyRecaptcha } from '../common/core';
import { currentShop } from '../common/shop-context';
import { signAffiliateToken } from '../common/affiliate-token';
import { AffiliateAuthGuard } from './affiliate-auth.guard';
import { MarcadeoProvisioning } from '../integrations/marcadeo-provisioning.service';
import { ShopifyService } from '../integrations/shopify.service';

@Controller('affiliates')
export class AffiliatesController {
  constructor(
    private prisma: PrismaService,
    private marcadeo: MarcadeoProvisioning,
    private shopify: ShopifyService,
  ) {}

  /** Public self-registration (PRD 4.1). */
  /** Public config for the affiliate signup page (branding, fields, reCAPTCHA). */
  @Get('signup-config')
  async signupConfig() {
    // A public affiliate page MUST know which store it belongs to. Without a
    // store (no ?shop / session), there is no program to join — tell the client
    // so it can show a clear "use your store's link" message instead of silently
    // enrolling into some default store.
    if (!currentShop()) return { noStore: true };
    const m = await getMerchant(this.prisma);
    const fields = await this.prisma.signupFieldDef.findMany({
      where: { merchantId: m.id },
      orderBy: { sortOrder: 'asc' },
    });
    return {
      programName: m.programName,
      branding: {
        logoUrl: m.logoUrl,
        brandColor: m.brandColor,
        brandSecondaryColor: m.brandSecondaryColor,
        termsText: m.termsText,
      },
      signupDefaults: m.signupDefaults || {},
      fields: fields.map((f) => ({ id: f.id, label: f.label, type: f.type, required: f.required })),
      recaptchaSiteKey: m.recaptchaEnabled ? m.recaptchaSiteKey : null,
    };
  }

  @Post('signup')
  async signup(
    @Body()
    body: {
      name?: string;
      email?: string;
      password?: string;
      social?: string;
      website?: string;
      signupData?: Record<string, unknown>;
      ref?: string; // recruiter's coupon code (MLM)
      recaptchaToken?: string;
    },
  ) {
    const { name, email, password } = body;
    if (!name || !email || !password) {
      throw new BadRequestException('name, email and password are required');
    }
    // Reject store-less signups — otherwise the affiliate would be enrolled into
    // a default store and never appear in the intended merchant's admin.
    if (!currentShop()) {
      throw new BadRequestException(
        "This sign-up link is missing its store. Please open the sign-up link the store shared with you (it looks like …/signup?shop=your-store.myshopify.com).",
      );
    }
    const merchant = await getMerchant(this.prisma);

    // Google reCAPTCHA v3 — verify the token when the merchant enabled it.
    if (merchant.recaptchaEnabled && merchant.recaptchaSecret) {
      const ok = await verifyRecaptcha(merchant.recaptchaSecret, body.recaptchaToken);
      if (!ok) throw new BadRequestException('reCAPTCHA verification failed — please try again');
    }

    // MLM: link to the recruiting affiliate (by their coupon code), if any.
    let referredById: string | null = null;
    if (merchant.mlmEnabled && body.ref?.trim()) {
      const parent = await this.prisma.affiliate.findFirst({
        where: { merchantId: merchant.id, couponCode: { equals: body.ref.trim(), mode: 'insensitive' } },
      });
      referredById = parent?.id ?? null;
    }

    const exists = await this.prisma.affiliate.findFirst({
      where: { merchantId: merchant.id, email },
    });
    if (exists) throw new BadRequestException('An affiliate with this email already exists');

    const active = merchant.autoApprove;
    const code = genCode(name);
    const web = process.env.WEB_URL || 'http://localhost:3000';
    // Public base for the referral redirect (/api/r/<code>) — never localhost.
    const api = (process.env.SHOPIFY_APP_URL || process.env.WEB_URL || '').replace(/\/$/, '');

    const affiliate = await this.prisma.affiliate.create({
      data: {
        merchantId: merchant.id,
        name,
        email,
        passwordHash: hashPassword(password),
        socialHandle: body.social || null,
        website: body.website || null,
        status: active ? 'ACTIVE' : 'PENDING',
        couponCode: active ? code : null,
        referralLink: active ? `${api}/api/r/${code}` : null,
        signupData: (body.signupData || {}) as any,
        tags: ['Influencer'],
        referredById,
      },
    });

    return {
      id: affiliate.id,
      status: affiliate.status,
      message: active
        ? 'Approved! Your referral link and coupon are ready.'
        : 'Registered! An admin will review your application shortly.',
      web,
    };
  }

  /** Portal login. Returns a simple token (the affiliate id) for the demo. */
  @Post('login')
  async login(@Body() body: { email?: string; password?: string }) {
    if (!currentShop()) {
      throw new BadRequestException(
        "This login link is missing its store. Please open the login link the store shared with you (…/login?shop=your-store.myshopify.com).",
      );
    }
    const merchant = await getMerchant(this.prisma);
    const affiliate = await this.prisma.affiliate.findFirst({
      where: { merchantId: merchant.id, email: body.email || '' },
    });
    if (!affiliate || !verifyPassword(body.password || '', affiliate.passwordHash)) {
      throw new BadRequestException('Invalid email or password');
    }
    // Signed session token — the client sends this as a Bearer token; the id is
    // never trusted from the URL again. The token carries the merchant's shop so
    // every later portal request resolves this same merchant (not the demo one).
    return {
      token: signAffiliateToken(affiliate.id, merchant.shop),
      id: affiliate.id,
      name: affiliate.name,
      status: affiliate.status,
    };
  }

  /** Affiliate's own dashboard (PRD 4.2). */
  @UseGuards(AffiliateAuthGuard)
  @Get(':id')
  async dashboard(@Param('id') id: string) {
    const a = await this.prisma.affiliate.findUnique({
      where: { id },
      include: {
        group: true,
        orders: { orderBy: { createdAt: 'desc' }, take: 20 },
        payouts: { orderBy: { createdAt: 'desc' } },
        _count: { select: { clicks: true, orders: true } },
      },
    });
    if (!a) throw new NotFoundException('Affiliate not found');
    const merchant = await getMerchant(this.prisma);

    const earnings = a.orders.reduce((s, o) => s + Number(o.commission), 0);
    const revenue = a.orders.reduce((s, o) => s + Number(o.total), 0);
    const clicks = a._count.clicks;
    const salesCount = a._count.orders;
    const { payable, held } = await payableBalance(this.prisma, a, merchant.commissionHoldDays);

    // Multi-level: recruit link + downline + override earnings.
    let mlm: { enabled: boolean; recruitLink?: string; downlineCount?: number; overrideEarnings?: number } = {
      enabled: false,
    };
    if (merchant.mlmEnabled) {
      const appUrl = (process.env.SHOPIFY_APP_URL || process.env.WEB_URL || '').replace(/\/$/, '');
      const [downlineCount, overrideAgg] = await Promise.all([
        this.prisma.affiliate.count({ where: { referredById: a.id } }),
        this.prisma.referralOrder.aggregate({
          where: { affiliateId: a.id, attribution: 'mlm', status: 'APPROVED' },
          _sum: { commission: true },
        }),
      ]);
      mlm = {
        enabled: true,
        recruitLink: a.couponCode ? `${appUrl}/signup?ref=${encodeURIComponent(a.couponCode)}` : undefined,
        downlineCount,
        overrideEarnings: Number(overrideAgg._sum.commission || 0),
      };
    }
    return {
      id: a.id,
      name: a.name,
      email: a.email,
      socialHandle: a.socialHandle,
      website: a.website,
      avatarUrl: a.avatarUrl,
      status: a.status,
      couponCode: a.couponCode,
      couponDiscountType: a.couponDiscountType,
      couponDiscountValue: a.couponDiscountValue == null ? null : Number(a.couponDiscountValue),
      // Prefer the real Marcadeo /click tracking link once provisioned.
      referralLink: a.marcadeoTrackingUrl || a.referralLink,
      balance: Number(a.balance),
      payable,
      held,
      mlm,
      minPayout: Number(merchant.minPayout),
      payoutMethods: merchant.payoutMethods,
      notifyPrefs: (a.notifyPrefs as any) || { sale: true, payout: true },
      stats: {
        clicks,
        sales: salesCount,
        earnings,
        revenue,
        conversionRate: clicks ? (salesCount / clicks) * 100 : 0,
      },
      paymentMethod: a.paymentMethod,
      paymentDetails: a.paymentDetails,
      orders: a.orders.map((o) => ({
        orderId: o.shopifyOrderId,
        date: o.createdAt.toISOString().slice(0, 10),
        total: Number(o.total),
        commission: Number(o.commission),
        via: o.attribution,
        status: o.status,
      })),
      payouts: a.payouts.map((p) => ({
        id: p.id,
        amount: Number(p.amount),
        status: p.status,
        method: p.method,
        reference: p.reference,
        date: p.createdAt.toISOString().slice(0, 10),
      })),
    };
  }

  /**
   * All brand/campaign tracking links for this affiliate — one per campaign.
   * This is how an affiliate gets the SECOND (and every) campaign's link: the
   * publisher (uid) stays the same, each brand is a different /click oid+lid.
   */
  @UseGuards(AffiliateAuthGuard)
  @Get(':id/links')
  async links(@Param('id') id: string) {
    const a = await this.prisma.affiliate.findUnique({ where: { id } });
    if (!a) throw new NotFoundException('Affiliate not found');
    if (a.status !== 'ACTIVE') return { brands: [] };
    const merchant = await getMerchant(this.prisma);
    const brands = await this.marcadeo.affiliateCampaignLinks(merchant, a);
    // Fallback to the single stored link if Marcadeo isn't wired yet.
    if (!brands.length && (a.marcadeoTrackingUrl || a.referralLink)) {
      return { brands: [{ campaignId: null, name: 'Store', url: a.marcadeoTrackingUrl || a.referralLink, couponCode: a.couponCode }] };
    }
    return { brands };
  }

  /** Browse the store's products (for the deep-link picker). */
  @UseGuards(AffiliateAuthGuard)
  @Get(':id/products')
  async products(@Param('id') id: string, @Query('q') q?: string) {
    const a = await this.prisma.affiliate.findUnique({ where: { id } });
    if (!a) throw new NotFoundException('Affiliate not found');
    const merchant = await getMerchant(this.prisma);
    if (!merchant.shopifyAccessToken) return { products: [] };
    const products = await this.shopify.listProducts({
      query: q,
      shop: merchant.shop,
      token: merchant.shopifyAccessToken,
    });
    return { products };
  }

  /** Shorten a (tracking) URL into a branded /s/<code> link. */
  @UseGuards(AffiliateAuthGuard)
  @Post(':id/shorten')
  async shorten(@Param('id') id: string, @Body() body: { url?: string }) {
    const url = (body.url || '').trim();
    if (!/^https?:\/\//.test(url)) throw new BadRequestException('Provide a full URL to shorten');
    // Reuse an existing short link for the same target (idempotent).
    const existing = await this.prisma.shortLink.findFirst({ where: { affiliateId: id, targetUrl: url } });
    const gen = () => Math.random().toString(36).slice(2, 8);
    let code = existing?.code;
    if (!code) {
      code = gen();
      // Retry on the rare collision.
      for (let i = 0; i < 5; i++) {
        const clash = await this.prisma.shortLink.findUnique({ where: { code } });
        if (!clash) break;
        code = gen();
      }
      await this.prisma.shortLink.create({ data: { code, targetUrl: url, affiliateId: id } });
    }
    const base = (process.env.SHOPIFY_APP_URL || '').replace(/\/$/, '');
    return { code, shortUrl: `${base}/s/${code}` };
  }

  /** Build a tracked deep link to a specific product/collection URL. */
  @UseGuards(AffiliateAuthGuard)
  @Post(':id/deep-link')
  async deepLink(@Param('id') id: string, @Body() body: { url?: string }) {
    const url = (body.url || '').trim();
    if (!/^https?:\/\//.test(url)) throw new BadRequestException('Enter a full product URL (https://…)');
    const a = await this.prisma.affiliate.findUnique({ where: { id } });
    if (!a) throw new NotFoundException('Affiliate not found');
    if (a.status !== 'ACTIVE') throw new BadRequestException('Your account is not active yet');
    const merchant = await getMerchant(this.prisma);
    const tracked = await this.marcadeo.affiliateDeepLink(merchant, a, url);
    // Fallback: if Marcadeo isn't wired, append the coupon to the raw URL.
    if (!tracked) {
      const sep = url.includes('?') ? '&' : '?';
      return { url: a.couponCode ? `${url}${sep}ref=${encodeURIComponent(a.couponCode)}` : url, tracked: false };
    }
    return { url: tracked, tracked: true };
  }

  /** Save payout details (PRD 4.2). */
  @UseGuards(AffiliateAuthGuard)
  @Post(':id/payment')
  async payment(
    @Param('id') id: string,
    @Body() body: { method?: string; details?: Record<string, unknown> },
  ) {
    await this.prisma.affiliate.update({
      where: { id },
      data: {
        paymentMethod: (body.method as any) || null,
        paymentDetails: (body.details || {}) as any,
      },
    });
    return { ok: true };
  }

  /** Affiliate updates their own profile (PRD 4.2 Settings). */
  @UseGuards(AffiliateAuthGuard)
  @Post(':id/profile')
  async updateProfile(
    @Param('id') id: string,
    @Body() body: { name?: string; socialHandle?: string; website?: string; avatarUrl?: string },
  ) {
    if (body.name !== undefined && !body.name.trim()) {
      throw new BadRequestException('Name cannot be empty');
    }
    const a = await this.prisma.affiliate.update({
      where: { id },
      data: {
        ...(body.name !== undefined ? { name: body.name } : {}),
        ...(body.socialHandle !== undefined ? { socialHandle: body.socialHandle || null } : {}),
        ...(body.website !== undefined ? { website: body.website || null } : {}),
        ...(body.avatarUrl !== undefined ? { avatarUrl: body.avatarUrl || null } : {}),
      },
    });
    return { ok: true, name: a.name };
  }

  /** Affiliate customizes their referral/coupon code (PRD 4.2 / 4.6). */
  @UseGuards(AffiliateAuthGuard)
  @Post(':id/referral-code')
  async updateReferralCode(@Param('id') id: string, @Body() body: { code?: string }) {
    const raw = (body.code || '').trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
    if (raw.length < 3) throw new BadRequestException('Code must be at least 3 letters/numbers');
    const a = await this.prisma.affiliate.findUnique({ where: { id } });
    if (!a) throw new NotFoundException('Affiliate not found');
    const clash = await this.prisma.affiliate.findFirst({
      where: { merchantId: a.merchantId, couponCode: { equals: raw, mode: 'insensitive' }, id: { not: id } },
    });
    if (clash) throw new BadRequestException('That code is already taken');
    const api = (process.env.SHOPIFY_APP_URL || process.env.WEB_URL || '').replace(/\/$/, '');
    await this.prisma.affiliate.update({ where: { id }, data: { couponCode: raw, referralLink: `${api}/api/r/${raw}` } });
    return { ok: true, code: raw };
  }

  /** Affiliate notification preferences (PRD 4.9). */
  @UseGuards(AffiliateAuthGuard)
  @Post(':id/notifications')
  async saveNotifyPrefs(@Param('id') id: string, @Body() body: { sale?: boolean; payout?: boolean }) {
    await this.prisma.affiliate.update({ where: { id }, data: { notifyPrefs: body as any } });
    return { ok: true };
  }

  /** Affiliate changes their password (PRD 4.2 Settings). */
  @UseGuards(AffiliateAuthGuard)
  @Post(':id/password')
  async changePassword(
    @Param('id') id: string,
    @Body() body: { currentPassword?: string; newPassword?: string },
  ) {
    const a = await this.prisma.affiliate.findUnique({ where: { id } });
    if (!a) throw new NotFoundException('Affiliate not found');
    if (!verifyPassword(body.currentPassword || '', a.passwordHash)) {
      throw new BadRequestException('Current password is incorrect');
    }
    if (!body.newPassword || body.newPassword.length < 4) {
      throw new BadRequestException('New password must be at least 4 characters');
    }
    await this.prisma.affiliate.update({ where: { id }, data: { passwordHash: hashPassword(body.newPassword) } });
    return { ok: true };
  }

  /** Affiliate requests a payout (PRD 4.8). */
  @UseGuards(AffiliateAuthGuard)
  @Post(':id/payout-request')
  async payoutRequest(@Param('id') id: string) {
    const a = await this.prisma.affiliate.findUnique({ where: { id } });
    if (!a) throw new NotFoundException('Affiliate not found');
    const merchant = await getMerchant(this.prisma);
    // Only the payable (past commission-hold) part can be requested.
    const { payable, held } = await payableBalance(this.prisma, a, merchant.commissionHoldDays);
    if (payable < Number(merchant.minPayout)) {
      const heldNote = held > 0 ? ` (₹${held} still on hold)` : '';
      throw new BadRequestException(
        `Payable ₹${payable}${heldNote} is below the minimum payout of ₹${Number(merchant.minPayout)}`,
      );
    }
    const payout = await this.prisma.payout.create({
      data: { merchantId: merchant.id, affiliateId: id, amount: payable, status: 'REQUESTED' },
    });
    return { ok: true, requested: Number(payout.amount) };
  }
}
