/**
 * ADR-024 — FIS Protocol Compliance Integration
 * ADR-024.3 — Full FIS Streaming Protocol Alignment
 *
 * All client↔server interactions are multiplexed through a single
 * `request` event carrying a FisAppMessage envelope (header + data).
 * The routing engine branches on `header.serviceId:header.messageName`
 * and always echoes back a FIS-compliant two-packet response:
 *   1. Data packet  — { id, messageID, ..., complete: false, message: JSON.stringify(payload) }
 *   2. Done packet  — { id, messageID, ..., complete: true }
 *
 * This two-packet protocol satisfies NgxSocketService's streaming contract:
 *   observer.next() fires on complete:false, observer.complete() on complete:true.
 *
 * ORDERING GUARANTEE (unchanged from Lego 12):
 *   PalmOilService.analyzeImage() is fully awaited — SQLite write
 *   completes before any vision response is emitted.
 */

import * as crypto from 'crypto';
import * as fs from 'fs';
import {
  WebSocketGateway,
  WebSocketServer,
  SubscribeMessage,
  OnGatewayInit,
  OnGatewayConnection,
  OnGatewayDisconnect,
  MessageBody,
  ConnectedSocket,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { PalmOilService } from './palm-oil.service';
import { AnalysisResponse } from './interfaces/palm-analysis.interface';

// ─── FIS Protocol Envelope ────────────────────────────────────────────────────

interface FisAppMessage {
  header?: {
    messageID?: string;
    serviceId?: string;
    messageName?: string;
    [k: string]: unknown;
  };
  data?: any;
  // Legacy flat-format fallback fields
  messageID?: string;
  serviceId?: string;
  operation?: string;
  payload?: any;
}

interface FisAppResponse {
  id: string;
  messageID: string;
  serviceId: string;
  operation: string;
  complete: boolean;
  message?: string;   // JSON-encoded payload for data packets (complete: false)
  error?: string;
}

// ─── Typed inbound payload shapes ────────────────────────────────────────────

interface PalmVisionPayload {
  frame: string;
  sourceLabel?: string;
  batchId?: string;
}

interface ChatPayload {
  message: string;
}

// ─── n8n intelligent-response schema ────────────────────────────────────────

interface ChatVisualDataset {
  label: string;
  data: number[];
}

interface ChatVisualData {
  type: string;
  title: string;
  labels: string[];
  datasets: ChatVisualDataset[];
}

interface ChatResponse {
  text: string;
  has_visuals: boolean;
  visual_data: ChatVisualData | null;
}

interface HistoryDeletePayload {
  archiveId: string;
}

interface BatchDetailsPayload {
  batchId: string;
}

interface EdgeResultPayload {
  frame: string;
  filename?: string;
  batchId?: string;
  detections: any[];
  industrial_summary: Record<string, number>;
  inference_ms: number;
  processing_ms?: number;
}

// ─── Env ──────────────────────────────────────────────────────────────────────

const N8N_WEBHOOK_URL = process.env['N8N_WEBHOOK_URL'] ?? '';

/**
 * n8n's AI agent node is only allowed to emit text — the enforced JSON schema
 * (text/has_visuals/visual_data) arrives as a *string* inside `output` (or
 * `text`/`message`, depending on node config), sometimes fenced in ```json.
 * This normalizes whatever shape comes back into a strict ChatResponse.
 */
function normalizeChatResponse(raw: any): ChatResponse {
  const node = Array.isArray(raw) ? raw[0] : raw;

  const candidate =
    node && typeof node === 'object'
      ? (node.text !== undefined || node.has_visuals !== undefined
          ? node
          : (node.output ?? node.message ?? node))
      : node;

  let parsed: any = candidate;
  if (typeof candidate === 'string') {
    const unfenced = candidate.trim().replace(/^```json\s*|```$/g, '').trim();
    try {
      parsed = JSON.parse(unfenced);
    } catch {
      parsed = { text: candidate };
    }
  }

  if (!parsed || typeof parsed !== 'object') {
    parsed = { text: String(parsed ?? '') };
  }

  const visualData: ChatVisualData | null =
    parsed.has_visuals && parsed.visual_data
      ? {
          type: String(parsed.visual_data.type ?? 'bar'),
          title: String(parsed.visual_data.title ?? ''),
          labels: Array.isArray(parsed.visual_data.labels) ? parsed.visual_data.labels : [],
          datasets: Array.isArray(parsed.visual_data.datasets) ? parsed.visual_data.datasets : [],
        }
      : null;

  return {
    text: String(parsed.text ?? parsed.output ?? parsed.message ?? ''),
    has_visuals: Boolean(parsed.has_visuals && visualData),
    visual_data: visualData,
  };
}

// ─── Gateway ──────────────────────────────────────────────────────────────────

@WebSocketGateway({
  cors: { origin: '*' },
  allowEIO3: true, // ngx-socket-io/socket.io-client in dp-ui is pinned at v2.5.0 (Engine.IO v3); this Nest gateway runs socket.io v4, which rejects EIO3 handshakes unless explicitly allowed.
})
export class VisionGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  @WebSocketServer()
  private server!: Server;

  private readonly logger = new Logger(VisionGateway.name);

  constructor(private readonly palmOilService: PalmOilService) {}

  // ─── Lifecycle ───────────────────────────────────────────────────────────────

  afterInit() {
    this.logger.log('🔌 VisionGateway initialized on root namespace');
  }

  handleConnection(client: Socket) {
    client.data.sessionId = crypto.randomUUID();
    this.logger.log(`📡 Vision client connected: ${client.id} (session: ${client.data.sessionId})`);
  }

  handleDisconnect(client: Socket) {
    this.logger.log(`🔌 Vision client disconnected: ${client.id}`);
  }

  // ─── Unified message broker ───────────────────────────────────────────────

  /**
   * Single catch-all handler for all client operations.
   *
   * Accepts both FIS protocol envelope (header/data) and legacy flat format.
   * Routing key: `${serviceId}:${operation}`
   *
   * Supported routes:
   *   PalmVision:analyze              — decode Base64 frame, run ONNX, persist, reply
   *   History:getAll                  — fetch last 50 history records
   *   History:delete                  — delete one record by archiveId
   *   History:clearAll                — wipe all records and archived images
   *   Chat:send                       — proxy message to n8n webhook
   *   Chat:clear                      — reset session UUID
   *   PalmHistory:GetImage            — stream archived image as Base64 data URL
   *   PalmHistory:SaveExternalResult  — persist edge-computed inference result to SQLite without re-running ONNX
   */
  @SubscribeMessage('request')
  async handleMessage(
    @MessageBody() rawMsg: any,
    @ConnectedSocket() client: Socket,
  ): Promise<void> {
    // NgxSocketService sends JSON.stringify(FisAppMessage) — parse if string
    const msg: FisAppMessage = typeof rawMsg === 'string' ? JSON.parse(rawMsg) : (rawMsg ?? {});

    // Extract fields from FIS envelope; fall back to legacy flat format
    const header = msg.header ?? {};
    const messageID: string = (header.messageID ?? msg.messageID ?? crypto.randomUUID()) as string;
    const serviceId: string = (header.serviceId ?? msg.serviceId ?? '') as string;
    const operation: string = (header.messageName ?? msg.operation ?? '') as string;
    const payload: any = msg.data ?? msg.payload;

    /**
     * FIS streaming two-packet reply:
     *  1. Data packet  (complete: false) — NgxSocketService calls observer.next()
     *  2. Done packet  (complete: true)  — NgxSocketService calls observer.complete()
     * makeNgxCall in DpService reads `res.message` via JSON.parse, so payload
     * must be serialised into the `message` string field.
     */
    const reply = (data: any): void => {
      client.emit('response', {
        id: messageID, messageID, serviceId, operation,
        complete: false,
        message: JSON.stringify(data),
      } satisfies FisAppResponse);
      client.emit('response', {
        id: messageID, messageID, serviceId, operation,
        complete: true,
        message: JSON.stringify({ EOF: true }),
      } satisfies FisAppResponse);
    };

    const replyError = (error: string): void => {
      client.emit('response', {
        id: messageID, messageID, serviceId, operation,
        complete: false,
        message: JSON.stringify({ error }),
      } satisfies FisAppResponse);
      client.emit('response', {
        id: messageID, messageID, serviceId, operation,
        complete: true,
        message: JSON.stringify({ EOF: true }),
      } satisfies FisAppResponse);
    };

    const route = `${serviceId}:${operation}`;
    this.logger.log(`📨 request [${route}] from ${client.id}`);

    try {
      switch (route) {

        // ── PalmVision:analyze ─────────────────────────────────────────────
        case 'PalmVision:analyze': {
          const { frame, sourceLabel, batchId } = (payload ?? {}) as PalmVisionPayload;

          if (!frame) {
            replyError('No frame data received');
            return;
          }

          this.logger.log(
            `📦 PalmVision:analyze from ${client.id} — payload size: ${frame.length} chars (~${(frame.length / 1024).toFixed(1)} KB)`,
          );

          const rawBase64 = frame.replace(/^data:image\/\w+;base64,/, '');
          const imageBuffer = Buffer.from(rawBase64, 'base64');

          const result: AnalysisResponse = await this.palmOilService.analyzeImage(
            imageBuffer,
            sourceLabel ?? 'socket-frame',
            batchId,
          );

          reply({
            ...result,
            technical_evidence: {
              engine: 'NestJS-ONNX' as const,
              archive_id: result.archive_id,
              total_count: result.total_count,
              threshold: result.current_threshold,
              industrial_summary: result.industrial_summary,
              raw_tensor_sample: result.raw_tensor_sample,
            },
          });
          break;
        }

        // ── History:getAll ─────────────────────────────────────────────────
        case 'History:getAll': {
          const history = await this.palmOilService.getHistory();
          reply(history);
          break;
        }

        // ── History:delete ─────────────────────────────────────────────────
        case 'History:delete': {
          const { archiveId } = (payload ?? {}) as HistoryDeletePayload;
          const result = await this.palmOilService.deleteRecord(archiveId);
          reply(result);
          break;
        }

        // ── History:clearAll ───────────────────────────────────────────────
        case 'History:clearAll': {
          const result = await this.palmOilService.clearAllHistory();
          reply(result);
          break;
        }

        // ── History:getBatchDetails ────────────────────────────────────────
        case 'History:getBatchDetails': {
          const { batchId } = (payload ?? {}) as BatchDetailsPayload;

          if (!batchId) {
            replyError('No batchId provided');
            return;
          }

          const result = await this.palmOilService.getBatchDetails(batchId);
          reply(result);
          break;
        }

        // ── Chat:send ──────────────────────────────────────────────────────
        case 'Chat:send': {
          const { message } = (payload ?? {}) as ChatPayload;

          if (!message?.trim()) {
            replyError('Empty message received');
            return;
          }

          if (!N8N_WEBHOOK_URL) {
            replyError('N8N_WEBHOOK_URL not configured in .env');
            return;
          }

          const body = {
            chatInput: message,
            action: 'sendMessage',
            sessionId: client.data.sessionId,
          };
          this.logger.log(`💬 Chat:send from ${client.id} → ${N8N_WEBHOOK_URL}`);

          const response = await fetch(N8N_WEBHOOK_URL, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(body),
          });

          const raw = await response.json();
          const chatResult = normalizeChatResponse(raw);
          reply(chatResult);
          break;
        }

        // ── Chat:clear ─────────────────────────────────────────────────────
        case 'Chat:clear': {
          client.data.sessionId = crypto.randomUUID();
          this.logger.log(`🔄 Chat:clear — new session for ${client.id}: ${client.data.sessionId}`);
          reply({ status: 'success' });
          break;
        }

        // ── PalmHistory:SaveExternalResult ─────────────────────────────────
        case 'PalmHistory:SaveExternalResult': {
          const edgePayload = (payload ?? {}) as EdgeResultPayload;

          if (!edgePayload.frame) {
            replyError('No frame data received');
            return;
          }

          const saved = await this.palmOilService.saveExternalResult(edgePayload);
          this.logger.log(
            `💾 PalmHistory:SaveExternalResult — SQLite write OK: ${saved.archive_id} | detections: ${saved.total_count} | edge_inference_ms: ${saved.inference_ms}`,
          );

          reply({
            status: 'success',
            archive_id: saved.archive_id,
            total_count: saved.total_count,
            inference_ms: saved.inference_ms,
            created_at: saved.created_at,
          });
          break;
        }

        // ── PalmHistory:GetImage ───────────────────────────────────────────
        case 'PalmHistory:GetImage': {
          const { archiveId } = (payload ?? {}) as HistoryDeletePayload;

          if (!archiveId) {
            replyError('No archiveId provided');
            return;
          }

          const record = await this.palmOilService.getRecordByArchiveId(archiveId);
          if (!record || !record.image_path) {
            replyError(`Image not found for archiveId: ${archiveId}`);
            return;
          }

          if (!fs.existsSync(record.image_path)) {
            replyError(`Image file missing on disk for archiveId: ${archiveId}`);
            return;
          }

          const fileBuffer = fs.readFileSync(record.image_path);
          const dataUrl = `data:image/jpeg;base64,${fileBuffer.toString('base64')}`;

          reply({ archiveId, image_data: dataUrl });
          break;
        }

        // ── Unknown route ──────────────────────────────────────────────────
        default: {
          this.logger.warn(`⚠️  Unknown route: ${route}`);
          replyError(`Unknown route: ${route}`);
        }
      }
    } catch (err: any) {
      this.logger.error(`❌ request error [${route}]: ${err.message}`);
      replyError(err.message);
    }
  }
}
