import { Injectable, Logger } from '@nestjs/common';
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';

/**
 * Shopify OAuth + webhook cryptography for the embedded public app.
 *
 * Install flow:
 *   GET /auth?shop=xxx        -> redirect merchant to authUrl()
 *   GET /auth/callback        -> verifyCallbackHmac() -> exchangeToken() ->
 *                                registerWebhooks() -> store token -> open app
 *
 * Config comes from SHOPIFY_API_KEY / SHOPIFY_API_SECRET / SHOPIFY_SCOPES /
 * SHOPIFY_APP_URL. When any is missing configured() is false and the OAuth
 * routes return 503 instead of half-working.
 */
@Injectable()
export class ShopifyAuthService {
  private readonly log = new Logger('ShopifyAuth');

  get apiKey() {
    return process.env.SHOPIFY_API_KEY || '';
  }
  private get secret() {
    return process.env.SHOPIFY_API_SECRET || '';
  }
  get scopes() {
    return (process.env.SHOPIFY_SCOPES || 'read_orders,write_discounts,read_products,write_script_tags,write_gift_cards')
      .split(',')
      .map((s) => s.trim())
      .filter(Boolean)
      .join(',');
  }
  get appUrl() {
    return (process.env.SHOPIFY_APP_URL || '').replace(/\/$/, '');
  }
  private get version() {
    return process.env.SHOPIFY_API_VERSION || '2026-07';
  }

  configured() {
    return Boolean(this.apiKey && this.secret && this.appUrl);
  }

  /** Only accept well-formed *.myshopify.com domains (guards open-redirect). */
  validShop(shop?: string): shop is string {
    return !!shop && /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/.test(shop);
  }

  /** A random state/nonce for the OAuth round-trip (stored in a cookie). */
  newState(): string {
    return randomBytes(16).toString('hex');
  }

  /** Build the Shopify OAuth consent URL. */
  authUrl(shop: string, state: string): string {
    const redirectUri = `${this.appUrl}/api/auth/callback`;
    const params = new URLSearchParams({
      client_id: this.apiKey,
      scope: this.scopes,
      redirect_uri: redirectUri,
      state,
    });
    return `https://${shop}/admin/oauth/authorize?${params.toString()}`;
  }

  /**
   * Verify the HMAC on an OAuth callback / App Bridge request (query-string form).
   * All params except `hmac` (and legacy `signature`) are sorted and joined, then
   * HMAC-SHA256'd with the app secret and compared to the supplied hex hmac.
   */
  verifyCallbackHmac(query: Record<string, any>): boolean {
    const { hmac, signature, ...rest } = query;
    if (typeof hmac !== 'string') return false;
    const message = Object.keys(rest)
      .sort()
      .map((k) => `${k}=${Array.isArray(rest[k]) ? rest[k].join(',') : rest[k]}`)
      .join('&');
    const digest = createHmac('sha256', this.secret).update(message).digest('hex');
    return this.safeEqualHex(digest, hmac);
  }

  /** Verify a webhook body signature (base64 HMAC of the RAW request body). */
  verifyWebhookHmac(rawBody: Buffer | string, hmacHeader?: string): boolean {
    if (!hmacHeader) return false;
    const digest = createHmac('sha256', this.secret)
      .update(typeof rawBody === 'string' ? Buffer.from(rawBody, 'utf8') : rawBody)
      .digest('base64');
    return this.safeEqualB64(digest, hmacHeader);
  }

  /** Exchange the OAuth `code` for a permanent Admin API access token. */
  async exchangeToken(shop: string, code: string): Promise<{ accessToken: string; scope: string }> {
    const res = await fetch(`https://${shop}/admin/oauth/access_token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
      body: JSON.stringify({ client_id: this.apiKey, client_secret: this.secret, code }),
    });
    const body = (await res.json().catch(() => null)) as any;
    if (!res.ok || !body?.access_token) {
      throw new Error(body?.error_description || body?.error || `Token exchange failed (${res.status})`);
    }
    return { accessToken: body.access_token, scope: body.scope || '' };
  }

  /**
   * Exchange a verified App Bridge session token for a durable OFFLINE Admin API
   * access token (OAuth 2.0 token exchange). This is what makes the app work
   * under Shopify's "managed installation" — where the classic /auth/callback
   * (and its token) may never run — so we still obtain a per-shop token the
   * first time the embedded admin loads. Returns the offline token + scopes.
   */
  async exchangeSessionToken(
    shop: string,
    sessionToken: string,
  ): Promise<{ accessToken: string; scope: string; expiresIn: number }> {
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), 10000);
    let res: Response;
    try {
      res = await fetch(`https://${shop}/admin/oauth/access_token`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
        signal: ctrl.signal,
        body: JSON.stringify({
          client_id: this.apiKey,
          client_secret: this.secret,
          grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
          subject_token: sessionToken,
          subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
          requested_token_type: 'urn:shopify:params:oauth:token-type:offline-access-token',
          // REQUIRED: ask for an EXPIRING offline token. Shopify no longer accepts
          // non-expiring tokens on the Admin API; without this flag the exchange
          // returns a legacy non-expiring token that fails every API call.
          expiring: 1,
        }),
      });
    } finally {
      clearTimeout(timer);
    }
    const body = (await res.json().catch(() => null)) as any;
    if (!res.ok || !body?.access_token) {
      throw new Error(body?.error_description || body?.error || `Token exchange failed (${res.status})`);
    }
    return { accessToken: body.access_token, scope: body.scope || '', expiresIn: Number(body.expires_in) || 3599 };
  }

  /**
   * Register the app's webhooks on the shop (idempotent — Shopify dedupes by
   * topic+address). orders/create drives real-time conversions; app/uninstalled
   * flips the store to uninstalled.
   */
  async registerWebhooks(shop: string, token: string): Promise<void> {
    const topics: Array<{ topic: string; path: string }> = [
      { topic: 'orders/create', path: '/api/webhooks/orders-create' },
      { topic: 'orders/updated', path: '/api/webhooks/orders-updated' },
      { topic: 'refunds/create', path: '/api/webhooks/refunds-create' },
      { topic: 'app/uninstalled', path: '/api/webhooks/app-uninstalled' },
    ];
    for (const { topic, path } of topics) {
      try {
        const res = await fetch(`https://${shop}/admin/api/${this.version}/webhooks.json`, {
          method: 'POST',
          headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
          body: JSON.stringify({ webhook: { topic, address: `${this.appUrl}${path}`, format: 'json' } }),
        });
        if (!res.ok && res.status !== 422) {
          // 422 = already exists; anything else is worth logging (non-fatal).
          this.log.warn(`webhook ${topic} -> ${res.status}`);
        }
      } catch (e) {
        this.log.warn(`webhook ${topic} failed: ${(e as Error).message}`);
      }
    }
  }

  /**
   * Inject the storefront click_id-capture script via a ScriptTag (idempotent —
   * we skip if one already points at our src). Non-fatal on failure.
   */
  async registerScriptTag(shop: string, token: string): Promise<void> {
    const src = `${this.appUrl}/api/storefront/marcadeo.js`;
    try {
      const list = await fetch(`https://${shop}/admin/api/${this.version}/script_tags.json`, {
        headers: { 'X-Shopify-Access-Token': token },
      });
      const body = (await list.json().catch(() => null)) as any;
      const exists = (body?.script_tags ?? []).some((t: any) => t.src === src);
      if (exists) return;

      await fetch(`https://${shop}/admin/api/${this.version}/script_tags.json`, {
        method: 'POST',
        headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
        body: JSON.stringify({ script_tag: { event: 'onload', src, display_scope: 'all' } }),
      });
    } catch (e) {
      this.log.warn(`script tag failed: ${(e as Error).message}`);
    }
  }

  private safeEqualHex(a: string, b: string): boolean {
    try {
      const ba = Buffer.from(a, 'hex');
      const bb = Buffer.from(b, 'hex');
      return ba.length === bb.length && timingSafeEqual(ba, bb);
    } catch {
      return false;
    }
  }

  private safeEqualB64(a: string, b: string): boolean {
    try {
      const ba = Buffer.from(a, 'base64');
      const bb = Buffer.from(b, 'base64');
      return ba.length === bb.length && timingSafeEqual(ba, bb);
    } catch {
      return false;
    }
  }
}
