import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as crypto from 'crypto';

import { VisitorSalt } from '../entities/visitor-salt.entity';

/**
 * Server-side visitor identity for cross-domain stitching.
 *
 *   visitor_id_daily = sha256(daily_salt || ip || ua || accept_language)
 *
 * Same visitor across all our domains within a UTC day → same id.
 * Salt rotates at 00:00 UTC and is purged from `visitor_salts` after 7 days,
 * making historical re-identification mathematically impossible.
 */
@Injectable()
export class IdentityService {
  private readonly logger = new Logger(IdentityService.name);

  /** Process-local salt cache. Keyed by YYYY-MM-DD (UTC). */
  private cache = new Map<string, Buffer>();

  constructor(
    @InjectRepository(VisitorSalt)
    private readonly saltRepo: Repository<VisitorSalt>,
  ) {}

  /**
   * Compute today's visitor id from request attributes.
   * Returns a 32-byte Buffer (sha256 digest), or null if no identifying
   * attribute is present (extremely unlikely — we always have at minimum a UA).
   */
  async visitorIdDaily(
    ip: string | undefined,
    ua: string | undefined,
    lang: string | undefined,
    now: Date = new Date(),
  ): Promise<Buffer | null> {
    if (!ip && !ua && !lang) return null;

    const day = this.utcDay(now);
    const salt = await this.getSaltFor(day);

    const h = crypto.createHash('sha256');
    h.update(salt);
    h.update('\n');
    h.update(ip ?? '');
    h.update('\n');
    h.update(ua ?? '');
    h.update('\n');
    h.update(lang ?? '');
    return h.digest();
  }

  /** YYYY-MM-DD in UTC. */
  private utcDay(d: Date): string {
    return d.toISOString().slice(0, 10);
  }

  /**
   * Fetch the salt for `day`, creating one race-safely if missing.
   * Memoized for the lifetime of the process; cache invalidates when the
   * UTC day rolls over (entries from older days remain valid for late events).
   */
  private async getSaltFor(day: string): Promise<Buffer> {
    const cached = this.cache.get(day);
    if (cached) return cached;

    // Try read.
    const existing = await this.saltRepo.findOne({ where: { day } });
    if (existing) {
      const buf = Buffer.isBuffer(existing.salt) ? existing.salt : Buffer.from(existing.salt);
      this.cache.set(day, buf);
      return buf;
    }

    // Create. Race-safe via ON CONFLICT DO NOTHING — re-fetch if we lost.
    const fresh = crypto.randomBytes(32);
    await this.saltRepo
      .createQueryBuilder()
      .insert()
      .into(VisitorSalt)
      .values({ day, salt: fresh })
      .orIgnore()
      .execute();

    const final = await this.saltRepo.findOne({ where: { day } });
    if (!final) {
      // Should be impossible — orIgnore() either inserted ours or kept theirs.
      this.logger.error({ day }, 'Failed to read visitor_salts row after race-safe insert');
      throw new Error(`visitor_salts row for ${day} missing after insert`);
    }
    const buf = Buffer.isBuffer(final.salt) ? final.salt : Buffer.from(final.salt);
    this.cache.set(day, buf);
    return buf;
  }
}
