import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import type { RawEventEnvelope } from '@lilith/analytics';

import { RawEvent } from '../entities/raw-event.entity';
import { SessionFingerprint } from '../entities/session-fingerprint.entity';
import {
  DeviceEnrichmentService,
  type ClientDeviceData,
  type EnrichedDeviceData,
} from './device-enrichment.service';
import { AttributionService, type ResolvedAttribution } from './attribution.service';
import { IdentityService } from './identity.service';
import { DomainResolverService } from './domain-resolver.service';

/**
 * Canonical writer. Consumes an edge-captured {@link RawEventEnvelope}, runs the
 * DB-bound enrichment that used to live in the collector (device, identity,
 * domain, fingerprint), and inserts the canonical `raw_events` row.
 *
 * Idempotency: the row's primary key is the edge-minted `eventId`, inserted
 * with `ON CONFLICT (id) DO NOTHING`. A re-drained or duplicated envelope is a
 * no-op and does NOT re-trigger aggregation (which is additive and would
 * otherwise inflate counters).
 *
 * Timestamps: `receivedAt` is taken from the envelope (the EDGE clock), never
 * `now()`, so events spooled during a canonical-store outage keep their true
 * arrival time when drained.
 */
@Injectable()
export class IngestService {
  private readonly logger = new Logger(IngestService.name);

  constructor(
    private readonly deviceEnrichment: DeviceEnrichmentService,
    private readonly attribution: AttributionService,
    private readonly identity: IdentityService,
    private readonly domainResolver: DomainResolverService,
    @InjectRepository(RawEvent)
    private readonly rawEventRepo: Repository<RawEvent>,
    @InjectRepository(SessionFingerprint)
    private readonly fingerprintRepo: Repository<SessionFingerprint>,
  ) {}

  /**
   * Persist one event. Returns the aggregation job payload when this call
   * actually inserted the row, or `null` when the row already existed (so the
   * caller skips re-enqueuing aggregation).
   */
  async write(
    envelope: RawEventEnvelope,
  ): Promise<{ eventId: string; eventType: string; sessionId: string } | null> {
    const ev = envelope.payload;
    const receivedAt = new Date(envelope.receivedAt);
    const timestamp =
      ev.timestamp != null && Number.isFinite(ev.timestamp)
        ? new Date(ev.timestamp)
        : receivedAt;

    let deviceType: string | null = null;
    let visitorIdDaily: Buffer | null = null;
    let corpId: number | null = null;
    let domainId: number | null = null;

    if (ev.isView) {
      const enriched = await this.deviceEnrichment.enrich(
        envelope.edge.ua,
        envelope.edge.ip,
        (ev.clientDevice ?? undefined) as ClientDeviceData | undefined,
      );
      deviceType = enriched.deviceType;

      const attribution = this.attribution.resolveTrafficSource({
        utmSource: ev.attribution?.utmSource,
        utmMedium: ev.attribution?.utmMedium,
        utmCampaign: ev.attribution?.utmCampaign,
        utmContent: ev.attribution?.utmContent,
        utmTerm: ev.attribution?.utmTerm,
        referrer: ev.attribution?.referrer ?? ev.referrer ?? undefined,
      });

      await this.upsertSessionFingerprint(envelope, enriched, attribution);

      visitorIdDaily = await this.identity.visitorIdDaily(
        envelope.edge.ip,
        envelope.edge.ua,
        envelope.edge.lang,
        receivedAt,
      );
      const hostname = this.domainResolver.hostnameFromHeaders(envelope.edge.headers);
      const resolved = await this.domainResolver.resolve(hostname);
      corpId = resolved?.corpId ?? null;
      domainId = resolved?.domainId ?? null;
    }

    const result = await this.rawEventRepo
      .createQueryBuilder()
      .insert()
      .into(RawEvent)
      .values({
        id: envelope.eventId,
        eventType: ev.eventType,
        sessionId: ev.sessionId,
        userId: ev.userId ?? null,
        pageUrl: ev.pageUrl ?? null,
        referrer: ev.referrer ?? null,
        deviceType,
        metadata: ev.metadata ?? null,
        timestamp,
        receivedAt,
        processed: false,
        visitorIdDaily,
        corpId,
        domainId,
      })
      .orIgnore()
      .returning('id')
      .execute();

    const inserted = Array.isArray(result.raw) && result.raw.length > 0;
    if (!inserted) {
      this.logger.debug(`raw_events ${envelope.eventId} already present — duplicate drain, skipping aggregation`);
      return null;
    }

    return { eventId: envelope.eventId, eventType: ev.eventType, sessionId: ev.sessionId };
  }

  /**
   * Create or update the session fingerprint. Attribution is first-touch: set
   * only on creation, never overwritten. Failures are logged, not thrown —
   * fingerprinting is enhancement, not the canonical record.
   */
  private async upsertSessionFingerprint(
    envelope: RawEventEnvelope,
    enriched: EnrichedDeviceData,
    attribution: ResolvedAttribution,
  ): Promise<void> {
    const ev = envelope.payload;
    try {
      const existing = await this.fingerprintRepo.findOne({
        where: { sessionId: ev.sessionId },
      });

      if (existing) {
        if (enriched.viewportWidth !== undefined) {
          existing.viewportWidth = enriched.viewportWidth;
        }
        if (enriched.viewportHeight !== undefined) {
          existing.viewportHeight = enriched.viewportHeight;
        }
        if (ev.userId && !existing.userId) {
          existing.userId = ev.userId;
        }
        await this.fingerprintRepo.save(existing);
        return;
      }

      const fingerprint = this.fingerprintRepo.create({
        sessionId: ev.sessionId,
        userId: ev.userId ?? null,
        deviceType: enriched.deviceType,
        isBot: enriched.isBot,
        browser: enriched.browser,
        browserVersion: enriched.browserVersion,
        browserMajor: enriched.browserMajor,
        os: enriched.os,
        osVersion: enriched.osVersion,
        deviceVendor: enriched.deviceVendor,
        deviceModel: enriched.deviceModel,
        screenWidth: enriched.screenWidth ?? null,
        screenHeight: enriched.screenHeight ?? null,
        viewportWidth: enriched.viewportWidth ?? null,
        viewportHeight: enriched.viewportHeight ?? null,
        pixelRatio: enriched.pixelRatio ?? null,
        colorDepth: enriched.colorDepth ?? null,
        language: enriched.language ?? null,
        languages: enriched.languages ?? null,
        timezone: enriched.timezone ?? null,
        timezoneOffset: enriched.timezoneOffset ?? null,
        country: enriched.country,
        region: enriched.region,
        city: enriched.city,
        isEU: enriched.isEU,
        geoTimezone: enriched.geoTimezone,
        isVpn: enriched.isVpn,
        isDatacenter: enriched.isDatacenter,
        isTor: enriched.isTor,
        isGovernment: enriched.isGovernment,
        orgType: enriched.orgType,
        responseTier: enriched.responseTier,
        org: enriched.org,
        asn: enriched.asn,
        ipHash: enriched.ipHash,
        deviceMemory: enriched.deviceMemory ?? null,
        hardwareConcurrency: enriched.hardwareConcurrency ?? null,
        touchPoints: enriched.touchPoints ?? null,
        cookiesEnabled: enriched.cookiesEnabled ?? null,
        doNotTrack: enriched.doNotTrack ?? null,
        trafficSource: attribution.source,
        utmSource: attribution.rawSource,
        utmMedium: attribution.medium,
        utmCampaign: attribution.campaign,
        utmContent: attribution.content,
        utmTerm: attribution.term,
        referrer: attribution.referrer,
        landingPage: ev.pageUrl ?? null,
      });
      await this.fingerprintRepo.save(fingerprint);
    } catch (error) {
      // First-write race on sessionId (unique) just means another envelope for
      // the same session won — safe to ignore. Anything else is logged.
      this.logger.warn(
        `Failed to upsert session fingerprint for ${ev.sessionId}: ${
          error instanceof Error ? error.message : String(error)
        }`,
      );
    }
  }
}
