/**
 * Nostr Profile HTTP Handler
 *
 * Handles HTTP requests for profile management:
 * - PUT /api/channels/nostr/:accountId/profile - Update and publish profile
 * - POST /api/channels/nostr/:accountId/profile/import - Import from relays
 * - GET /api/channels/nostr/:accountId/profile - Get current profile state
 */

import type { IncomingMessage, ServerResponse } from "node:http";
import { readJsonBodyWithLimit, requestBodyErrorToText } from "openclaw/plugin-sdk";
import { z } from "zod";
import { publishNostrProfile, getNostrProfileState } from "./channel.js";
import { NostrProfileSchema, type NostrProfile } from "./config-schema.js";
import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js";

// ============================================================================
// Types
// ============================================================================

export interface NostrProfileHttpContext {
  /** Get current profile from config */
  getConfigProfile: (accountId: string) => NostrProfile | undefined;
  /** Update profile in config (after successful publish) */
  updateConfigProfile: (accountId: string, profile: NostrProfile) => Promise<void>;
  /** Get account's public key and relays */
  getAccountInfo: (accountId: string) => { pubkey: string; relays: string[] } | null;
  /** Logger */
  log?: {
    info: (msg: string) => void;
    warn: (msg: string) => void;
    error: (msg: string) => void;
  };
}

// ============================================================================
// Rate Limiting
// ============================================================================

interface RateLimitEntry {
  count: number;
  windowStart: number;
}

const rateLimitMap = new Map<string, RateLimitEntry>();
const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
const RATE_LIMIT_MAX_REQUESTS = 5; // 5 requests per minute

function checkRateLimit(accountId: string): boolean {
  const now = Date.now();
  const entry = rateLimitMap.get(accountId);

  if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
    rateLimitMap.set(accountId, { count: 1, windowStart: now });
    return true;
  }

  if (entry.count >= RATE_LIMIT_MAX_REQUESTS) {
    return false;
  }

  entry.count++;
  return true;
}

// ============================================================================
// Mutex for Concurrent Publish Prevention
// ============================================================================

const publishLocks = new Map<string, Promise<void>>();

async function withPublishLock<T>(accountId: string, fn: () => Promise<T>): Promise<T> {
  // Atomic mutex using promise chaining - prevents TOCTOU race condition
  const prev = publishLocks.get(accountId) ?? Promise.resolve();
  let resolve: () => void;
  const next = new Promise<void>((r) => {
    resolve = r;
  });
  // Atomically replace the lock before awaiting - any concurrent request
  // will now wait on our `next` promise
  publishLocks.set(accountId, next);

  // Wait for previous operation to complete
  await prev.catch(() => {});

  try {
    return await fn();
  } finally {
    resolve!();
    // Clean up if we're the last in chain
    if (publishLocks.get(accountId) === next) {
      publishLocks.delete(accountId);
    }
  }
}

// ============================================================================
// SSRF Protection
// ============================================================================

// Block common private/internal hostnames (quick string check)
const BLOCKED_HOSTNAMES = new Set([
  "localhost",
  "localhost.localdomain",
  "127.0.0.1",
  "::1",
  "[::1]",
  "0.0.0.0",
]);

// Check if an IP address (resolved) is in a private range
function isPrivateIp(ip: string): boolean {
  // Handle IPv4
  const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
  if (ipv4Match) {
    const [, a, b] = ipv4Match.map(Number);
    // 127.0.0.0/8 (loopback)
    if (a === 127) {
      return true;
    }
    // 10.0.0.0/8 (private)
    if (a === 10) {
      return true;
    }
    // 172.16.0.0/12 (private)
    if (a === 172 && b >= 16 && b <= 31) {
      return true;
    }
    // 192.168.0.0/16 (private)
    if (a === 192 && b === 168) {
      return true;
    }
    // 169.254.0.0/16 (link-local)
    if (a === 169 && b === 254) {
      return true;
    }
    // 0.0.0.0/8
    if (a === 0) {
      return true;
    }
    return false;
  }

  // Handle IPv6
  const ipLower = ip.toLowerCase().replace(/^\[|\]$/g, "");
  // ::1 (loopback)
  if (ipLower === "::1") {
    return true;
  }
  // fe80::/10 (link-local)
  if (ipLower.startsWith("fe80:")) {
    return true;
  }
  // fc00::/7 (unique local)
  if (ipLower.startsWith("fc") || ipLower.startsWith("fd")) {
    return true;
  }
  // ::ffff:x.x.x.x (IPv4-mapped IPv6) - extract and check IPv4
  const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
  if (v4Mapped) {
    return isPrivateIp(v4Mapped[1]);
  }

  return false;
}

function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: string } {
  try {
    const url = new URL(urlStr);

    if (url.protocol !== "https:") {
      return { ok: false, error: "URL must use https:// protocol" };
    }

    const hostname = url.hostname.toLowerCase();

    // Quick hostname block check
    if (BLOCKED_HOSTNAMES.has(hostname)) {
      return { ok: false, error: "URL must not point to private/internal addresses" };
    }

    // Check if hostname is an IP address directly
    if (isPrivateIp(hostname)) {
      return { ok: false, error: "URL must not point to private/internal addresses" };
    }

    // Block suspicious TLDs that resolve to localhost
    if (hostname.endsWith(".localhost") || hostname.endsWith(".local")) {
      return { ok: false, error: "URL must not point to private/internal addresses" };
    }

    return { ok: true };
  } catch {
    return { ok: false, error: "Invalid URL format" };
  }
}

// Export for use in import validation
export { validateUrlSafety };

// ============================================================================
// Validation Schemas
// ============================================================================

// NIP-05 format: user@domain.com
const nip05FormatSchema = z
  .string()
  .regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid NIP-05 format (user@domain.com)")
  .optional();

// LUD-16 Lightning address format: user@domain.com
const lud16FormatSchema = z
  .string()
  .regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid Lightning address format")
  .optional();

// Extended profile schema with additional format validation
const ProfileUpdateSchema = NostrProfileSchema.extend({
  nip05: nip05FormatSchema,
  lud16: lud16FormatSchema,
});

// ============================================================================
// Request Helpers
// ============================================================================

function sendJson(res: ServerResponse, status: number, body: unknown): void {
  res.statusCode = status;
  res.setHeader("Content-Type", "application/json; charset=utf-8");
  res.end(JSON.stringify(body));
}

async function readJsonBody(
  req: IncomingMessage,
  maxBytes = 64 * 1024,
  timeoutMs = 30_000,
): Promise<unknown> {
  const result = await readJsonBodyWithLimit(req, {
    maxBytes,
    timeoutMs,
    emptyObjectOnEmpty: true,
  });
  if (result.ok) {
    return result.value;
  }
  if (result.code === "PAYLOAD_TOO_LARGE") {
    throw new Error("Request body too large");
  }
  if (result.code === "REQUEST_BODY_TIMEOUT") {
    throw new Error(requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
  }
  if (result.code === "CONNECTION_CLOSED") {
    throw new Error(requestBodyErrorToText("CONNECTION_CLOSED"));
  }
  throw new Error(result.code === "INVALID_JSON" ? "Invalid JSON" : result.error);
}

function parseAccountIdFromPath(pathname: string): string | null {
  // Match: /api/channels/nostr/:accountId/profile
  const match = pathname.match(/^\/api\/channels\/nostr\/([^/]+)\/profile/);
  return match?.[1] ?? null;
}

function isLoopbackRemoteAddress(remoteAddress: string | undefined): boolean {
  if (!remoteAddress) {
    return false;
  }

  const ipLower = remoteAddress.toLowerCase().replace(/^\[|\]$/g, "");

  // IPv6 loopback
  if (ipLower === "::1") {
    return true;
  }

  // IPv4 loopback (127.0.0.0/8)
  if (ipLower === "127.0.0.1" || ipLower.startsWith("127.")) {
    return true;
  }

  // IPv4-mapped IPv6
  const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
  if (v4Mapped) {
    return isLoopbackRemoteAddress(v4Mapped[1]);
  }

  return false;
}

function isLoopbackOriginLike(value: string): boolean {
  try {
    const url = new URL(value);
    const hostname = url.hostname.toLowerCase();
    return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
  } catch {
    return false;
  }
}

function enforceLoopbackMutationGuards(
  ctx: NostrProfileHttpContext,
  req: IncomingMessage,
  res: ServerResponse,
): boolean {
  // Mutation endpoints are local-control-plane only.
  const remoteAddress = req.socket.remoteAddress;
  if (!isLoopbackRemoteAddress(remoteAddress)) {
    ctx.log?.warn?.(`Rejected mutation from non-loopback remoteAddress=${String(remoteAddress)}`);
    sendJson(res, 403, { ok: false, error: "Forbidden" });
    return false;
  }

  // CSRF guard: browsers send Origin/Referer on cross-site requests.
  const origin = req.headers.origin;
  if (typeof origin === "string" && !isLoopbackOriginLike(origin)) {
    ctx.log?.warn?.(`Rejected mutation with non-loopback origin=${origin}`);
    sendJson(res, 403, { ok: false, error: "Forbidden" });
    return false;
  }

  const referer = req.headers.referer ?? req.headers.referrer;
  if (typeof referer === "string" && !isLoopbackOriginLike(referer)) {
    ctx.log?.warn?.(`Rejected mutation with non-loopback referer=${referer}`);
    sendJson(res, 403, { ok: false, error: "Forbidden" });
    return false;
  }

  return true;
}

// ============================================================================
// HTTP Handler
// ============================================================================

export function createNostrProfileHttpHandler(
  ctx: NostrProfileHttpContext,
): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
  return async (req, res) => {
    const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);

    // Only handle /api/channels/nostr/:accountId/profile paths
    if (!url.pathname.startsWith("/api/channels/nostr/")) {
      return false;
    }

    const accountId = parseAccountIdFromPath(url.pathname);
    if (!accountId) {
      return false;
    }

    const isImport = url.pathname.endsWith("/profile/import");
    const isProfilePath = url.pathname.endsWith("/profile") || isImport;

    if (!isProfilePath) {
      return false;
    }

    // Handle different HTTP methods
    try {
      if (req.method === "GET" && !isImport) {
        return await handleGetProfile(accountId, ctx, res);
      }

      if (req.method === "PUT" && !isImport) {
        return await handleUpdateProfile(accountId, ctx, req, res);
      }

      if (req.method === "POST" && isImport) {
        return await handleImportProfile(accountId, ctx, req, res);
      }

      // Method not allowed
      sendJson(res, 405, { ok: false, error: "Method not allowed" });
      return true;
    } catch (err) {
      ctx.log?.error(`Profile HTTP error: ${String(err)}`);
      sendJson(res, 500, { ok: false, error: "Internal server error" });
      return true;
    }
  };
}

// ============================================================================
// GET /api/channels/nostr/:accountId/profile
// ============================================================================

async function handleGetProfile(
  accountId: string,
  ctx: NostrProfileHttpContext,
  res: ServerResponse,
): Promise<true> {
  const configProfile = ctx.getConfigProfile(accountId);
  const publishState = await getNostrProfileState(accountId);

  sendJson(res, 200, {
    ok: true,
    profile: configProfile ?? null,
    publishState: publishState ?? null,
  });
  return true;
}

// ============================================================================
// PUT /api/channels/nostr/:accountId/profile
// ============================================================================

async function handleUpdateProfile(
  accountId: string,
  ctx: NostrProfileHttpContext,
  req: IncomingMessage,
  res: ServerResponse,
): Promise<true> {
  if (!enforceLoopbackMutationGuards(ctx, req, res)) {
    return true;
  }

  // Rate limiting
  if (!checkRateLimit(accountId)) {
    sendJson(res, 429, { ok: false, error: "Rate limit exceeded (5 requests/minute)" });
    return true;
  }

  // Parse body
  let body: unknown;
  try {
    body = await readJsonBody(req);
  } catch (err) {
    sendJson(res, 400, { ok: false, error: String(err) });
    return true;
  }

  // Validate profile
  const parseResult = ProfileUpdateSchema.safeParse(body);
  if (!parseResult.success) {
    const errors = parseResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
    sendJson(res, 400, { ok: false, error: "Validation failed", details: errors });
    return true;
  }

  const profile = parseResult.data;

  // SSRF check for picture URL
  if (profile.picture) {
    const pictureCheck = validateUrlSafety(profile.picture);
    if (!pictureCheck.ok) {
      sendJson(res, 400, { ok: false, error: `picture: ${pictureCheck.error}` });
      return true;
    }
  }

  // SSRF check for banner URL
  if (profile.banner) {
    const bannerCheck = validateUrlSafety(profile.banner);
    if (!bannerCheck.ok) {
      sendJson(res, 400, { ok: false, error: `banner: ${bannerCheck.error}` });
      return true;
    }
  }

  // SSRF check for website URL
  if (profile.website) {
    const websiteCheck = validateUrlSafety(profile.website);
    if (!websiteCheck.ok) {
      sendJson(res, 400, { ok: false, error: `website: ${websiteCheck.error}` });
      return true;
    }
  }

  // Merge with existing profile to preserve unknown fields
  const existingProfile = ctx.getConfigProfile(accountId) ?? {};
  const mergedProfile: NostrProfile = {
    ...existingProfile,
    ...profile,
  };

  // Publish with mutex to prevent concurrent publishes
  try {
    const result = await withPublishLock(accountId, async () => {
      return await publishNostrProfile(accountId, mergedProfile);
    });

    // Only persist if at least one relay succeeded
    if (result.successes.length > 0) {
      await ctx.updateConfigProfile(accountId, mergedProfile);
      ctx.log?.info(`[${accountId}] Profile published to ${result.successes.length} relay(s)`);
    } else {
      ctx.log?.warn(`[${accountId}] Profile publish failed on all relays`);
    }

    sendJson(res, 200, {
      ok: true,
      eventId: result.eventId,
      createdAt: result.createdAt,
      successes: result.successes,
      failures: result.failures,
      persisted: result.successes.length > 0,
    });
  } catch (err) {
    ctx.log?.error(`[${accountId}] Profile publish error: ${String(err)}`);
    sendJson(res, 500, { ok: false, error: `Publish failed: ${String(err)}` });
  }

  return true;
}

// ============================================================================
// POST /api/channels/nostr/:accountId/profile/import
// ============================================================================

async function handleImportProfile(
  accountId: string,
  ctx: NostrProfileHttpContext,
  req: IncomingMessage,
  res: ServerResponse,
): Promise<true> {
  if (!enforceLoopbackMutationGuards(ctx, req, res)) {
    return true;
  }

  // Get account info
  const accountInfo = ctx.getAccountInfo(accountId);
  if (!accountInfo) {
    sendJson(res, 404, { ok: false, error: `Account not found: ${accountId}` });
    return true;
  }

  const { pubkey, relays } = accountInfo;

  if (!pubkey) {
    sendJson(res, 400, { ok: false, error: "Account has no public key configured" });
    return true;
  }

  // Parse options from body
  let autoMerge = false;
  try {
    const body = await readJsonBody(req);
    if (typeof body === "object" && body !== null) {
      autoMerge = (body as { autoMerge?: boolean }).autoMerge === true;
    }
  } catch {
    // Ignore body parse errors - use defaults
  }

  ctx.log?.info(`[${accountId}] Importing profile for ${pubkey.slice(0, 8)}...`);

  // Import from relays
  const result = await importProfileFromRelays({
    pubkey,
    relays,
    timeoutMs: 10_000, // 10 seconds for import
  });

  if (!result.ok) {
    sendJson(res, 200, {
      ok: false,
      error: result.error,
      relaysQueried: result.relaysQueried,
    });
    return true;
  }

  // If autoMerge is requested, merge and save
  if (autoMerge && result.profile) {
    const localProfile = ctx.getConfigProfile(accountId);
    const merged = mergeProfiles(localProfile, result.profile);
    await ctx.updateConfigProfile(accountId, merged);
    ctx.log?.info(`[${accountId}] Profile imported and merged`);

    sendJson(res, 200, {
      ok: true,
      imported: result.profile,
      merged,
      saved: true,
      event: result.event,
      sourceRelay: result.sourceRelay,
      relaysQueried: result.relaysQueried,
    });
    return true;
  }

  // Otherwise, just return the imported profile for review
  sendJson(res, 200, {
    ok: true,
    imported: result.profile,
    saved: false,
    event: result.event,
    sourceRelay: result.sourceRelay,
    relaysQueried: result.relaysQueried,
  });
  return true;
}
