/**
 * V3 Byzantine Fault Tolerant Consensus
 * PBFT-style consensus for handling malicious or faulty nodes
 */

import { EventEmitter } from 'events';
import { createHash } from 'node:crypto';
import {
  ConsensusProposal,
  ConsensusVote,
  ConsensusResult,
  ConsensusConfig,
  SWARM_CONSTANTS,
} from '../types.js';
import type { ConsensusTransport, ConsensusMessage } from './transport.js';

export type ByzantinePhase = 'pre-prepare' | 'prepare' | 'commit' | 'reply';

export interface ByzantineMessage {
  type: ByzantinePhase;
  viewNumber: number;
  sequenceNumber: number;
  digest: string;
  senderId: string;
  timestamp: Date;
  payload?: unknown;
  signature?: string;
}

export interface ByzantineNode {
  id: string;
  isPrimary: boolean;
  viewNumber: number;
  sequenceNumber: number;
  preparedMessages: Map<string, ByzantineMessage[]>;
  committedMessages: Map<string, ByzantineMessage[]>;
}

export interface ByzantineConfig extends Partial<ConsensusConfig> {
  maxFaultyNodes?: number;
  viewChangeTimeoutMs?: number;
  /**
   * ADR-095 G2 — optional pluggable transport. When set, PBFT messages
   * (pre-prepare/prepare/commit) go over it (and are signed if the
   * transport has a keypair) instead of being `emit`ted into the void.
   * When unset, behavior is unchanged: messages are emitted as
   * `message.broadcast` / `message.sent` events for an external wiring
   * layer to relay (the legacy single-process path).
   */
  transport?: ConsensusTransport;
}

export class ByzantineConsensus extends EventEmitter {
  private config: ByzantineConfig;
  private node: ByzantineNode;
  private nodes: Map<string, ByzantineNode> = new Map();
  private proposals: Map<string, ConsensusProposal> = new Map();
  private messageLog: Map<string, ByzantineMessage[]> = new Map();
  private proposalCounter: number = 0;
  private viewChangeTimeout?: NodeJS.Timeout;
  private readonly transport?: ConsensusTransport;

  constructor(nodeId: string, config: ByzantineConfig = {}) {
    super();
    this.config = {
      threshold: config.threshold ?? SWARM_CONSTANTS.DEFAULT_CONSENSUS_THRESHOLD,
      timeoutMs: config.timeoutMs ?? SWARM_CONSTANTS.DEFAULT_CONSENSUS_TIMEOUT_MS,
      maxRounds: config.maxRounds ?? 10,
      requireQuorum: config.requireQuorum ?? true,
      maxFaultyNodes: config.maxFaultyNodes, // #G2: undefined → byzantineF() derives from cluster size; a value caps it
      viewChangeTimeoutMs: config.viewChangeTimeoutMs ?? 5000,
      transport: config.transport,
    };
    this.transport = config.transport;

    this.node = {
      id: nodeId,
      isPrimary: false,
      viewNumber: 0,
      sequenceNumber: 0,
      preparedMessages: new Map(),
      committedMessages: new Map(),
    };

    // ADR-095 G2 — when a transport is wired, route inbound PBFT messages
    // through the protocol handlers. The transport handles signature
    // verification (if signing is enabled) before we ever see the message.
    if (this.transport) {
      this.transport.onMessage(async (msg: ConsensusMessage) => {
        await this.handleInboundMessage(msg);
      });
    }
  }

  /**
   * ADR-095 G2 — dispatch an inbound transport message to the right PBFT
   * handler. The transport has already verified the signature (if signing
   * is on); here we only need to demux by type and reconstruct the
   * ByzantineMessage shape the handlers expect.
   */
  private async handleInboundMessage(msg: ConsensusMessage): Promise<void> {
    const p = msg.payload as Partial<ByzantineMessage> | undefined;
    if (!p || typeof p.viewNumber !== 'number' || typeof p.sequenceNumber !== 'number' || typeof p.digest !== 'string') return;
    const bm: ByzantineMessage = {
      type: (msg.type as ByzantinePhase) ?? p.type ?? 'prepare',
      viewNumber: p.viewNumber,
      sequenceNumber: p.sequenceNumber,
      digest: p.digest,
      senderId: msg.from,
      timestamp: p.timestamp ? new Date(p.timestamp as unknown as string) : new Date(),
      payload: p.payload,
      signature: msg.signature,
    };
    switch (bm.type) {
      case 'pre-prepare': await this.handlePrePrepare(bm); break;
      case 'prepare': await this.handlePrepare(bm); break;
      case 'commit': await this.handleCommit(bm); break;
      default: break;
    }
  }

  async initialize(): Promise<void> {
    this.emit('initialized', { nodeId: this.node.id });
  }

  async shutdown(): Promise<void> {
    if (this.viewChangeTimeout) {
      clearTimeout(this.viewChangeTimeout);
    }
    this.emit('shutdown');
  }

  addNode(nodeId: string, isPrimary: boolean = false): void {
    this.nodes.set(nodeId, {
      id: nodeId,
      isPrimary,
      viewNumber: 0,
      sequenceNumber: 0,
      preparedMessages: new Map(),
      committedMessages: new Map(),
    });

    if (isPrimary && this.node.id === nodeId) {
      this.node.isPrimary = true;
    }
  }

  removeNode(nodeId: string): void {
    this.nodes.delete(nodeId);
  }

  /**
   * ADR-095 G2 — BFT fault tolerance f, derived from the *actual* cluster
   * size unless explicitly capped via config.maxFaultyNodes. PBFT needs
   * n ≥ 3f+1, so f = floor((n-1)/3) where n = self + known peers. The
   * config value (if set) acts as an upper bound — never exceed what the
   * operator declared the cluster can tolerate.
   */
  private byzantineF(): number {
    const n = this.nodes.size + 1; // self + known peers
    const derived = Math.max(1, Math.floor((n - 1) / 3));
    const cap = this.config.maxFaultyNodes;
    return cap === undefined ? derived : Math.min(derived, cap);
  }

  electPrimary(): string {
    const nodeIds = [this.node.id, ...Array.from(this.nodes.keys())];
    const primaryIndex = this.node.viewNumber % nodeIds.length;
    const primaryId = nodeIds[primaryIndex];

    this.node.isPrimary = primaryId === this.node.id;

    for (const [id, node] of this.nodes) {
      node.isPrimary = id === primaryId;
    }

    this.emit('primary.elected', { primaryId, viewNumber: this.node.viewNumber });

    return primaryId;
  }

  async propose(value: unknown): Promise<ConsensusProposal> {
    if (!this.node.isPrimary) {
      throw new Error('Only primary can propose values');
    }

    this.proposalCounter++;
    const sequenceNumber = ++this.node.sequenceNumber;
    const digest = this.computeDigest(value);
    const proposalId = `bft_${this.node.viewNumber}_${sequenceNumber}`;

    const proposal: ConsensusProposal = {
      id: proposalId,
      proposerId: this.node.id,
      value,
      term: this.node.viewNumber,
      timestamp: new Date(),
      votes: new Map(),
      status: 'pending',
    };

    this.proposals.set(proposalId, proposal);

    // Phase 1: Pre-prepare
    const prePrepareMsg: ByzantineMessage = {
      type: 'pre-prepare',
      viewNumber: this.node.viewNumber,
      sequenceNumber,
      digest,
      senderId: this.node.id,
      timestamp: new Date(),
      payload: value,
    };

    await this.broadcastMessage(prePrepareMsg);

    // Self-prepare
    await this.handlePrepare({
      type: 'prepare',
      viewNumber: this.node.viewNumber,
      sequenceNumber,
      digest,
      senderId: this.node.id,
      timestamp: new Date(),
    });

    return proposal;
  }

  async vote(proposalId: string, vote: ConsensusVote): Promise<void> {
    const proposal = this.proposals.get(proposalId);
    if (!proposal || proposal.status !== 'pending') {
      return;
    }

    proposal.votes.set(vote.voterId, vote);

    // Check consensus
    const f = this.byzantineF();
    const n = this.nodes.size + 1;
    const requiredVotes = 2 * f + 1;

    const approvingVotes = Array.from(proposal.votes.values()).filter(
      v => v.approve
    ).length;

    if (approvingVotes >= requiredVotes) {
      proposal.status = 'accepted';
      this.emit('consensus.achieved', { proposalId, approved: true });
    } else if (proposal.votes.size >= n && approvingVotes < requiredVotes) {
      proposal.status = 'rejected';
      this.emit('consensus.achieved', { proposalId, approved: false });
    }
  }

  async awaitConsensus(proposalId: string): Promise<ConsensusResult> {
    const startTime = Date.now();

    return new Promise((resolve, reject) => {
      const checkInterval = setInterval(() => {
        const proposal = this.proposals.get(proposalId);
        if (!proposal) {
          clearInterval(checkInterval);
          reject(new Error(`Proposal ${proposalId} not found`));
          return;
        }

        if (proposal.status !== 'pending') {
          clearInterval(checkInterval);
          resolve(this.createResult(proposal, Date.now() - startTime));
          return;
        }

        if (Date.now() - startTime > (this.config.timeoutMs ?? 30000)) {
          clearInterval(checkInterval);
          proposal.status = 'expired';
          resolve(this.createResult(proposal, Date.now() - startTime));
        }
      }, 10);
    });
  }

  // ===== MESSAGE HANDLERS =====

  async handlePrePrepare(message: ByzantineMessage): Promise<void> {
    // Validate message
    if (message.viewNumber !== this.node.viewNumber) {
      return;
    }

    // Accept pre-prepare from primary
    const proposalId = `bft_${message.viewNumber}_${message.sequenceNumber}`;

    if (!this.proposals.has(proposalId) && message.payload !== undefined) {
      const proposal: ConsensusProposal = {
        id: proposalId,
        proposerId: message.senderId,
        value: message.payload,
        term: message.viewNumber,
        timestamp: message.timestamp,
        votes: new Map(),
        status: 'pending',
      };
      this.proposals.set(proposalId, proposal);
    }

    // Send prepare message
    const prepareMsg: ByzantineMessage = {
      type: 'prepare',
      viewNumber: message.viewNumber,
      sequenceNumber: message.sequenceNumber,
      digest: message.digest,
      senderId: this.node.id,
      timestamp: new Date(),
    };

    await this.broadcastMessage(prepareMsg);
    await this.handlePrepare(prepareMsg);
  }

  async handlePrepare(message: ByzantineMessage): Promise<void> {
    const key = `${message.viewNumber}_${message.sequenceNumber}`;

    if (!this.messageLog.has(key)) {
      this.messageLog.set(key, []);
    }

    const messages = this.messageLog.get(key)!;
    const hasPrepare = messages.some(
      m => m.type === 'prepare' && m.senderId === message.senderId
    );

    if (!hasPrepare) {
      messages.push(message);
    }

    // Check if prepared (2f + 1 prepare messages)
    const f = this.byzantineF();
    const prepareCount = messages.filter(m => m.type === 'prepare').length;

    if (prepareCount >= 2 * f + 1) {
      const proposalId = `bft_${message.viewNumber}_${message.sequenceNumber}`;
      this.node.preparedMessages.set(key, messages);

      // Send commit message
      const commitMsg: ByzantineMessage = {
        type: 'commit',
        viewNumber: message.viewNumber,
        sequenceNumber: message.sequenceNumber,
        digest: message.digest,
        senderId: this.node.id,
        timestamp: new Date(),
      };

      await this.broadcastMessage(commitMsg);
      await this.handleCommit(commitMsg);

      // Record vote
      const proposal = this.proposals.get(proposalId);
      if (proposal) {
        proposal.votes.set(this.node.id, {
          voterId: this.node.id,
          approve: true,
          confidence: 1.0,
          timestamp: new Date(),
        });
      }
    }
  }

  async handleCommit(message: ByzantineMessage): Promise<void> {
    const key = `${message.viewNumber}_${message.sequenceNumber}`;

    if (!this.messageLog.has(key)) {
      this.messageLog.set(key, []);
    }

    const messages = this.messageLog.get(key)!;
    const hasCommit = messages.some(
      m => m.type === 'commit' && m.senderId === message.senderId
    );

    if (!hasCommit) {
      messages.push(message);
    }

    // Check if committed (2f + 1 commit messages)
    const f = this.byzantineF();
    const commitCount = messages.filter(m => m.type === 'commit').length;

    if (commitCount >= 2 * f + 1) {
      const proposalId = `bft_${message.viewNumber}_${message.sequenceNumber}`;
      this.node.committedMessages.set(key, messages);

      const proposal = this.proposals.get(proposalId);
      if (proposal && proposal.status === 'pending') {
        proposal.status = 'accepted';
        this.emit('consensus.achieved', { proposalId, approved: true });
      }
    }
  }

  // ===== VIEW CHANGE =====

  async initiateViewChange(): Promise<void> {
    this.node.viewNumber++;

    this.emit('view.changing', { newViewNumber: this.node.viewNumber });

    // Elect new primary
    this.electPrimary();

    this.emit('view.changed', { viewNumber: this.node.viewNumber });
  }

  // ===== PRIVATE METHODS =====

  private async broadcastMessage(message: ByzantineMessage): Promise<void> {
    // Always emit for observability / legacy external relay.
    this.emit('message.broadcast', { message });
    for (const node of this.nodes.values()) {
      this.emit('message.sent', { to: node.id, message });
    }

    // ADR-095 G2 — when a transport is wired, actually send the message
    // over it (signed by the transport if signing is enabled). This is
    // the real inter-node path; the emits above stay for observers.
    if (this.transport) {
      const cm: Omit<ConsensusMessage, 'from'> = {
        type: message.type,
        payload: {
          type: message.type,
          viewNumber: message.viewNumber,
          sequenceNumber: message.sequenceNumber,
          digest: message.digest,
          timestamp: message.timestamp.toISOString(),
          payload: message.payload,
        },
        viewNumber: message.viewNumber,
      };
      try {
        await this.transport.broadcast(cm);
      } catch {
        // Transport failures are non-fatal at the protocol layer — the
        // proposal will simply not reach consensus and time out, which is
        // the correct failure mode (no liveness, but no incorrect commit).
      }
    }
  }

  private computeDigest(value: unknown): string {
    // ADR-095 G2 — was a 32-bit string-hash "for demonstration"; replaced
    // with sha256 so digests are collision-resistant (PBFT relies on the
    // digest uniquely identifying the request being agreed on).
    return createHash('sha256').update(JSON.stringify(value ?? null)).digest('hex');
  }

  private createResult(proposal: ConsensusProposal, durationMs: number): ConsensusResult {
    const n = this.nodes.size + 1;
    const approvingVotes = Array.from(proposal.votes.values()).filter(
      v => v.approve
    ).length;

    return {
      proposalId: proposal.id,
      approved: proposal.status === 'accepted',
      approvalRate: proposal.votes.size > 0
        ? approvingVotes / proposal.votes.size
        : 0,
      participationRate: proposal.votes.size / n,
      finalValue: proposal.value,
      rounds: 3, // pre-prepare, prepare, commit
      durationMs,
    };
  }

  // ===== STATE QUERIES =====

  isPrimary(): boolean {
    return this.node.isPrimary;
  }

  getViewNumber(): number {
    return this.node.viewNumber;
  }

  getSequenceNumber(): number {
    return this.node.sequenceNumber;
  }

  getPreparedCount(): number {
    return this.node.preparedMessages.size;
  }

  getCommittedCount(): number {
    return this.node.committedMessages.size;
  }

  getMaxFaultyNodes(): number {
    const n = this.nodes.size + 1;
    return Math.floor((n - 1) / 3);
  }

  canTolerate(faultyCount: number): boolean {
    return faultyCount <= this.getMaxFaultyNodes();
  }
}

export function createByzantineConsensus(
  nodeId: string,
  config?: ByzantineConfig
): ByzantineConsensus {
  return new ByzantineConsensus(nodeId, config);
}
