import { createHash, randomUUID } from 'node:crypto';
import { schnorr } from '@noble/curves/secp256k1.js';
import { finalizeEvent, getPublicKey, nip19, type Event, verifyEvent } from 'nostr-tools';
import {
  NotificationDeliveryError,
  type Notification,
  type NotificationEnvelope,
  type NotificationReceipt,
  type Notifier,
} from './notifier';

interface BuzzNotifierOptions {
  relayUrl: string;
  privateKey: string;
  authTag?: string;
  fetchImpl?: typeof fetch;
  now?: () => Date;
  nonce?: () => string;
}

interface BuzzRelayResponse {
  event_id?: string;
  accepted?: boolean;
}

export class BuzzNotifier implements Notifier {
  readonly provider = 'buzz';
  readonly relayUrl: string;
  readonly workspaceId: string;
  private readonly eventsUrl: string;
  private readonly secretKey: Uint8Array;
  private readonly publicKey: string;
  private readonly authTag: string[] | null;
  private readonly authTagJson: string | null;
  private readonly fetchImpl: typeof fetch;
  private readonly now: () => Date;
  private readonly nonce: () => string;

  constructor(options: BuzzNotifierOptions) {
    this.relayUrl = normaliseBuzzRelayUrl(options.relayUrl);
    this.workspaceId = new URL(this.relayUrl).host;
    this.eventsUrl = `${this.relayUrl}/events`;
    this.secretKey = parseBuzzPrivateKey(options.privateKey);
    this.publicKey = getPublicKey(this.secretKey);
    const parsedAuth = options.authTag
      ? parseAndVerifyBuzzAuthTag(options.authTag, this.publicKey)
      : null;
    this.authTag = parsedAuth;
    this.authTagJson = parsedAuth ? JSON.stringify(parsedAuth) : null;
    this.fetchImpl = options.fetchImpl ?? fetch;
    this.now = options.now ?? (() => new Date());
    this.nonce = options.nonce ?? randomUUID;
  }

  async send(
    envelope: NotificationEnvelope,
    signal?: AbortSignal,
  ): Promise<NotificationReceipt> {
    if (envelope.workspaceId && envelope.workspaceId !== this.workspaceId) {
      throw new NotificationDeliveryError('buzz_workspace_mismatch', false);
    }
    if (!isUuid(envelope.destination)) {
      throw new NotificationDeliveryError('invalid_buzz_channel', false);
    }

    const createdAt = notificationTimestamp(envelope.notification);
    if (this.authTag && !buzzAuthConditionsAllow(this.authTag[2]!, 9, createdAt)) {
      throw new NotificationDeliveryError('buzz_auth_tag_conditions', false);
    }
    const event = finalizeEvent(
      {
        kind: 9,
        created_at: createdAt,
        tags: [
          ['h', envelope.destination],
          [
            'client',
            'PIG',
            createHash('sha256').update(envelope.idempotencyKey).digest('hex'),
          ],
          ...(this.authTag ? [this.authTag] : []),
        ],
        content: formatBuzzNotification(envelope.notification),
      },
      this.secretKey,
    );
    const body = JSON.stringify(event);
    const authorization = this.createNip98Authorization(body);

    let response: Response;
    try {
      response = await this.fetchImpl(this.eventsUrl, {
        method: 'POST',
        headers: {
          authorization,
          'content-type': 'application/json',
          ...(this.authTagJson ? { 'x-auth-tag': this.authTagJson } : {}),
        },
        body,
        signal,
      });
    } catch {
      // The event id is content-addressed and remains stable on a retry, so an
      // ambiguous network outcome cannot create a second Buzz message.
      throw new NotificationDeliveryError('buzz_network_error', true);
    }

    if (response.status === 429) {
      throw new NotificationDeliveryError(
        'buzz_rate_limited',
        true,
        retryAfterMs(response.headers.get('retry-after')),
      );
    }
    if (response.status === 408 || response.status >= 500) {
      throw new NotificationDeliveryError('buzz_unavailable', true);
    }
    if (!response.ok) {
      throw new NotificationDeliveryError(`buzz_http_${response.status}`, false);
    }

    let result: BuzzRelayResponse;
    try {
      result = (await response.json()) as BuzzRelayResponse;
    } catch {
      throw new NotificationDeliveryError('invalid_buzz_response', true);
    }
    if (result.accepted !== true || !isEventId(result.event_id)) {
      throw new NotificationDeliveryError('buzz_rejected', false);
    }
    if (result.event_id !== event.id) {
      throw new NotificationDeliveryError('buzz_event_id_mismatch', false);
    }
    return { externalId: event.id };
  }

  private createNip98Authorization(body: string): string {
    const authEvent = finalizeEvent(
      {
        kind: 27235,
        created_at: Math.floor(this.now().getTime() / 1_000),
        tags: [
          ['u', this.eventsUrl],
          ['method', 'POST'],
          ['nonce', this.nonce()],
          ['payload', createHash('sha256').update(body).digest('hex')],
        ],
        content: '',
      },
      this.secretKey,
    );
    return `Nostr ${Buffer.from(JSON.stringify(authEvent), 'utf8').toString('base64')}`;
  }
}

export function normaliseBuzzRelayUrl(value: string): string {
  const trimmed = value.trim().replace(/^wss:/i, 'https:').replace(/^ws:/i, 'http:');
  let url: URL;
  try {
    url = new URL(trimmed);
  } catch {
    throw new Error('BUZZ_RELAY_URL must be a valid http(s) or ws(s) URL.');
  }
  if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
    throw new Error('BUZZ_RELAY_URL must be a credential-free http(s) or ws(s) URL.');
  }
  url.pathname = url.pathname.replace(/\/+$/, '');
  url.search = '';
  url.hash = '';
  return url.toString().replace(/\/$/, '');
}

export function parseBuzzPrivateKey(value: string): Uint8Array {
  const trimmed = value.trim();
  let key: Uint8Array;
  try {
    if (trimmed.startsWith('nsec1')) {
      const decoded = nip19.decode(trimmed);
      if (decoded.type !== 'nsec') throw new Error('wrong key type');
      key = decoded.data;
    } else {
      if (!/^[a-f0-9]{64}$/i.test(trimmed)) throw new Error('invalid hex');
      key = Uint8Array.from(Buffer.from(trimmed, 'hex'));
    }
    getPublicKey(key);
  } catch {
    throw new Error('BUZZ_PRIVATE_KEY must be a valid 32-byte hex or nsec private key.');
  }
  return key;
}

export function parseAndVerifyBuzzAuthTag(value: string, agentPublicKey: string): string[] {
  let tag: unknown;
  try {
    tag = JSON.parse(value);
  } catch {
    throw new Error('BUZZ_AUTH_TAG must be a valid JSON array.');
  }
  if (
    !Array.isArray(tag) ||
    tag.length !== 4 ||
    tag.some((part) => typeof part !== 'string') ||
    tag[0] !== 'auth' ||
    !/^[a-f0-9]{64}$/.test(tag[1] as string) ||
    !/^[a-f0-9]{128}$/.test(tag[3] as string)
  ) {
    throw new Error('BUZZ_AUTH_TAG has an invalid NIP-OA structure.');
  }

  const parts = tag as string[];
  validateBuzzAuthConditions(parts[2]!);
  if (parts[1] === agentPublicKey) {
    throw new Error('BUZZ_AUTH_TAG must be signed by an owner distinct from the agent.');
  }
  const digest = createHash('sha256')
    .update(`nostr:agent-auth:${agentPublicKey}:${parts[2]}`)
    .digest();
  let verified = false;
  try {
    verified = schnorr.verify(hexBytes(parts[3]!), digest, hexBytes(parts[1]!));
  } catch {
    verified = false;
  }
  if (!verified) throw new Error('BUZZ_AUTH_TAG signature does not authorize this agent key.');
  return parts;
}

function notificationTimestamp(notification: Notification): number {
  const value = notification.kind === 'stage_change' ? notification.changedAt : notification.observedAt;
  const milliseconds = Date.parse(value);
  if (!Number.isFinite(milliseconds)) {
    throw new NotificationDeliveryError('invalid_notification_timestamp', false);
  }
  return Math.floor(milliseconds / 1_000);
}

function formatBuzzNotification(notification: Notification): string {
  if (notification.kind === 'stage_change') {
    return [
      `**${notification.dealName}** moved from \`${notification.fromStage}\` to \`${notification.toStage}\`.`,
      `${notification.dealSide === 'demand' ? 'Demand' : 'Supply'} pipeline stage changed in PIG.`,
    ].join('\n');
  }
  return [
    `**Idle capacity: ${notification.commitmentName}**`,
    `${notification.gpuType} has ${formatNumber(notification.idleGpuHours)} unsold GPU-hours (${Math.round(notification.utilisation * 100)}% utilised).`,
    `Idle committed cost: ${formatMoney(notification.idleCostCents)}.`,
  ].join('\n');
}

function formatNumber(value: number): string {
  return new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }).format(value);
}

function formatMoney(cents: number): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
    maximumFractionDigits: 0,
  }).format(cents / 100);
}

function validateBuzzAuthConditions(conditions: string): void {
  if (conditions === '') return;
  for (const clause of conditions.split('&')) {
    const match = /^(kind=|created_at<|created_at>)(0|[1-9]\d*)$/.exec(clause);
    if (!match) throw new Error('BUZZ_AUTH_TAG contains invalid NIP-OA conditions.');
    const value = Number(match[2]);
    const maximum = match[1] === 'kind=' ? 65_535 : 4_294_967_295;
    if (!Number.isSafeInteger(value) || value > maximum) {
      throw new Error('BUZZ_AUTH_TAG contains out-of-range NIP-OA conditions.');
    }
  }
}

function buzzAuthConditionsAllow(conditions: string, kind: number, createdAt: number): boolean {
  if (conditions === '') return true;
  return conditions.split('&').every((clause) => {
    if (clause.startsWith('kind=')) return kind === Number(clause.slice(5));
    if (clause.startsWith('created_at<')) return createdAt < Number(clause.slice(11));
    if (clause.startsWith('created_at>')) return createdAt > Number(clause.slice(11));
    return false;
  });
}

function retryAfterMs(value: string | null): number | undefined {
  const seconds = Number(value);
  return Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds, 3_600) * 1_000 : undefined;
}

function hexBytes(value: string): Uint8Array {
  return Uint8Array.from(Buffer.from(value, 'hex'));
}

function isUuid(value: string): boolean {
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}

function isEventId(value: string | undefined): value is string {
  return Boolean(value && /^[a-f0-9]{64}$/.test(value));
}

export function decodeBuzzAuthorization(value: string): Event | null {
  if (!value.startsWith('Nostr ')) return null;
  try {
    const event = JSON.parse(Buffer.from(value.slice(6), 'base64').toString('utf8')) as Event;
    return verifyEvent(event) ? event : null;
  } catch {
    return null;
  }
}
