import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { createHash, randomBytes } from 'crypto';
import type { RelojApiMarcacion, RelojApiResult } from './reloj-api-client.service';

/**
 * Cliente ISAPI para el reloj biométrico Hikvision DS-K1T343MFWX (M16 — protocolo HIKVISION_ISAPI).
 *
 * A diferencia del cliente genérico ({@link RelojApiClientService}), este consume el endpoint
 * `/ISAPI/AccessControl/AcsEvent?format=json` mediante **POST paginado** con autenticación
 * **Digest (RFC 2617)** implementada nativamente con `crypto` (sin dependencias externas).
 *
 * Devuelve el mismo contrato {@link RelojApiResult} que el cliente genérico, de modo que el
 * pipeline aguas abajo (persistirRaw → dedup → novedades) no requiere cambios.
 *
 * Decisiones (ver docs/plan-rrhh-presentismo-hikvision.md):
 *  - `documento_raw = employeeNoString` (mapeado contra `cedula_identidad`).
 *  - `timestamp_marcacion = time` (ISO-8601 con offset).
 *  - Sólo entrada/salida: se descartan eventos `break*` / `overtime*`.
 */

const ACS_EVENT_PATH = '/ISAPI/AccessControl/AcsEvent?format=json';
const DEFAULT_TIMEOUT_MS = 30_000; // Hikvision es más lento que un API genérico
const MAX_RESULTS = 100; // registros por página (recomendado por Hikvision)
const MAX_PAGINAS = 1000; // tope de seguridad: 100k eventos por sincronización
const DEFAULT_LOOKBACK_MIN = 15; // ventana por defecto si no se pasa rango

export type HikvisionIsapiConfig = {
  url_base: string;
  api_key: string | null; // "usuario:password"
  campo_documento: string | null; // default: employeeNoString
  campo_timestamp: string | null; // default: time
};

type DigestChallenge = {
  realm: string;
  nonce: string;
  qop?: string;
  opaque?: string;
  algorithm?: string;
};

type DigestCtx = { challenge?: DigestChallenge; nc: number };

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

  /**
   * Recupera las marcaciones de asistencia del reloj Hikvision en el rango indicado.
   * Si no se pasa `desde`/`hasta`, consulta los últimos {@link DEFAULT_LOOKBACK_MIN} minutos.
   */
  async consultarAcsEvent(
    config: HikvisionIsapiConfig,
    params: { desde?: Date; hasta?: Date },
  ): Promise<RelojApiResult> {
    if (!config.url_base) {
      throw new BadRequestException('url_base no configurada para el reloj');
    }
    const { usuario, password } = this.parseCredenciales(config.api_key);

    let endpoint: string;
    let uri: string;
    try {
      const base = new URL(config.url_base);
      endpoint = `${base.protocol}//${base.host}${ACS_EVENT_PATH}`;
      uri = ACS_EVENT_PATH;
    } catch {
      throw new BadRequestException(`url_base inválida: ${config.url_base}`);
    }

    const hasta = params.hasta ?? new Date();
    const desde =
      params.desde ?? new Date(hasta.getTime() - DEFAULT_LOOKBACK_MIN * 60_000);
    const startTime = this.toIsapiTime(desde);
    const endTime = this.toIsapiTime(hasta);
    const searchID = `novasis-${Date.now()}`;
    const campoDoc = config.campo_documento || 'employeeNoString';
    const campoTs = config.campo_timestamp || 'time';

    const ctx: DigestCtx = { nc: 0 };
    const marcaciones: RelojApiMarcacion[] = [];
    let errores = 0;
    let position = 0;
    let lastStatus: number | undefined;

    for (let pagina = 0; pagina < MAX_PAGINAS; pagina++) {
      const body = JSON.stringify({
        AcsEventCond: {
          searchID,
          searchResultPosition: position,
          maxResults: MAX_RESULTS,
          major: 0,
          minor: 0,
          startTime,
          endTime,
          eventAttribute: 'attendance',
        },
      });

      let res: Response;
      try {
        res = await this.digestFetch({
          method: 'POST',
          url: endpoint,
          uri,
          body,
          usuario,
          password,
          ctx,
        });
      } catch (err: any) {
        const detalle =
          err?.name === 'AbortError'
            ? `Timeout al consultar el reloj Hikvision (> ${DEFAULT_TIMEOUT_MS}ms)`
            : `Error de red al consultar el reloj Hikvision: ${err?.message ?? err}`;
        this.logger.warn(`Reloj ${endpoint}: ${detalle}`);
        return { ok: false, marcaciones: [], errores_parse: 0, detalle };
      }

      lastStatus = res.status;
      if (!res.ok) {
        const txt = await this.safeText(res);
        return {
          ok: false,
          marcaciones: [],
          errores_parse: 0,
          http_status: res.status,
          detalle: `HTTP ${res.status} ${res.statusText}: ${txt.slice(0, 500)}`,
        };
      }

      let acs: any;
      try {
        const json = JSON.parse(await res.text());
        acs = json?.AcsEvent;
      } catch (err: any) {
        return {
          ok: false,
          marcaciones: [],
          errores_parse: 0,
          http_status: res.status,
          detalle: `Error parseando respuesta AcsEvent: ${err?.message ?? err}`,
        };
      }

      if (!acs || acs.responseStatusStrg === 'NO MATCH') break;

      const infoList: any[] = Array.isArray(acs.InfoList) ? acs.InfoList : [];
      if (infoList.length === 0) break;

      for (const item of infoList) {
        const status = String(item?.attendanceStatus ?? '').toLowerCase();
        // Sólo entrada/salida: descartar descansos y horas extra (decisión 6).
        if (status.startsWith('break') || status.startsWith('overtime')) continue;

        const documento_raw = this.extraer(item, campoDoc);
        const ts = this.parseTs(this.extraer(item, campoTs));
        if (!documento_raw || !ts) {
          errores++;
          continue;
        }
        marcaciones.push({ documento_raw, timestamp_marcacion: ts });
      }

      position += infoList.length;
      const total = Number(acs.totalMatches ?? 0);
      this.logger.log(`[AcsEvent] ${endpoint} página ${pagina + 1}: ${position}/${total}`);
      if (!total || position >= total) break;
    }

    return {
      ok: true,
      marcaciones,
      errores_parse: errores,
      http_status: lastStatus,
    };
  }

  // ── Digest Auth (RFC 2617) ───────────────────────────────────────────────

  /**
   * Ejecuta una request con Digest Auth. Reutiliza el challenge cacheado en `ctx`
   * incrementando `nc`; ante 401 (sin challenge o nonce vencido) re-negocia una vez.
   */
  private async digestFetch(opts: {
    method: string;
    url: string;
    uri: string;
    body?: string;
    usuario: string;
    password: string;
    ctx: DigestCtx;
  }): Promise<Response> {
    const { method, url, uri, body, usuario, password, ctx } = opts;

    // Sin challenge previo: pedir el reto con una request no autenticada.
    if (!ctx.challenge) {
      const challengeRes = await this.rawFetch(method, url, body, undefined);
      if (challengeRes.status !== 401) return challengeRes; // el reloj no exige Digest
      ctx.challenge = this.parseChallenge(challengeRes.headers.get('www-authenticate'));
      ctx.nc = 0;
      await this.safeText(challengeRes); // drenar el socket
      if (!ctx.challenge) {
        throw new Error('El reloj respondió 401 sin header WWW-Authenticate válido');
      }
    }

    let auth = this.buildAuthHeader(ctx, method, uri, usuario, password);
    let res = await this.rawFetch(method, url, body, auth);

    // Nonce vencido: re-negociar una sola vez.
    if (res.status === 401) {
      const fresh = this.parseChallenge(res.headers.get('www-authenticate'));
      await this.safeText(res);
      if (fresh) {
        ctx.challenge = fresh;
        ctx.nc = 0;
        auth = this.buildAuthHeader(ctx, method, uri, usuario, password);
        res = await this.rawFetch(method, url, body, auth);
      }
    }
    return res;
  }

  private async rawFetch(
    method: string,
    url: string,
    body: string | undefined,
    authorization: string | undefined,
  ): Promise<Response> {
    const headers: Record<string, string> = {
      Accept: 'application/json',
      'Content-Type': 'application/json',
    };
    if (authorization) headers.Authorization = authorization;

    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
    try {
      return await fetch(url, { method, headers, body, signal: controller.signal });
    } finally {
      clearTimeout(timeoutId);
    }
  }

  private parseChallenge(header: string | null): DigestChallenge | undefined {
    if (!header) return undefined;
    const params: Record<string, string> = {};
    const re = /(\w+)=(?:"([^"]*)"|([^,\s]+))/g;
    let m: RegExpExecArray | null;
    while ((m = re.exec(header)) !== null) {
      params[m[1].toLowerCase()] = (m[2] !== undefined ? m[2] : m[3]).trim();
    }
    if (!params.realm || !params.nonce) return undefined;
    let qop: string | undefined;
    if (params.qop) {
      // qop puede ser "auth,auth-int" — preferimos "auth".
      qop = params.qop.split(',').map((s) => s.trim()).includes('auth')
        ? 'auth'
        : params.qop.split(',')[0].trim();
    }
    return {
      realm: params.realm,
      nonce: params.nonce,
      qop,
      opaque: params.opaque,
      algorithm: params.algorithm,
    };
  }

  private buildAuthHeader(
    ctx: DigestCtx,
    method: string,
    uri: string,
    usuario: string,
    password: string,
  ): string {
    const c = ctx.challenge!;
    const algorithm = (c.algorithm || 'MD5').toUpperCase();
    let ha1 = this.md5(`${usuario}:${c.realm}:${password}`);
    const ha2 = this.md5(`${method}:${uri}`);

    const nc = (++ctx.nc).toString(16).padStart(8, '0');
    const cnonce = randomBytes(8).toString('hex');

    if (algorithm === 'MD5-SESS') {
      ha1 = this.md5(`${ha1}:${c.nonce}:${cnonce}`);
    }

    const response = c.qop
      ? this.md5(`${ha1}:${c.nonce}:${nc}:${cnonce}:${c.qop}:${ha2}`)
      : this.md5(`${ha1}:${c.nonce}:${ha2}`);

    const parts = [
      `username="${usuario}"`,
      `realm="${c.realm}"`,
      `nonce="${c.nonce}"`,
      `uri="${uri}"`,
      `response="${response}"`,
    ];
    if (c.algorithm) parts.push(`algorithm=${c.algorithm}`);
    if (c.opaque) parts.push(`opaque="${c.opaque}"`);
    if (c.qop) parts.push(`qop=${c.qop}`, `nc=${nc}`, `cnonce="${cnonce}"`);
    return `Digest ${parts.join(', ')}`;
  }

  private md5(s: string): string {
    return createHash('md5').update(s).digest('hex');
  }

  // ── Helpers ──────────────────────────────────────────────────────────────

  private parseCredenciales(apiKey: string | null): { usuario: string; password: string } {
    if (!apiKey || !apiKey.includes(':')) {
      throw new BadRequestException(
        'Digest requiere api_key con formato "usuario:password"',
      );
    }
    const idx = apiKey.indexOf(':');
    return { usuario: apiKey.slice(0, idx), password: apiKey.slice(idx + 1) };
  }

  /** Path con punto soportado (ej: "AccessControllerEvent.employeeNoString"). */
  private extraer(obj: any, key: string): string {
    if (!obj || typeof obj !== 'object') return '';
    let cur: any = obj;
    for (const p of key.split('.')) {
      if (cur && typeof cur === 'object' && p in cur) cur = cur[p];
      else return '';
    }
    return cur == null ? '' : String(cur).trim();
  }

  private parseTs(s: string): Date | null {
    if (!s) return null;
    const d = new Date(s);
    return Number.isNaN(d.getTime()) ? null : d;
  }

  /** ISO-8601 UTC con offset explícito "+00:00" (formato esperado por ISAPI). */
  private toIsapiTime(d: Date): string {
    return d.toISOString().replace(/\.\d{3}Z$/, '+00:00');
  }

  private async safeText(res: Response): Promise<string> {
    try {
      return await res.text();
    } catch {
      return '';
    }
  }
}
