import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as crypto from 'crypto';
import { Webhook } from '../../entities/webhook.entity';
import { CreateWebhookDto } from './dto/create-webhook.dto';
import { UpdateWebhookDto } from './dto/update-webhook.dto';
import { assertPublicHttpUrl } from '../../common/ssrf-guard';

/** Safe list view — secret is included (operator's own resource). */
export interface WebhookListItem {
  id: string;
  name: string;
  url: string;
  events: string[];
  secret: string;
  is_active: boolean;
  last_delivery_at: Date | null;
  last_status: string | null;
  created_at: Date;
}

/** Shape returned on create — identical to list item. */
export type CreatedWebhook = WebhookListItem;

@Injectable()
export class WebhooksService {
  private readonly logger = new Logger(WebhooksService.name);

  constructor(
    @InjectRepository(Webhook)
    private readonly webhookRepo: Repository<Webhook>,
  ) {}

  // ---------------------------------------------------------------------------
  // CRUD
  // ---------------------------------------------------------------------------

  async create(licenseId: string, dto: CreateWebhookDto): Promise<CreatedWebhook> {
    // SSRF guard: reject URLs resolving to private/reserved space before storing.
    await assertPublicHttpUrl(dto.url);

    // Generate a secret if the caller didn't supply one.
    const secret = dto.secret ?? crypto.randomBytes(32).toString('hex');

    const entity = this.webhookRepo.create({
      license_id: licenseId,
      name: dto.name,
      url: dto.url,
      events: dto.events,
      secret,
      is_active: true,
    });

    const saved = await this.webhookRepo.save(entity);

    this.logger.log(
      `webhook created: id=${saved.id} name="${saved.name}" events=[${saved.events.join(',')}] license=${licenseId}`,
    );

    return this.toListItem(saved);
  }

  async list(licenseId: string): Promise<WebhookListItem[]> {
    const rows = await this.webhookRepo.find({
      where: { license_id: licenseId },
      order: { created_at: 'DESC' },
    });
    return rows.map(this.toListItem);
  }

  async update(licenseId: string, id: string, dto: UpdateWebhookDto): Promise<WebhookListItem> {
    const webhook = await this.findOwned(licenseId, id);

    // SSRF guard on any URL change.
    if (dto.url !== undefined) await assertPublicHttpUrl(dto.url);

    if (dto.name !== undefined) webhook.name = dto.name;
    if (dto.url !== undefined) webhook.url = dto.url;
    if (dto.events !== undefined) webhook.events = dto.events;
    if (dto.is_active !== undefined) webhook.is_active = dto.is_active;

    const saved = await this.webhookRepo.save(webhook);

    this.logger.log(`webhook updated: id=${id} license=${licenseId}`);

    return this.toListItem(saved);
  }

  async remove(licenseId: string, id: string): Promise<{ id: string }> {
    const webhook = await this.findOwned(licenseId, id);
    await this.webhookRepo.remove(webhook);
    this.logger.log(`webhook deleted: id=${id} license=${licenseId}`);
    return { id };
  }

  // ---------------------------------------------------------------------------
  // Dispatch
  // ---------------------------------------------------------------------------

  /**
   * Fire an event to all active webhooks for a license that are subscribed to
   * the given event key.
   *
   * Contract:
   *  - Fire-and-forget: each delivery is attempted with a 5-second AbortController
   *    timeout and never throws out to the caller.
   *  - Each attempt updates last_delivery_at + last_status ('ok' | 'failed').
   *  - The triggering action is NOT blocked. All deliveries run concurrently via
   *    Promise.allSettled; the returned Promise resolves only after all attempts
   *    finish (or time out), so callers can void it for true fire-and-forget.
   *
   * Signature header: X-Corrosion-Signature: sha256=<hex>
   * where hex = HMAC-SHA256(rawBody, webhook.secret).
   */
  async dispatch(
    licenseId: string,
    event: string,
    payload: Record<string, unknown>,
  ): Promise<void> {
    let hooks: Webhook[];
    try {
      hooks = await this.webhookRepo.find({
        where: { license_id: licenseId, is_active: true },
      });
    } catch (err) {
      this.logger.error(
        `dispatch: failed to query webhooks for license ${licenseId}: ${(err as Error).message}`,
      );
      return;
    }

    // Filter to those subscribed to this event.
    const subscribed = hooks.filter((h) => h.events.includes(event));
    if (subscribed.length === 0) return;

    const body = JSON.stringify({
      event,
      timestamp: new Date().toISOString(),
      data: payload,
    });

    await Promise.allSettled(
      subscribed.map((hook) => this.deliverOne(hook, event, body)),
    );
  }

  /** Deliver to a single webhook endpoint; update delivery metadata. Never throws. */
  private async deliverOne(hook: Webhook, event: string, body: string): Promise<void> {
    const signature = this.sign(body, hook.secret);
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), 5_000);

    let status: 'ok' | 'failed' = 'failed';

    try {
      // Re-validate at send time: a host that was public at create time can
      // resolve to a private address now (DNS rebinding / TOCTOU). Throws → caught
      // below → recorded 'failed'.
      await assertPublicHttpUrl(hook.url);

      const res = await fetch(hook.url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Corrosion-Signature': `sha256=${signature}`,
        },
        body,
        signal: controller.signal,
        // Do not auto-follow redirects — a 3xx Location could point at an
        // internal host, re-opening the SSRF we just closed. A redirect is a
        // failed delivery here.
        redirect: 'manual',
      });

      if (res.ok) {
        status = 'ok';
      } else {
        this.logger.warn(
          `webhook delivery failed: id=${hook.id} event=${event} status=${res.status}`,
        );
      }
    } catch (err) {
      const msg = (err as Error).message ?? String(err);
      this.logger.warn(
        `webhook delivery error: id=${hook.id} event=${event} err=${msg}`,
      );
    } finally {
      clearTimeout(timer);
    }

    // Persist delivery outcome — best-effort, never throws.
    try {
      await this.webhookRepo.update(hook.id, {
        last_delivery_at: new Date(),
        last_status: status,
      });
    } catch (err) {
      this.logger.error(
        `webhook metadata update failed: id=${hook.id}: ${(err as Error).message}`,
      );
    }
  }

  // ---------------------------------------------------------------------------
  // Helpers
  // ---------------------------------------------------------------------------

  private async findOwned(licenseId: string, id: string): Promise<Webhook> {
    const webhook = await this.webhookRepo.findOne({
      where: { id, license_id: licenseId },
    });
    if (!webhook) {
      throw new NotFoundException(`Webhook ${id} not found`);
    }
    return webhook;
  }

  private sign(body: string, secret: string): string {
    return crypto.createHmac('sha256', secret).update(body).digest('hex');
  }

  private toListItem(w: Webhook): WebhookListItem {
    return {
      id: w.id,
      name: w.name,
      url: w.url,
      events: w.events,
      secret: w.secret,
      is_active: w.is_active,
      last_delivery_at: w.last_delivery_at,
      last_status: w.last_status,
      created_at: w.created_at,
    };
  }
}
