import type {ServiceStatsLike} from './deriveMetrics';

/** A call of the service that is in the queue or being talked right now. */
export interface LiveCall {
  callId: string;
  /** Last action of the call: INIT, WAIT, RING, REJECT, CANCEL, ANSWER or END (the PBX may send other spellings). */
  action?: string;
  /** When the call entered the queue, in epoch milliseconds. */
  startTime?: number;
  time?: number;
  queuePosition?: number;
  relatedAgents?: string[];
  customer?: {phone?: string; name?: string};
  [key: string]: unknown;
}

/** An agent of the service as the service events describe it. */
export interface LiveAgent {
  id: string;
  name?: string;
  status?: string;
  paused?: boolean;
  reason?: string;
  userLicense?: string;
  [key: string]: unknown;
}

type Record_ = Record<string, unknown>;

const WAITING_ACTIONS = new Set(['INIT', 'WAIT', 'RING', 'REJECT']);

const isRecord = (value: unknown): value is Record_ =>
  typeof value === 'object' && value !== null && !Array.isArray(value);
const dropNull = (record: Record_): Record_ =>
  Object.fromEntries(Object.entries(record).filter(([, value]) => value != null));

/** Calls `visitor` for every record of a `broadcast` payload. The payload is a record or an array of records. */
function visit(node: unknown, visitor: (record: Record_) => void): void {
  if (Array.isArray(node)) {
    node.forEach((item) => visit(item, visitor));

    return;
  }

  if (!isRecord(node)) {
    return;
  }

  visitor(node);

  for (const key of ['services', 'items', 'data']) {
    visit(node[key], visitor);
  }
}

/**
 * Keeps the state of one service up to date from the messages of the `services` websocket topic.
 *
 * - `stats` is the latest stats block. It arrives on the subscribe snapshot and on `service_stats_update` events,
 *   never on call or agent events. Updates carry a growing `version`: an update that is not newer than the one
 *   already applied is ignored, because updates can arrive out of order.
 * - `agents` and `calls` follow the service, call and agent events, so a dashboard can list who is waiting or talking.
 *
 * Call {@link resetForSubscribe} right before every `subscribe` (also on reconnect): the snapshot that answers it
 * carries stats without a version, and versions start again from there.
 */
export class ServiceStatsTracker {
  stats: ServiceStatsLike | undefined;

  /** When the current stats were received, in epoch milliseconds (local clock). */
  statsReceivedAt = 0;

  /** Version of the last applied `service_stats_update`; 0 until one arrives after a subscribe. */
  statsVersion = 0;

  readonly agents = new Map<string, LiveAgent>();

  readonly calls = new Map<string, LiveCall>();

  /** True once the service has ended (`service_complete`). */
  ended = false;

  private readonly serviceId: string;

  constructor(serviceId: string) {
    this.serviceId = serviceId;
  }

  resetForSubscribe(): void {
    this.stats = undefined;
    this.statsVersion = 0;
  }

  /** Applies one parsed websocket message. Returns true when anything of the tracked service changed. */
  apply(message: unknown): boolean {
    if (!isRecord(message) || message.event !== 'broadcast' || message.topic !== 'services') {
      return false;
    }

    let changed = false;

    visit(message.data, (record) => {
      if (typeof record.id === 'string' && this.matches(record.id) && typeof record.event === 'string') {
        changed = this.applyRecord(record) || changed;
      }
    });

    return changed;
  }

  /** Calls that are in the queue and not answered yet, longest waiting first. */
  waitingCalls(): LiveCall[] {
    return [...this.calls.values()]
      .filter((call) => call.action == null || WAITING_ACTIONS.has(String(call.action).toUpperCase()))
      .sort((first, second) => (first.startTime ?? first.time ?? 0) - (second.startTime ?? second.time ?? 0));
  }

  /** Calls that an agent has answered and that have not ended. */
  talkingCalls(): LiveCall[] {
    return [...this.calls.values()].filter((call) => String(call.action).toUpperCase() === 'ANSWER');
  }

  private matches(id: string): boolean {
    return id === this.serviceId || id.endsWith(`_${this.serviceId}`) || this.serviceId.endsWith(`_${id}`);
  }

  private applyRecord(record: Record_): boolean {
    switch (record.event) {
      case 'service_stats_update':
        return this.applyStatsUpdate(record);
      case 'service':
        return this.applyService(record);
      case 'service_call':
        return this.applyCall(record);
      case 'service_call_complete':
        return typeof record.callId === 'string' && this.calls.delete(record.callId);
      case 'service_complete':
        this.ended = true;
        this.calls.clear();

        return true;
      default:
        return false;
    }
  }

  private applyStatsUpdate(record: Record_): boolean {
    if (!isRecord(record.stats)) {
      return false;
    }

    if (typeof record.version === 'number') {
      if (record.version <= this.statsVersion) {
        return false; // older than, or the same as, what is already applied
      }

      this.statsVersion = record.version;
    }

    this.stats = record.stats as ServiceStatsLike;
    this.statsReceivedAt = Date.now();

    return true;
  }

  /** The service record: the subscribe snapshot (with stats) and later service, agent and call-list updates. */
  private applyService(record: Record_): boolean {
    const trigger = typeof record.eventTrigger === 'string' ? record.eventTrigger : '';

    if (isRecord(record.stats)) {
      this.stats = record.stats as ServiceStatsLike;
      this.statsReceivedAt = Date.now();
      this.statsVersion = 0;
    }

    if (Array.isArray(record.agents)) {
      const agents = record.agents.filter(isRecord).filter((agent): agent is LiveAgent => typeof agent.id === 'string');

      if (trigger === 'service.init' || trigger === '') {
        const keep = new Set(agents.map((agent) => agent.id));

        for (const id of [...this.agents.keys()]) {
          if (!keep.has(id)) {
            this.agents.delete(id);
          }
        }
      }

      for (const agent of agents) {
        if (trigger === 'service.agent_leave') {
          this.agents.delete(agent.id);
        } else {
          this.agents.set(agent.id, {...this.agents.get(agent.id), ...dropNull(agent), id: agent.id});
        }
      }
    }

    for (const removed of Array.isArray(record.agentsRemoved) ? record.agentsRemoved : []) {
      this.agents.delete(typeof removed === 'string' ? removed : String((removed as Record_)?.id));
    }

    if (Array.isArray(record.calls) && trigger !== 'service.call_init') {
      const live = new Set(
        record.calls.map((call) => (typeof call === 'string' ? call : String((call as Record_)?.callId))),
      );

      for (const callId of [...this.calls.keys()]) {
        if (!live.has(callId)) {
          this.calls.delete(callId);
        }
      }
    }

    return true;
  }

  private applyCall(record: Record_): boolean {
    if (typeof record.callId !== 'string') {
      return false;
    }

    const action = String(record.action ?? '').toUpperCase();

    if (record.eventTrigger === 'service.call_end' || action === 'END') {
      return this.calls.delete(record.callId);
    }

    // Stats are never part of a call record; the merge keeps what earlier events said (start time, customer).
    const update = dropNull(record);

    delete update.stats;
    this.calls.set(record.callId, {...this.calls.get(record.callId), ...update, callId: record.callId});

    return true;
  }
}
