/**
 * OpenSRS (Tucows) XML-RPC client.
 *
 * https://domains.opensrs.guide/
 *
 * Auth: MD5 double-hash over `xml + api_key`, per OpenSRS docs.
 *   sig = md5( md5(xml + key) + key )
 *   headers: X-Username, X-Signature, Content-Type: text/xml
 *
 * TLS:
 *   - live (rr-n1-tor.opensrs.net) — publicly-trusted cert, strict verify.
 *   - test (horizon.opensrs.net)   — self-signed, relaxed verify. This is
 *     intentional, documented OpenSRS behavior. We only relax in `test` mode
 *     and never in `live`.
 *
 * Registry quirks baked in:
 *   - `.ai` requires period >= 2 years (registry rule)
 *   - `.ca` requires CPR fields (reg_type + legal_type on contact) — caller
 *     supplies them; we validate presence before the call.
 *
 * Nameservers:
 *   - Sandbox registrations use ns1.systemdns.com / ns2.systemdns.com
 *     (OpenSRS test requirement — Horizon won't accept arbitrary nameservers).
 *   - Live registrations use the workspace's attach-time nameservers or the
 *     Vibn default set if none given.
 */

import { createHash } from 'crypto';
import { request as httpsRequest } from 'https';

export type OpenSrsMode = 'test' | 'live';

export interface OpenSrsConfig {
  resellerUsername: string;
  apiKey: string;
  mode: OpenSrsMode;
  host?: string;   // override (defaults inferred from mode)
  port?: number;
  /** Called once per raw HTTP response — useful for logging/tests. */
  onResponse?: (info: { action: string; object: string; ms: number; httpStatus: number }) => void;
}

function defaultHost(mode: OpenSrsMode): string {
  return mode === 'live' ? 'rr-n1-tor.opensrs.net' : 'horizon.opensrs.net';
}

export class OpenSrsError extends Error {
  constructor(
    public readonly code: string,
    public readonly action: string,
    message: string,
    public readonly body?: string,
  ) {
    super(`[opensrs ${action} ${code}] ${message}`);
    this.name = 'OpenSrsError';
  }
}

// ──────────────────────────────────────────────────
// Low-level transport
// ──────────────────────────────────────────────────

function signature(xml: string, apiKey: string): string {
  const first = createHash('md5').update(xml + apiKey).digest('hex');
  return createHash('md5').update(first + apiKey).digest('hex');
}

interface HttpsPostArgs {
  host: string;
  port: number;
  path: string;
  body: string;
  headers: Record<string, string>;
  rejectUnauthorized: boolean;
}

function httpsPost(args: HttpsPostArgs): Promise<{ text: string; status: number }> {
  return new Promise((resolve, reject) => {
    const req = httpsRequest(
      {
        host: args.host,
        port: args.port,
        path: args.path,
        method: 'POST',
        headers: args.headers,
        rejectUnauthorized: args.rejectUnauthorized,
      },
      res => {
        const chunks: Buffer[] = [];
        res.on('data', c => chunks.push(c));
        res.on('end', () => {
          resolve({
            text: Buffer.concat(chunks).toString('utf8'),
            status: res.statusCode ?? 0,
          });
        });
      },
    );
    req.on('error', reject);
    req.write(args.body);
    req.end();
  });
}

function configFromEnv(overrides?: Partial<OpenSrsConfig>): OpenSrsConfig {
  const mode = (overrides?.mode ?? (process.env.OPENSRS_MODE as OpenSrsMode) ?? 'test') as OpenSrsMode;
  const resellerUsername =
    overrides?.resellerUsername ??
    process.env.OPENSRS_RESELLER_USERNAME ??
    '';
  const apiKey =
    overrides?.apiKey ??
    (mode === 'live'
      ? process.env.OPENSRS_API_KEY_LIVE ?? ''
      : process.env.OPENSRS_API_KEY_TEST ?? '');
  if (!resellerUsername || !apiKey) {
    throw new Error(
      `OpenSRS ${mode} credentials missing (OPENSRS_RESELLER_USERNAME / OPENSRS_API_KEY_${mode.toUpperCase()})`,
    );
  }
  return {
    resellerUsername,
    apiKey,
    mode,
    host: overrides?.host ?? defaultHost(mode),
    port: overrides?.port ?? 55443,
    onResponse: overrides?.onResponse,
  };
}

/**
 * Low-level call. Returns parsed attribute map on success. Throws
 * OpenSrsError on API-level failure (is_success != 1).
 */
async function rawCall(
  action: string,
  object: string,
  attrs: Record<string, unknown>,
  cfg: OpenSrsConfig,
): Promise<ParsedResponse> {
  const xml = buildEnvelope(action, object, attrs);
  const sig = signature(xml, cfg.apiKey);

  // Use the native https module so we can disable cert verification for the
  // test endpoint (Horizon uses a self-signed cert by design) without the
  // process-wide NODE_TLS_REJECT_UNAUTHORIZED hack.
  const started = Date.now();
  const { text, status } = await httpsPost({
    host: cfg.host!,
    port: cfg.port ?? 55443,
    path: '/',
    body: xml,
    headers: {
      'Content-Type': 'text/xml',
      'X-Username': cfg.resellerUsername,
      'X-Signature': sig,
      'Content-Length': String(Buffer.byteLength(xml)),
    },
    rejectUnauthorized: cfg.mode === 'live',
  });
  cfg.onResponse?.({ action, object, ms: Date.now() - started, httpStatus: status });

  if (status !== 200) {
    throw new OpenSrsError(String(status), action, `HTTP ${status}`, text);
  }

  const parsed = parseEnvelope(text);
  if (parsed.is_success !== '1') {
    throw new OpenSrsError(
      parsed.response_code || 'unknown',
      action,
      parsed.response_text || 'OpenSRS call failed',
      text,
    );
  }
  return parsed;
}

// ──────────────────────────────────────────────────
// XML envelope helpers
// ──────────────────────────────────────────────────

function buildEnvelope(action: string, object: string, attrs: Record<string, unknown>): string {
  return `<?xml version='1.0' encoding='UTF-8' standalone='no'?>
<!DOCTYPE OPS_envelope SYSTEM 'ops.dtd'>
<OPS_envelope>
 <header><version>0.9</version></header>
 <body>
  <data_block>
   <dt_assoc>
    <item key="protocol">XCP</item>
    <item key="action">${xmlEsc(action)}</item>
    <item key="object">${xmlEsc(object)}</item>
    <item key="attributes">${renderValue(attrs)}</item>
   </dt_assoc>
  </data_block>
 </body>
</OPS_envelope>`;
}

function renderValue(value: unknown): string {
  if (value === null || value === undefined) return '';
  if (Array.isArray(value)) {
    const items = value
      .map((v, i) => `<item key="${i}">${renderValue(v)}</item>`)
      .join('');
    return `<dt_array>${items}</dt_array>`;
  }
  if (typeof value === 'object') {
    const items = Object.entries(value as Record<string, unknown>)
      .map(([k, v]) => `<item key="${xmlEsc(k)}">${renderValue(v)}</item>`)
      .join('');
    return `<dt_assoc>${items}</dt_assoc>`;
  }
  return xmlEsc(String(value));
}

function xmlEsc(s: string): string {
  return s
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;');
}

interface ParsedResponse {
  is_success: string;
  response_code: string;
  response_text: string;
  attributes: Record<string, string>;
  /** Raw body for debugging. */
  _raw: string;
}

/**
 * Tiny, purpose-built parser for OpenSRS XML responses.
 *
 * OpenSRS responses are deterministic and flat enough that a small
 * regex-based extractor is sufficient. If we ever need nested
 * `<dt_assoc>` traversal (eg. domain all_info), swap to a real parser.
 */
function parseEnvelope(body: string): ParsedResponse {
  const grab = (key: string) => {
    const re = new RegExp(`key="${key}">([^<]*)<`, 'i');
    const m = body.match(re);
    return m ? m[1] : '';
  };
  // Flatten top-level attribute keys (one level deep under `<item key="attributes">`)
  const attrs: Record<string, string> = {};
  const attrBlock = body.match(/key="attributes">\s*<dt_assoc>([\s\S]*?)<\/dt_assoc>/i);
  if (attrBlock) {
    const inner = attrBlock[1];
    const re = /<item\s+key="([^"]+)">([\s\S]*?)<\/item>/g;
    let m: RegExpExecArray | null;
    while ((m = re.exec(inner)) !== null) {
      const k = m[1];
      const v = m[2].trim();
      // Leaf values only; nested dt_assoc/dt_array are left as raw XML.
      if (!/<dt_(assoc|array)>/.test(v)) {
        attrs[k] = v.replace(/^<!\[CDATA\[|\]\]>$/g, '');
      } else {
        attrs[k] = v;
      }
    }
  }
  return {
    is_success: grab('is_success'),
    response_code: grab('response_code'),
    response_text: grab('response_text'),
    attributes: attrs,
    _raw: body,
  };
}

// ──────────────────────────────────────────────────
// High-level API
// ──────────────────────────────────────────────────

export interface DomainAvailability {
  domain: string;
  available: boolean;
  responseCode: string;
  responseText: string;
}

export async function lookupDomain(
  domain: string,
  overrides?: Partial<OpenSrsConfig>,
): Promise<DomainAvailability> {
  const cfg = configFromEnv(overrides);
  const parsed = await rawCall('lookup', 'domain', { domain }, cfg);
  const status = parsed.attributes.status || '';
  return {
    domain,
    available: status.toLowerCase() === 'available',
    responseCode: parsed.response_code,
    responseText: parsed.response_text,
  };
}

export interface DomainPrice {
  domain: string;
  period: number;
  price: string | null;
  currency: string;
}

export async function getDomainPrice(
  domain: string,
  period: number,
  overrides?: Partial<OpenSrsConfig>,
): Promise<DomainPrice> {
  const tld = domainTld(domain);
  const effectivePeriod = minPeriodFor(tld, period);
  const cfg = configFromEnv(overrides);
  const parsed = await rawCall(
    'get_price',
    'domain',
    { domain, period: effectivePeriod },
    cfg,
  );
  return {
    domain,
    period: effectivePeriod,
    price: parsed.attributes.price ?? null,
    currency: process.env.OPENSRS_CURRENCY ?? 'CAD',
  };
}

/**
 * Combined availability + price check. Used by `domains.search`.
 * Swallows price-lookup errors — an unavailable-or-expensive TLD should still
 * return a useful availability row.
 */
export async function checkDomain(
  domain: string,
  period = 1,
  overrides?: Partial<OpenSrsConfig>,
): Promise<DomainAvailability & Partial<DomainPrice>> {
  const avail = await lookupDomain(domain, overrides);
  if (!avail.available) return avail;
  try {
    const price = await getDomainPrice(domain, period, overrides);
    return { ...avail, ...price };
  } catch (err) {
    if (err instanceof OpenSrsError) {
      return { ...avail, price: null, period: minPeriodFor(domainTld(domain), period), currency: process.env.OPENSRS_CURRENCY ?? 'CAD' };
    }
    throw err;
  }
}

export interface RegistrationContact {
  first_name: string;
  last_name: string;
  org_name?: string;
  address1: string;
  address2?: string;
  city: string;
  state: string;   // province for .ca
  country: string; // ISO 2-letter (e.g. "CA")
  postal_code: string;
  phone: string;   // E.164-ish, e.g. +1.4165551234
  fax?: string;
  email: string;
}

export interface RegistrationInput {
  domain: string;
  period?: number;
  contact: RegistrationContact;
  nameservers?: string[];
  /** Workspace-scoped manage-user credentials. Generated if omitted. */
  regUsername?: string;
  regPassword?: string;
  whoisPrivacy?: boolean;
  /** Caller IP for the OpenSRS audit log; uses Vibn server IP if unset. */
  registrantIp?: string;

  /** Required for .ca — CIRA Presence Requirements. */
  ca?: {
    /** CCT, CCO, RES, ABO, LGR, GOV, EDU, HOP, … */
    cprCategory: string;
    /** CCT (Canadian citizen), CCO (Canadian corp), etc. */
    legalType: string;
    wantsCiraMemberDiscount?: boolean;
  };
}

export interface RegistrationResult {
  domain: string;
  orderId: string;
  responseCode: string;
  responseText: string;
  regUsername: string;
  regPassword: string;
}

/**
 * Register a domain. Assumes reseller funds cover the purchase (OpenSRS
 * debits the reseller balance — caller should verify balance upstream).
 *
 * Returns the OpenSRS order id. Actual domain activation is usually
 * immediate (cctlds) or async for some TLDs — callers should poll
 * `getOrderStatus` or use OpenSRS events to confirm.
 */
export async function registerDomain(
  input: RegistrationInput,
  overrides?: Partial<OpenSrsConfig>,
): Promise<RegistrationResult> {
  const tld = domainTld(input.domain);
  const period = minPeriodFor(tld, input.period ?? 1);

  if (tld === 'ca' && !input.ca) {
    throw new Error('.ca registration requires ca.cprCategory and ca.legalType');
  }

  const cfg = configFromEnv(overrides);
  const regUsername = input.regUsername ?? generateHandle(input.domain);
  const regPassword = input.regPassword ?? generateRandomPassword();

  const nameservers = (
    input.nameservers && input.nameservers.length >= 2
      ? input.nameservers
      : defaultNameservers(cfg.mode)
  ).map(ns => ns.trim().replace(/\.+$/, '').toLowerCase());

  const contactSet = {
    owner: input.contact,
    admin: input.contact,
    billing: input.contact,
    tech: input.contact,
  };

  const attrs: Record<string, unknown> = {
    domain: input.domain,
    period,
    reg_username: regUsername,
    reg_password: regPassword,
    reg_type: 'new',
    auto_renew: 0,
    // Intentionally register UNLOCKED. Locking immediately would block the
    // first nameserver update that the attach flow needs. Vibn calls
    // `setDomainLock(domain, true)` after DNS is fully wired (see
    // lib/domain-attach.ts for the sequence).
    f_lock_domain: 0,
    f_whois_privacy: input.whoisPrivacy === false ? 0 : 1,
    handle: 'process',
    contact_set: contactSet,
    nameserver_list: nameservers.map((host, i) => ({
      sortorder: i + 1,
      name: host,
    })),
    custom_nameservers: input.nameservers ? 1 : 0,
    custom_tech_contact: 0,
  };

  if (input.ca) {
    attrs.tld_data = {
      ca_owner: {
        legal_type: input.ca.legalType,
        lang: 'en',
        cpr: input.ca.cprCategory,
      },
    };
  }

  const parsed = await rawCall('sw_register', 'domain', attrs, cfg);
  return {
    domain: input.domain,
    orderId: parsed.attributes.id || parsed.attributes.order_id || '',
    responseCode: parsed.response_code,
    responseText: parsed.response_text,
    regUsername,
    regPassword,
  };
}

/**
 * Replace the registrar-side nameserver list for a domain. Use this after
 * creating a Cloud DNS zone so the registry points at the right NS set.
 *
 * Note: OpenSRS requires the new nameservers to already be registered as
 * host records under the registrar, OR to be resolvable in DNS (Cloud DNS
 * nameservers are always resolvable, so this works out of the box for us).
 */
export async function updateDomainNameservers(
  domain: string,
  nameservers: string[],
  overrides?: Partial<OpenSrsConfig>,
): Promise<{ responseCode: string; responseText: string }> {
  if (!nameservers || nameservers.length < 2) {
    throw new Error('At least two nameservers are required');
  }
  // OpenSRS rejects FQDN-style nameservers (with trailing dot). Cloud DNS
  // returns NS records as `ns-cloud-eX.googledomains.com.` so we must strip
  // the trailing dot before handing them to the registrar.
  const normalized = nameservers.map(ns => ns.trim().replace(/\.+$/, '').toLowerCase());
  const cfg = configFromEnv(overrides);
  const parsed = await rawCall(
    'advanced_update_nameservers',
    'domain',
    {
      domain,
      op_type: 'assign',
      assign_ns: normalized,
    },
    cfg,
  );
  return { responseCode: parsed.response_code, responseText: parsed.response_text };
}

/**
 * Lock/unlock a domain at the registrar. Locking prevents transfers away
 * and is Vibn's default post-attach state. Unlocking is required briefly
 * for nameserver changes on TLDs that gate NS updates behind the lock
 * (eg. some gTLDs return 405 "Object status prohibits operation").
 */
export async function setDomainLock(
  domain: string,
  locked: boolean,
  overrides?: Partial<OpenSrsConfig>,
): Promise<{ responseCode: string; responseText: string }> {
  const cfg = configFromEnv(overrides);
  const parsed = await rawCall(
    'modify',
    'domain',
    {
      domain_name: domain,
      affect_domains: 0,
      data: 'status',
      lock_state: locked ? 1 : 0,
    },
    cfg,
  );
  return { responseCode: parsed.response_code, responseText: parsed.response_text };
}

// ──────────────────────────────────────────────────
// Reseller balance + misc
// ──────────────────────────────────────────────────

export interface ResellerBalance {
  balance: string;
  holdBalance: string;
  currency: string;
}

export async function getResellerBalance(
  overrides?: Partial<OpenSrsConfig>,
): Promise<ResellerBalance> {
  const cfg = configFromEnv(overrides);
  const parsed = await rawCall('get_balance', 'balance', {}, cfg);
  return {
    balance: parsed.attributes.balance || '0',
    holdBalance: parsed.attributes.hold_balance || '0',
    currency: process.env.OPENSRS_CURRENCY ?? 'CAD',
  };
}

// ──────────────────────────────────────────────────
// Utilities
// ──────────────────────────────────────────────────

export function domainTld(domain: string): string {
  const parts = domain.toLowerCase().trim().split('.');
  return parts[parts.length - 1] ?? '';
}

/**
 * Registry-minimum period enforcement. Add entries here as we see them.
 *   - .ai = 2 years
 *   - .au = 2 years (if we ever enable)
 */
export function minPeriodFor(tld: string, requested: number): number {
  if (tld === 'ai') return Math.max(2, requested);
  return Math.max(1, requested);
}

function defaultNameservers(mode: OpenSrsMode): string[] {
  if (mode === 'test') return ['ns1.systemdns.com', 'ns2.systemdns.com'];
  // Live: use Cloud DNS names once a zone is attached. Until then we fall back
  // to systemdns so registration succeeds — records can be updated later.
  return (
    process.env.OPENSRS_DEFAULT_NS?.split(',').map(s => s.trim()).filter(Boolean) ?? [
      'ns1.systemdns.com',
      'ns2.systemdns.com',
    ]
  );
}

function generateHandle(domain: string): string {
  const base = domain.replace(/[^a-z0-9]/gi, '').slice(0, 10).toLowerCase();
  const suffix = Math.random().toString(36).slice(2, 8);
  return `vibn${base}${suffix}`;
}

function generateRandomPassword(): string {
  // Alphanumeric only — OpenSRS has been touchy about some punctuation.
  const alpha = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';
  let out = '';
  for (let i = 0; i < 18; i++) out += alpha[Math.floor(Math.random() * alpha.length)];
  return out;
}
