/**
 * zap.ts — NIP-57 zap-out capability for Timmy.
 *
 * ZapService constructs and signs a NIP-57 zap request event, resolves the
 * recipient's LNURL-pay endpoint from their stored lightning address, obtains
 * a BOLT11 invoice, pays it via LNbits, and logs the outbound event.
 *
 * LNURL-pay resolution flow (NIP-57 §4):
 *   1. Look up recipient's lightningAddress from nostr_identities.
 *   2. Convert user@domain → https://domain/.well-known/lnurlp/user
 *   3. GET the LNURL-pay metadata — extract callback URL.
 *   4. Build the signed kind-9734 zap request event.
 *   5. GET callback?amount=<msats>&nostr=<urlencoded-event> → receive bolt11.
 *   6. Pay bolt11 via LNbits. Log event regardless of payment outcome.
 *
 * Configuration (env vars):
 *   ZAP_PCT_DEFAULT   — percentage of work fee to zap back (default 0 = disabled)
 *   ZAP_MIN_SATS      — minimum sats to trigger a zap (default 10)
 *   NOSTR_RELAY_URL   — relay URL embedded in the zap request tags (optional)
 */

import { randomUUID } from "crypto";
import { promises as dns } from "dns";
import { db, timmyNostrEvents, nostrIdentities } from "@workspace/db";
import { eq } from "drizzle-orm";
import { timmyIdentityService } from "./timmy-identity.js";
import { lnbitsService } from "./lnbits.js";
import { makeLogger } from "./logger.js";

const logger = makeLogger("zap");

function envInt(name: string, fallback: number): number {
  const v = parseInt(process.env[name] ?? "", 10);
  return Number.isFinite(v) && v >= 0 ? v : fallback;
}

const ZAP_PCT_DEFAULT = envInt("ZAP_PCT_DEFAULT", 0);
const ZAP_MIN_SATS    = envInt("ZAP_MIN_SATS", 10);
const RELAY_URL       = process.env["NOSTR_RELAY_URL"] ?? "";

// LNURL fetch timeout (ms)
const LNURL_TIMEOUT_MS = 8_000;

// ── SSRF protection ───────────────────────────────────────────────────────────
// All LNURL fetches use user-supplied domain names; we must block requests to
// private/loopback/link-local addresses to prevent Server-Side Request Forgery.

const PRIVATE_RANGES: Array<{ prefix: string; bits: number }> = [
  // IPv4 private + special ranges
  { prefix: "10.",     bits: 8  },
  { prefix: "172.16.", bits: 12 },
  { prefix: "172.17.", bits: 12 },
  { prefix: "172.18.", bits: 12 },
  { prefix: "172.19.", bits: 12 },
  { prefix: "172.20.", bits: 12 },
  { prefix: "172.21.", bits: 12 },
  { prefix: "172.22.", bits: 12 },
  { prefix: "172.23.", bits: 12 },
  { prefix: "172.24.", bits: 12 },
  { prefix: "172.25.", bits: 12 },
  { prefix: "172.26.", bits: 12 },
  { prefix: "172.27.", bits: 12 },
  { prefix: "172.28.", bits: 12 },
  { prefix: "172.29.", bits: 12 },
  { prefix: "172.30.", bits: 12 },
  { prefix: "172.31.", bits: 12 },
  { prefix: "192.168.", bits: 16 },
  { prefix: "127.",    bits: 8  },   // loopback
  { prefix: "169.254.", bits: 16 },  // link-local
  { prefix: "100.64.", bits: 10 },   // shared address space (RFC 6598)
  { prefix: "0.",      bits: 8  },   // "this" network
  { prefix: "198.18.", bits: 15 },   // benchmarking (RFC 2544)
  { prefix: "198.51.100.", bits: 24 }, // TEST-NET-2
  { prefix: "203.0.113.", bits: 24 }, // TEST-NET-3
  { prefix: "240.",    bits: 4  },   // reserved
];

const BLOCKED_HOSTNAMES = new Set([
  "localhost", "ip6-localhost", "ip6-loopback", "broadcasthost",
]);

/** Returns true if the resolved IPv4/IPv6 string is in a private/special range. */
function isPrivateIp(ip: string): boolean {
  // IPv6 loopback or link-local
  if (ip === "::1" || ip.startsWith("fc") || ip.startsWith("fd") || ip.startsWith("fe80")) {
    return true;
  }
  // IPv4 mapped in IPv6 (::ffff:a.b.c.d)
  const v4mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
  const ipv4 = v4mapped ? v4mapped[1]! : ip;
  return PRIVATE_RANGES.some(r => ipv4.startsWith(r.prefix));
}

/**
 * Validate that a URL is safe to fetch: HTTPS only, not a private/loopback host.
 * Resolves the hostname via DNS and checks all returned IPs.
 * Returns an error string if blocked, null if safe.
 */
async function assertSafeUrl(urlStr: string): Promise<string | null> {
  let parsed: URL;
  try {
    parsed = new URL(urlStr);
  } catch {
    return `Invalid URL: ${urlStr}`;
  }

  if (parsed.protocol !== "https:") {
    return `LNURL fetch must use HTTPS (got ${parsed.protocol})`;
  }

  const host = parsed.hostname.toLowerCase();

  if (BLOCKED_HOSTNAMES.has(host)) {
    return `Blocked hostname: ${host}`;
  }

  // If hostname is a literal IP, check it directly
  if (/^[\d.]+$/.test(host) || host.startsWith("[")) {
    const bare = host.replace(/^\[/, "").replace(/\]$/, "");
    if (isPrivateIp(bare)) {
      return `SSRF blocked: IP ${bare} is in a private/reserved range`;
    }
    return null;
  }

  // Resolve hostname via DNS and check all returned addresses
  try {
    const { address } = await dns.lookup(host, { all: false });
    if (isPrivateIp(address)) {
      return `SSRF blocked: ${host} resolved to private IP ${address}`;
    }
  } catch (err) {
    return `DNS resolution failed for ${host}: ${err instanceof Error ? err.message : String(err)}`;
  }

  return null; // safe
}

export interface ZapRequest {
  recipientPubkey: string;
  amountSats: number;
  bolt11?: string;
  message?: string;
}

export interface ZapResult {
  skipped: boolean;
  reason?: string;
  eventId?: string;
  paymentHash?: string;
}

// ── LNURL-pay resolution helpers ──────────────────────────────────────────────

/**
 * Convert a lightning address (user@domain.com) to the LNURL-pay well-known URL.
 * Returns null if the input is not a valid address.
 */
function lightningAddressToUrl(address: string): string | null {
  const match = address.match(/^([^@]+)@(.+)$/);
  if (!match) return null;
  const [, user, domain] = match;
  return `https://${domain}/.well-known/lnurlp/${encodeURIComponent(user!)}`;
}

/**
 * Fetch a BOLT11 invoice for the given amount via the LNURL-pay protocol,
 * embedding a NIP-57 zap request event in the callback.
 *
 * Returns the invoice string, or null if any step fails.
 */
async function resolveLnurlInvoice(
  lightningAddress: string,
  amountMsats: number,
  zapRequestEvent: Record<string, unknown>,
): Promise<string | null> {
  const metaUrl = lightningAddressToUrl(lightningAddress);
  if (!metaUrl) {
    logger.warn("zap: invalid lightning address format", { lightningAddress });
    return null;
  }

  // SSRF guard: validate metadata URL before fetching
  const metaSafe = await assertSafeUrl(metaUrl);
  if (metaSafe) {
    logger.warn("zap: SSRF block on LNURL metadata URL", { metaUrl, reason: metaSafe });
    return null;
  }

  let callbackUrl: string;
  let minSendable: number;
  let maxSendable: number;

  // Step 1 — fetch LNURL-pay metadata
  try {
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), LNURL_TIMEOUT_MS);
    const metaRes = await fetch(metaUrl, { signal: ctrl.signal });
    clearTimeout(timer);

    if (!metaRes.ok) {
      logger.warn("zap: LNURL metadata fetch failed", { status: metaRes.status, metaUrl });
      return null;
    }

    const meta = await metaRes.json() as Record<string, unknown>;
    if (typeof meta["callback"] !== "string") {
      logger.warn("zap: LNURL metadata missing callback field", { metaUrl });
      return null;
    }

    callbackUrl  = meta["callback"] as string;
    minSendable  = typeof meta["minSendable"]  === "number" ? meta["minSendable"]  as number : 1000;
    maxSendable  = typeof meta["maxSendable"]  === "number" ? meta["maxSendable"]  as number : 10_000_000_000;
  } catch (err) {
    logger.warn("zap: LNURL metadata fetch error", {
      lightningAddress,
      error: err instanceof Error ? err.message : String(err),
    });
    return null;
  }

  // Validate amount within LNURL-pay limits
  if (amountMsats < minSendable || amountMsats > maxSendable) {
    logger.warn("zap: amount outside LNURL-pay bounds", { amountMsats, minSendable, maxSendable });
    return null;
  }

  // SSRF guard: validate callback URL returned by LNURL server before using it
  // This prevents a malicious LNURL server from redirecting Timmy to internal hosts.
  const callbackSafe = await assertSafeUrl(callbackUrl);
  if (callbackSafe) {
    logger.warn("zap: SSRF block on LNURL callback URL", { callbackUrl, reason: callbackSafe });
    return null;
  }

  // Step 2 — call LNURL-pay callback with zap request
  try {
    const params = new URLSearchParams({
      amount: amountMsats.toString(),
      nostr: JSON.stringify(zapRequestEvent),
    });
    const callbackFull = `${callbackUrl}${callbackUrl.includes("?") ? "&" : "?"}${params.toString()}`;

    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), LNURL_TIMEOUT_MS);
    const callRes = await fetch(callbackFull, { signal: ctrl.signal });
    clearTimeout(timer);

    if (!callRes.ok) {
      logger.warn("zap: LNURL callback failed", { status: callRes.status });
      return null;
    }

    const body = await callRes.json() as Record<string, unknown>;
    if (typeof body["pr"] !== "string") {
      logger.warn("zap: LNURL callback response missing pr field");
      return null;
    }

    return body["pr"] as string;
  } catch (err) {
    logger.warn("zap: LNURL callback error", {
      error: err instanceof Error ? err.message : String(err),
    });
    return null;
  }
}

// ── ZapService ────────────────────────────────────────────────────────────────

class ZapService {
  /** Calculate the zap amount for a given work fee, returns 0 if disabled. */
  zapAmountSats(workFeeSats: number, pct: number = ZAP_PCT_DEFAULT): number {
    if (pct <= 0) return 0;
    return Math.floor(workFeeSats * pct / 100);
  }

  /**
   * Send a zap from Timmy to a trusted partner.
   *
   * Resolution order for payment:
   *   1. Use provided bolt11 (caller already has invoice).
   *   2. Look up recipient's lightningAddress in nostr_identities and resolve
   *      a fresh invoice via LNURL-pay.
   *   3. If neither is available, log the zap event without a payment.
   *
   * The signed kind-9734 event is always logged to timmy_nostr_events.
   */
  async sendZap(req: ZapRequest): Promise<ZapResult> {
    const { recipientPubkey, amountSats, bolt11: providedBolt11, message } = req;

    if (amountSats < ZAP_MIN_SATS) {
      return { skipped: true, reason: `amount ${amountSats} < ZAP_MIN_SATS ${ZAP_MIN_SATS}` };
    }

    const amountMsats = amountSats * 1000;

    const tags: string[][] = [
      ["p", recipientPubkey],
      ["amount", amountMsats.toString()],
    ];

    if (RELAY_URL) {
      tags.push(["relays", RELAY_URL]);
    }

    const zapRequestEvent = timmyIdentityService.sign({
      kind: 9734,
      created_at: Math.floor(Date.now() / 1000),
      tags,
      content: message ?? "⚡ zap from Timmy",
    });

    // ── Resolve bolt11 ────────────────────────────────────────────────────────
    let resolvedBolt11: string | undefined = providedBolt11;

    if (!resolvedBolt11 && !lnbitsService.stubMode) {
      // Try LNURL-pay resolution from stored lightning address
      const rows = await db
        .select({ lightningAddress: nostrIdentities.lightningAddress })
        .from(nostrIdentities)
        .where(eq(nostrIdentities.pubkey, recipientPubkey));

      const lightningAddress = rows[0]?.lightningAddress ?? null;

      if (lightningAddress) {
        logger.info("zap: resolving LNURL-pay for recipient", {
          pubkey: recipientPubkey.slice(0, 8),
          lightningAddress,
        });
        const invoice = await resolveLnurlInvoice(lightningAddress, amountMsats, zapRequestEvent as unknown as Record<string, unknown>);
        if (invoice) {
          resolvedBolt11 = invoice;
        } else {
          logger.warn("zap: LNURL-pay resolution failed — event logged without payment", {
            pubkey: recipientPubkey.slice(0, 8),
          });
        }
      } else {
        logger.info("zap: no lightning address stored for recipient — event logged without payment", {
          pubkey: recipientPubkey.slice(0, 8),
        });
      }
    }

    // ── Pay invoice ───────────────────────────────────────────────────────────
    let paymentHash: string | undefined;

    if (resolvedBolt11 && !lnbitsService.stubMode) {
      try {
        paymentHash = await lnbitsService.payInvoice(resolvedBolt11);
        logger.info("zap payment sent", { amountSats, paymentHash, pubkey: recipientPubkey.slice(0, 8) });
      } catch (err) {
        logger.warn("zap payment failed — event logged anyway", {
          error: err instanceof Error ? err.message : String(err),
        });
      }
    } else if (lnbitsService.stubMode) {
      paymentHash = "stub_" + zapRequestEvent.id.slice(0, 16);
      logger.info("zap stub payment simulated", { amountSats, paymentHash });
    }

    // ── Log event ─────────────────────────────────────────────────────────────
    await db.insert(timmyNostrEvents).values({
      id: randomUUID(),
      kind: 9734,
      recipientPubkey,
      eventJson: JSON.stringify(zapRequestEvent),
      amountSats,
      relayUrl: RELAY_URL || null,
    });

    logger.info("zap event logged", {
      eventId: zapRequestEvent.id,
      pubkey: recipientPubkey.slice(0, 8),
      amountSats,
      paid: !!paymentHash,
    });

    return { skipped: false, eventId: zapRequestEvent.id, paymentHash };
  }

  /**
   * Called from the job completion path.
   * Only fires if trust tier warrants it and ZAP_PCT_DEFAULT > 0.
   */
  async maybeZapOnJobComplete(opts: {
    pubkey: string | null;
    workFeeSats: number;
    tier: string;
  }): Promise<void> {
    const { pubkey, workFeeSats, tier } = opts;
    if (!pubkey) return;
    if (ZAP_PCT_DEFAULT <= 0) return;
    if (tier !== "trusted" && tier !== "elite") return;

    const amount = this.zapAmountSats(workFeeSats);
    if (amount < ZAP_MIN_SATS) return;

    const result = await this.sendZap({
      recipientPubkey: pubkey,
      amountSats: amount,
      message: "Thanks for being a trusted partner! ⚡",
    }).catch(err => {
      logger.warn("zapOnJobComplete failed silently", { error: err instanceof Error ? err.message : String(err) });
      return { skipped: true, reason: "error" } as ZapResult;
    });

    if (!result.skipped) {
      logger.info("zap sent on job complete", { pubkey: pubkey.slice(0, 8), amount, tier });
    }
  }
}

export const zapService = new ZapService();
