import { ShortChannelId } from "@node-lightning/core";
import { ILogger } from "@node-lightning/logger";
import { QueryChannelRangeMessage } from "../messages/QueryChannelRangeMessage";
import { ReplyChannelRangeMessage } from "../messages/ReplyChannelRangeMessage";
import { IMessageSender } from "../Peer";
import { GossipError, GossipErrorCode } from "./GossipError";

export enum ChannelRangeQueryState {
    Idle,
    Active,
    Complete,
    Failed,
}

/**
 * Performs a single query_channel_range operation and encapsulates the state
 * machine performed during the query operations.
 *
 * A single query_channel_range may be too large to fit in a single
 * reply_channel_range response. When this happens, there will be multiple
 * reply_channel_range responses to address the query_channel_range message.
 *
 * There are two modes of replies: Legacy LND and Standard.
 *
 * Standard was defined in changes to BOLT7 that were merged in with #730. These
 * changes clarified the meaning of the reply fields to ensure the recipient
 * of replies knows when the query has successfully completed. This includes:
 *
 *  - a node must not submit a new query_channel_range until the full set of
 *  reply_channel_range messages covering the range are received.
 *
 *  - clarified the meaning of full_information (previously complete) to
 *  indicate that the responding node contains complete information for the
 *  chain_hash, not that the reply is complete.
 *
 *  - enforces that when there are multiple reply_channel_range msgs, the
 *  range of blocks must be strictly increasing until the full query range is
 *  covered. This range may exceed (both below and above) the request range.
 *  This means:
 *     - the first reply first_blocknum must be <= requests first_blocknum
 *     - subsequent first_blocknum will be strictly increasing from the
 *     previous reply's first_blocknum+number_of_blocks
 *     - the final first_blocknum+number_of_blocks must be >= the query's
 *     first_blocknum+number_of_blocks
 *
 * This is functionality is a clarification from Legacy LND mode where
 * gossip_queries was implemented in a different manner.
 *
 * - full_information indicated a multipart message that was incomplete
 * - lack of short_channel_ids and full_information=false can be treated
 *   as a failure condition
 */
export class ChannelRangeQuery {
    private _state: ChannelRangeQueryState;
    private _isLegacy = false;
    private _query: QueryChannelRangeMessage;
    private _results: ShortChannelId[] = [];
    private _error: GossipError;
    private _resolve: (scids: ShortChannelId[]) => void;
    private _reject: (reason: unknown) => void;

    constructor(
        readonly chainHash: Buffer,
        readonly messageSender: IMessageSender,
        readonly logger: ILogger,
        isLegacy: boolean = false,
    ) {
        this._state = ChannelRangeQueryState.Idle;
        this._isLegacy = isLegacy;
    }

    /**
     * Returns true if we detect this is using the legacy querying gossip_queries
     * mechanism that was originally implemented in LND. This code may be able to
     * be removed eventually.
     */
    public get isLegacy(): boolean {
        return this._isLegacy;
    }

    /**
     * Gets the current state of the Query object
     */
    public get state(): ChannelRangeQueryState {
        return this._state;
    }

    /**
     * Results found
     */
    public get results(): ShortChannelId[] {
        return this._results;
    }

    /**
     * Gets the error that was encountered during processing
     */
    public get error(): GossipError {
        return this._error;
    }

    /**
     * Handles a reply_channel_range message. May initiate a message
     * broadcast.
     */
    public handleReplyChannelRange(msg: ReplyChannelRangeMessage): void {
        // check the incoming message to see if we need to transition to legacy
        // mode. If it is determined to be in legacy mode, we will switch the
        // strategy that is used to handle the reply.
        if (!this._isLegacy && this._isLegacyReply(msg)) {
            this._isLegacy = true;
            this.logger.info("using legacy LND query_channel_range technique");
        }

        // handle the message according to which state the reply system is working
        if (this._isLegacy) {
            this._handleLegacyReply(msg);
        } else {
            this._handleReply(msg);
        }
    }

    /**
     * Resolves when the query has completed, otherwise it will reject on an
     * error.
     */
    public queryRange(
        firstBlock: number = 0,
        numBlocks = 4294967295 - firstBlock,
    ): Promise<ShortChannelId[]> {
        // Construct a promise that will be resolved after all query logic has
        // succeeded or failed. This is a slightly different pattern in that
        // we use an private event emitter to signal completion or failure
        // asynchronously. We use those handlers to resolve the promise. As a
        // result, the external interface is very clean, but we can have
        // complicated internal operations
        return new Promise((resolve, reject) => {
            // transition the state to active
            this._state = ChannelRangeQueryState.Active;

            // send the query message and start the process
            this._sendQuery(firstBlock, numBlocks);

            // capture the promise methods so we can invoke them from
            // within our state machine.
            this._resolve = resolve;
            this._reject = reject;
        });
    }

    /**
     * Idempotent method that marks the state machine failed
     * @param error
     */
    private _transitionFailed(error: GossipError) {
        if (this._state !== ChannelRangeQueryState.Active) return;
        this._error = error;
        this._state = ChannelRangeQueryState.Failed;
        this._reject(error);
    }

    /**
     * Idempotent method that marks the state machine complete
     */
    private _transitionComplete() {
        if (this._state !== ChannelRangeQueryState.Active) return;
        this._state = ChannelRangeQueryState.Complete;
        this._resolve(this._results);
    }

    /**
     * Constructs and sends the query message the remote peer.
     * @param firstBlock
     * @param numBlocks
     */
    private _sendQuery(firstBlock: number, numBlocks: number) {
        this.logger.info(
            "sending query_channel_range start_block=%d end_block=%d",
            firstBlock,
            firstBlock + numBlocks - 1,
        );

        // send message
        const msg = new QueryChannelRangeMessage();
        msg.chainHash = this.chainHash;
        msg.firstBlocknum = firstBlock;
        msg.numberOfBlocks = numBlocks;
        this.messageSender.sendMessage(msg);

        // capture the active query to check reply if it is a legacy reply
        this._query = msg;
    }

    /**
     * Check if this has the signature of a legacy reply. We can detect this by
     * looking at a complete=false and that scids exist.
     * @param msg
     */
    private _isLegacyReply(msg: ReplyChannelRangeMessage): boolean {
        return !msg.fullInformation && msg.shortChannelIds.length > 0;
    }

    /**
     * Handles a reply_channel_range message which ensures that the entire queried
     * range has been received. The responder can reply with pre-sized ranges
     * which means the reply range may not be the EXACT range requested but will
     * include the queried range.
     *
     * For a query range with first_blocknum and number_of_blocks arguments,
     * we can expect messages to have the following:
     *
     *  - first reply first_blocknum <= requested first_blocknum
     *  - intermediate replies sequentially ordered so that first_blocknum is the
     *    first_blocknum + number_of_blocks from previous reply (strictly ordered)
     *  - last reply has fist_blocknum + number_of_blocks >= the queries
     *    first_blocknum + number_of_blocks
     *
     * This ordering allows us to know when a message is complete. If a reply has
     * full_information=false, then the remote peer does not maintain a
     * up-to-date information for the supplied chain_hash.
     * @param msg
     */
    private _handleReply(msg: ReplyChannelRangeMessage) {
        this.logger.debug(
            "received reply_channel_range - full_info=%d start_block=%d end_block=%d scid_count=%d",
            msg.fullInformation,
            msg.firstBlocknum,
            msg.firstBlocknum + msg.numberOfBlocks - 1,
            msg.shortChannelIds.length,
        );

        // enqueues any scids to be processed by a query_short_chan_id message
        if (msg.shortChannelIds.length) {
            this._results.push(...msg.shortChannelIds);
        }

        // The full_information flag should only return false when the remote peer
        // does not maintain up-to-date information for the request chain_hash
        if (!msg.fullInformation) {
            const error = new GossipError(GossipErrorCode.ReplyChannelRangeNoInformation, msg);
            this._transitionFailed(error);
            return;
        }

        // We can finished when we have received a reply that covers the full range
        // of requested data. We know the final block height will be the querie's
        // first_blocknum + number_of_blocks.
        const currentHeight = msg.firstBlocknum + msg.numberOfBlocks;
        const targetHeight = this._query.firstBlocknum + this._query.numberOfBlocks;
        if (currentHeight >= targetHeight) {
            this.logger.debug(
                "received final reply_channel_range height %d >= query_channel_range height %d",
                currentHeight,
                targetHeight,
            );
            this._transitionComplete();
            return;
        }
    }

    /**
     * Handles a reply_channel_range message using the legacy strategy. This
     * code will error if fullInformation=0 scids=[] and will be considered
     * complete when fullInformation=1.
     * @param msg
     */
    private _handleLegacyReply(msg: ReplyChannelRangeMessage) {
        this.logger.debug(
            "received reply_channel_range - full_info=%d start_block=%d end_block=%d scid_count=%d",
            msg.fullInformation,
            msg.firstBlocknum,
            msg.firstBlocknum + msg.numberOfBlocks - 1,
            msg.shortChannelIds.length,
        );

        // enqueues any scids to be processed by a query_short_chan_id message
        if (msg.shortChannelIds.length) {
            this.results.push(...msg.shortChannelIds);
        }

        // Check the complete flag and the existance of SCIDs. Unfortunately,
        // non-confirming implementations are incorrectly using the completion
        // flag to a multi-message reply.
        if (msg.fullInformation && !this.results.length) {
            const error = new GossipError(GossipErrorCode.ReplyChannelRangeNoInformation, msg);
            this._transitionFailed(error);
            return;
        }

        // If we see a fullInformation flag then we have received all parts of
        // the multipart message and are complete.
        if (msg.fullInformation) {
            this._transitionComplete();
            return;
        }
    }
}
