"use strict";

var Zmodem = module.exports;

/**
 * This is where the protocol-level logic lives: the interaction of ZMODEM
 * headers and subpackets. The logic here is not unlikely to need tweaking
 * as little edge cases crop up.
 */

Zmodem.DEBUG = false;

Object.assign(
    Zmodem,
    require("./encode"),
    require("./text"),
    require("./zdle"),
    require("./zmlib"),
    require("./zheader"),
    require("./zsubpacket"),
    require("./zvalidation"),
    require("./zerror")
);

const
    //pertinent to this module
    KEEPALIVE_INTERVAL = 5000,

    //We ourselves don’t need ESCCTL, so we don’t send it;
    //however, we always expect to receive it in ZRINIT.
    //See _ensure_receiver_escapes_ctrl_chars() for more details.
    ZRINIT_FLAGS = [
        "CANFDX",   //full duplex
        "CANOVIO",  //overlap I/O

        //lsz has a buffer overflow bug that shows itself when:
        //
        //  - 16-bit CRC is used, and
        //  - lsz receives the abort sequence while sending a file
        //
        //To avoid this, we just tell lsz to use 32-bit CRC
        //even though there is otherwise no reason. This ensures that
        //unfixed lsz versions will avoid the buffer overflow.
        "CANFC32",
    ],

    //We do this because some WebSocket shell servers
    //(e.g., xterm.js’s demo server) enable the IEXTEN termios flag,
    //which bars 0x0f and 0x16 from reaching the shell process,
    //which results in transmission errors.
    FORCE_ESCAPE_CTRL_CHARS = true,

    DEFAULT_RECEIVE_INPUT_MODE = "spool_uint8array",

    //pertinent to ZMODEM
    MAX_CHUNK_LENGTH = 8192,    //1 KiB officially, but lrzsz allows 8192
    BS = 0x8,
    OVER_AND_OUT = [ 79, 79 ],
    ABORT_SEQUENCE = Zmodem.ZMLIB.ABORT_SEQUENCE
;

/**
 * A base class for objects that have events.
 *
 * @private
 */
class _Eventer {

    /**
     * Not called directly.
     */
    constructor() {
        this._on_evt = {};
        this._evt_once_index = {};
    }

    _Add_event(evt_name) {
        this._on_evt[evt_name] = [];
        this._evt_once_index[evt_name] = [];
    }

    _get_evt_queue(evt_name) {
        if (!this._on_evt[evt_name]) {
            throw( "Bad event: " + evt_name );
        }

        return this._on_evt[evt_name];
    }

    /**
     * Register a callback for a given event.
     *
     * @param {string} evt_name - The name of the event.
     *
     * @param {Function} todo - The function to execute when the event happens.
     */
    on(evt_name, todo) {
        var queue = this._get_evt_queue(evt_name);

        queue.push(todo);

        return this;
    }

    /**
     * Unregister a callback for a given event.
     *
     * @param {string} evt_name - The name of the event.
     *
     * @param {Function} [todo] - The function to execute when the event
     *  happens. If not given, the last event registered for the event
     *  is unregistered.
     */
    off(evt_name, todo) {
        var queue = this._get_evt_queue(evt_name);

        if (todo) {
            var at = queue.indexOf(todo);
            if (at === -1) {
                throw("“" + todo + "” is not in the “" + evt_name + "” queue.");
            }
            queue.splice(at, 1);
        }
        else {
            queue.pop();
        }

        return this;
    }

    _Happen(evt_name /*, arg0, arg1, .. */) {
        var queue = this._get_evt_queue(evt_name);   //might as well validate

        //console.info("EVENT", this, arguments);

        var args = Array.apply(null, arguments);
        args.shift();

        var sess = this;

        queue.forEach( function(cb) { cb.apply(sess, args) } );

        return queue.length;
    }
}

/**
 * The Session classes handle the protocol-level logic.
 * These shield the user from dealing with headers and subpackets.
 * This is a base class with functionality common to both Receive
 * and Send subclasses.
 *
 * @extends _Eventer
*/
Zmodem.Session = class ZmodemSession extends _Eventer {

    /**
     * Parse out a hex header from the given array.
     * If there’s a ZRQINIT or ZRINIT at the beginning,
     * we’ll return it. If the input isn’t a header,
     * for whatever reason, we return undefined.
     *
     * @param {number[]} octets - The bytes to parse.
     *
     * @return {Session|undefined} A Session object if the beginning
     *      of a session was parsable in “octets”; otherwise undefined.
     */
    static parse( octets ) {

        //Will need to trap errors.
        var hdr;
        try {
            hdr = Zmodem.Header.parse_hex(octets);
        }
        catch(e) {     //Don’t report since we aren’t in session

            //debug
            //console.warn("No hex header: ", e);

            return;
        }

        if (!hdr) return;

        switch (hdr.NAME) {
            case "ZRQINIT":
                //throw if ZCOMMAND
                return new Zmodem.Session.Receive();
            case "ZRINIT":
                return new Zmodem.Session.Send(hdr);
        }

        //console.warn("Invalid first Zmodem header", hdr);
    }

    /**
     * Sets the sender function that a Session object will use.
     *
     * @param {Function} sender_func - The function to call.
     *      It will receive an Array with the relevant octets.
     *
     * @return {Session} The session object (for chaining).
     */
    set_sender(sender_func) {
        this._sender = sender_func;
        return this;
    }

    /**
     * Whether the current Session has ended.
     *
     * @returns {boolean} The ended state.
     */
    has_ended() { return this._has_ended() }

    /**
     * Consumes an array of octets as ZMODEM session input.
     *
     * @param {number[]} octets - The input octets.
     */
    consume(octets) {
        this._before_consume(octets);

        if (this._aborted) throw new Zmodem.Error('already_aborted');

        if (!octets.length) return;

        this._strip_and_enqueue_input(octets);

        if (!this._check_for_abort_sequence(octets)) {
            this._consume_first();
        }

        return;
    }

    /**
     * Whether the current Session has been `abort()`ed.
     *
     * @returns {boolean} The aborted state.
     */
    aborted() { return !!this._aborted }

    /**
     * Not called directly.
     */
    constructor() {
        super();
        //if (!sender_func) throw "Need sender!";

        //this._first_header = first_header;
        //this._sender = sender_func;
        this._config = {};

        //this._input = new ZInput();

        this._input_buffer = [];

        //This is mostly for debugging.
        this._Add_event("receive");
        this._Add_event("garbage");
        this._Add_event("session_end");
    }

    /**
     * Returns the Session object’s role.
     *
     * @returns {string} One of:
     * - `receive`
     * - `send`
     */
    get_role() { return this.type }

    _trim_leading_garbage_until_header() {
        var garbage = Zmodem.Header.trim_leading_garbage(this._input_buffer);

        if (garbage.length) {
            if (this._Happen("garbage", garbage) === 0) {
                console.debug(
                    "Garbage: ",
                    String.fromCharCode.apply(String, garbage),
                    garbage
                );
            }
        }
    }

    _parse_and_consume_header() {
        this._trim_leading_garbage_until_header();

        var new_header_and_crc = Zmodem.Header.parse(this._input_buffer);
        if (!new_header_and_crc) return;

        if (Zmodem.DEBUG) {
            this._log_header( "RECEIVED HEADER", new_header_and_crc[0] );
        }

        this._consume_header(new_header_and_crc[0]);

        this._last_header_name = new_header_and_crc[0].NAME;
        this._last_header_crc = new_header_and_crc[1];

        return new_header_and_crc[0];
    }

    _log_header(label, header) {
        console.debug(this.type, label, header.NAME, header._bytes4.join());
    }

    _consume_header(new_header) {
        this._on_receive(new_header);

        var handler = this._next_header_handler && this._next_header_handler[ new_header.NAME ];
        if (!handler) {
            console.error("Unhandled header!", new_header, this._next_header_handler);
            throw new Zmodem.Error( "Unhandled header: " + new_header.NAME );
        }

        this._next_header_handler = null;

        handler.call(this, new_header);
    }

    //TODO: strip out the abort sequence
    _check_for_abort_sequence() {
        var abort_at = Zmodem.ZMLIB.find_subarray( this._input_buffer, ABORT_SEQUENCE );

        if (abort_at !== -1) {

            //TODO: expose this to caller
            this._input_buffer.splice( 0, abort_at + ABORT_SEQUENCE.length );

            this._aborted = true;

            //TODO compare response here to lrzsz.
            this._on_session_end();

            //We shouldn’t ever expect to receive an abort. Even if we
            //have sent an abort ourselves, the Sentry should have stopped
            //directing input to this Session object.
            //if (this._expect_abort) {
            //    return true;
            //}

            throw new Zmodem.Error("peer_aborted");
        }
    }

    _send_header(name /*, args */) {
        if (!this._sender) throw "Need sender!";

        var args = Array.apply( null, arguments );

        var bytes_hdr = this._create_header_bytes(args);

        if (Zmodem.DEBUG) {
            this._log_header( "SENDING HEADER", bytes_hdr[1] );
        }

        this._sender(bytes_hdr[0]);

        this._last_sent_header = bytes_hdr[1];
    }

    _create_header_bytes(name_and_args) {

        var hdr = Zmodem.Header.build.apply( Zmodem.Header, name_and_args );

        var formatter = this._get_header_formatter(name_and_args[0]);

        return [
            hdr[formatter](this._zencoder),
            hdr
        ];
    }

    _strip_and_enqueue_input(input) {
        Zmodem.ZMLIB.strip_ignored_bytes(input);

        //It’s possible that “input” is empty at this point.
        //It doesn’t seem to hurt anything to keep processing, though.

        this._input_buffer.push.apply( this._input_buffer, input );
    }

    /**
     * **STOP!** You probably want to `skip()` an Offer rather than
     * `abort()`. See below.
     *
     * Abort the current session by sending the ZMODEM abort sequence.
     * This function will cause the Session object to refuse to send
     * any further data.
     *
     * Zmodem.Sentry is configured to send all output to the terminal
     * after a session’s `abort()`. That could result in lots of
     * ZMODEM garble being sent to the JavaScript terminal, which you
     * probably don’t want.
     *
     * `skip()` on an Offer is better because Session will continue to
     * discard data until we reach either another file or the
     * sender-initiated end of the ZMODEM session. So no ZMODEM garble,
     * and the session will end successfully.
     *
     * The behavior of `abort()` is subject to change since it’s not
     * very useful as currently implemented.
     */
    abort() {

        //this._expect_abort = true;

        //From Forsberg:
        //
        //The Cancel sequence consists of eight CAN characters
        //and ten backspace characters. ZMODEM only requires five
        //Cancel characters; the other three are "insurance".
        //The trailing backspace characters attempt to erase
        //the effects of the CAN characters if they are
        //received by a command interpreter.
        //
        //FG: Since we assume our connection is reliable, there’s
        //no reason to send more than 5 CANs.
        this._sender(
            ABORT_SEQUENCE.concat([ BS, BS, BS, BS, BS ])
        );

        this._aborted = true;
        this._sender = function() {
            throw new Zmodem.Error('already_aborted');
        };

        this._on_session_end();

        return;
    }

    //----------------------------------------------------------------------
    _on_session_end() {
        this._Happen("session_end");
    }

    _on_receive(hdr_or_pkt) {
        this._Happen("receive", hdr_or_pkt);
    }

    _before_consume() {}
}

function _trim_OO(array) {
    if (0 === Zmodem.ZMLIB.find_subarray(array, OVER_AND_OUT)) {
        array.splice(0, OVER_AND_OUT.length);
    }

    //TODO: This assumes OVER_AND_OUT is 2 bytes long. No biggie, but.
    else if ( array[0] === OVER_AND_OUT[ OVER_AND_OUT.length - 1 ] ) {
        array.splice(0, 1);
    }

    return array;
}

/** A class for ZMODEM receive sessions.
 *
 * @extends Session
 */
Zmodem.Session.Receive = class ZmodemReceiveSession extends Zmodem.Session {
    //We only get 1 file at a time, so on each consume() either
    //continue state for the current file or start a new one.

    /**
     * Not called directly.
     */
    constructor() {
        super();

        this._Add_event("offer");
        this._Add_event("data_in");
        this._Add_event("file_end");
    }

    /**
     * Consume input bytes from the sender.
     *
     * @private
     * @param {number[]} octets - The bytes to consume.
     */
    _before_consume(octets) {
        if (this._bytes_after_OO) {
            throw "PROTOCOL: Session is completed!";
        }

        //Put this here so that our logic later on has access to the
        //input string and can populate _bytes_after_OO when the
        //session ends.
        this._bytes_being_consumed = octets;
    }

    /**
     * Return any bytes that have been `consume()`d but
     * came after the end of the ZMODEM session.
     *
     * @returns {number[]} The trailing bytes.
     */
    get_trailing_bytes() {
        if (this._aborted) return [];

        if (!this._bytes_after_OO) {
            throw "PROTOCOL: Session is not completed!";
        }

        return this._bytes_after_OO.slice(0);
    }

    _has_ended() { return this.aborted() || !!this._bytes_after_OO }

    //Receiver always sends hex headers.
    _get_header_formatter() { return "to_hex" }

    _parse_and_consume_subpacket() {
        var parse_func;
        if (this._last_header_crc === 16) {
            parse_func = "parse16";
        }
        else {
            parse_func = "parse32";
        }

        var subpacket = Zmodem.Subpacket[parse_func](this._input_buffer);

        if (subpacket) {
            if (Zmodem.DEBUG) {
                console.debug(this.type, "RECEIVED SUBPACKET", subpacket);
            }

            this._consume_data(subpacket);

            //What state are we in if the subpacket indicates frame end
            //but we haven’t gotten ZEOF yet? Can anything other than ZEOF
            //follow after a ZDATA?
            if (subpacket.frame_end()) {
                this._next_subpacket_handler = null;
            }
        }

        return subpacket;
    }

    _consume_first() {
        if (this._got_ZFIN) {
            if (this._input_buffer.length < 2) return;

            //if it’s OO, then set this._bytes_after_OO
            if (Zmodem.ZMLIB.find_subarray(this._input_buffer, OVER_AND_OUT) === 0) {

                //This doubles as an indication that the session has ended.
                //We need to set this right away so that handlers like
                //"session_end" will have access to it.
                this._bytes_after_OO = _trim_OO(this._bytes_being_consumed.slice(0));
                this._on_session_end();

                return;
            }
            else {
                throw( "PROTOCOL: Only thing after ZFIN should be “OO” (79,79), not: " + this._input_buffer.join() );
            }
        }

        var parsed;
        do {
            if (this._next_subpacket_handler) {
                parsed = this._parse_and_consume_subpacket();
            }
            else {
                parsed = this._parse_and_consume_header();
            }
        } while (parsed && this._input_buffer.length);
    }

    _consume_data(subpacket) {
        this._on_receive(subpacket);

        if (!this._next_subpacket_handler) {
            throw( "PROTOCOL: Received unexpected data packet after " + this._last_header_name + " header: " + subpacket.get_payload().join() );
        }

        this._next_subpacket_handler.call(this, subpacket);
    }

    _octets_to_string(octets) {
        if (!this._textdecoder) {
            this._textdecoder = new Zmodem.Text.Decoder();
        }

        return this._textdecoder.decode( new Uint8Array(octets) );
    }

    _consume_ZFILE_data(hdr, subpacket) {
        if (this._file_info) {
            throw "PROTOCOL: second ZFILE data subpacket received";
        }

        var packet_payload = subpacket.get_payload();
        var nul_at = packet_payload.indexOf(0);

        //
        var fname = this._octets_to_string( packet_payload.slice(0, nul_at) );
        var the_rest = this._octets_to_string( packet_payload.slice( 1 + nul_at ) ).split(" ");

        var mtime = the_rest[1] && parseInt( the_rest[1], 8 ) || undefined;
        if (mtime) {
            mtime = new Date(mtime * 1000);
        }

        this._file_info = {
            name: fname,
            size: the_rest[0] ? parseInt( the_rest[0], 10 ) : null,
            mtime: mtime || null,
            mode: the_rest[2] && parseInt( the_rest[2], 8 ) || null,
            serial: the_rest[3] && parseInt( the_rest[3], 10 ) || null,

            files_remaining: the_rest[4] ? parseInt( the_rest[4], 10 ) : null,
            bytes_remaining: the_rest[5] ? parseInt( the_rest[5], 10 ) : null,
        };

        //console.log("ZFILE", hdr);

        var xfer = new Offer(
            hdr.get_options(),
            this._file_info,
            this._accept.bind(this),
            this._skip.bind(this)
        );
        this._current_transfer = xfer;

        //this._Happen("offer", xfer);
    }

    _consume_ZDATA_data(subpacket) {
        if (!this._accepted_offer) {
            throw "PROTOCOL: Received data without accepting!";
        }

        //TODO: Probably should include some sort of preventive against
        //infinite loop here: if the peer hasn’t sent us what we want after,
        //say, 10 ZRPOS headers then we should send ZABORT and just end.
        if (!this._offset_ok) {
            console.warn("offset not ok!");
            _send_ZRPOS();
            return;
        }

        this._file_offset += subpacket.get_payload().length;
        this._on_data_in(subpacket);

        /*
        console.warn("received error from data_in callback; retrying", e);
        throw "unimplemented";
        */

        if (subpacket.ack_expected() && !subpacket.frame_end()) {
            this._send_header( "ZACK", Zmodem.ENCODELIB.pack_u32_le(this._file_offset) );
        }
    }

    _make_promise_for_between_files() {
        var sess = this;

        return new Promise( function(res) {
            var between_files_handler = {
                ZFILE: function(hdr) {
                    this._next_subpacket_handler = function(subpacket) {
                        this._next_subpacket_handler = null;
                        this._consume_ZFILE_data(hdr, subpacket);
                        this._Happen("offer", this._current_transfer);
                        res(this._current_transfer);
                    };
                },

                //We use this as a keep-alive. Maybe other
                //implementations do, too?
                ZSINIT: function(hdr) {
                    //The content of this header doesn’t affect us
                    //since all it does is tell us details of how
                    //the sender will ZDLE-encode binary data. Our
                    //ZDLE parser doesn’t need to know in advance.

                    sess._next_subpacket_handler = function(spkt) {
                        sess._next_subpacket_handler = null;
                        sess._consume_ZSINIT_data(spkt);
                        sess._send_header('ZACK');
                        sess._next_header_handler = between_files_handler;
                    };
                },

                ZFIN: function() {
                    this._consume_ZFIN();
                    res();
                },
            };

            sess._next_header_handler = between_files_handler;
        } );
    }

    _consume_ZSINIT_data(spkt) {

        //TODO: Should this be used when we signal a cancellation?
        this._attn = spkt.get_payload();
    }

    /**
     * Start the ZMODEM session by signaling to the sender that
     * we are ready for the first file offer.
     *
     * @returns {Promise} A promise that resolves with an Offer object
     * or, if the sender closes the session immediately without offering
     * anything, nothing.
     */
    start() {
        if (this._started) throw "Already started!";
        this._started = true;

        var ret = this._make_promise_for_between_files();

        this._send_ZRINIT();

        return ret;
    }

    //Returns a promise that’s fulfilled when the file
    //transfer is done.
    //
    //  That ZEOF promise return is another promise that’s
    //  fulfilled when we get either ZFIN or another ZFILE.
    _accept(offset) {
        this._accepted_offer = true;
        this._file_offset = offset || 0;

        var sess = this;

        var ret = new Promise( function(resolve_accept) {
            var last_ZDATA;

            sess._next_header_handler = {
                ZDATA: function on_ZDATA(hdr) {
                    this._consume_ZDATA(hdr);

                    this._next_subpacket_handler = this._consume_ZDATA_data;

                    this._next_header_handler = {
                        ZEOF: function on_ZEOF(hdr) {

                            // Do this first to verify the ZEOF.
                            // This also fires the “file_end” event.
                            this._consume_ZEOF(hdr);

                            this._next_subpacket_handler = null;

                            // We don’t care about this promise.
                            // Prior to v0.1.8 we did because we called
                            // resolve_accept() at the resolution of this
                            // promise, but that was a bad idea and was
                            // never documented, so 0.1.8 changed it.
                            this._make_promise_for_between_files();

                            resolve_accept();

                            this._send_ZRINIT();
                        },
                    };
                },
            };
        } );

        this._send_ZRPOS();

        return ret;
    }

    _skip() {
        var ret = this._make_promise_for_between_files();

        if (this._accepted_offer) {
            // There’s a race condition where we might attempt to
            // skip() an in-progress transfer near its end but actually
            // the skip() will fire after the transfer is complete.
            // While there might be ways to prevent this, they likely
            // would require extra work on the part of implementations.
            //
            // It seems far simpler just to make this function a no-op
            // in these cases.
            if (!this._current_transfer) return;

            //For cancel of an in-progress transfer from lsz,
            //it’s necessary to avoid this buffer overflow bug:
            //
            //  https://github.com/gooselinux/lrzsz/blob/master/lrzsz-0.12.20.patch
            //
            //… which we do by asking for CRC32 from lsz.

            //We might or might not have consumed ZDATA.
            //The sender also might or might not send a ZEOF before it
            //parses the ZSKIP. Thus, we want to ignore the following:
            //
            //  - ZDATA
            //  - ZDATA then ZEOF
            //  - ZEOF
            //
            //… and just look for the next between-file header.

            var bound_make_promise_for_between_files = function() {

                //Once this happens we fail on any received data packet.
                //So it needs not to happen until we’ve received a header.
                this._accepted_offer = false;
                this._next_subpacket_handler = null;

                this._make_promise_for_between_files();
            }.bind(this);

            Object.assign(
                this._next_header_handler,
                {
                    ZEOF: bound_make_promise_for_between_files,
                    ZDATA: function() {
                        bound_make_promise_for_between_files();
                        this._next_header_handler.ZEOF = bound_make_promise_for_between_files;
                    }.bind(this),
                }
            );
        }

        //this._accepted_offer = false;

        this._file_info = null;

        this._send_header( "ZSKIP" );

        return ret;
    }

    _send_ZRINIT() {
        this._send_header( "ZRINIT", ZRINIT_FLAGS );
    }

    _consume_ZFIN() {
        this._got_ZFIN = true;
        this._send_header( "ZFIN" );
    }

    _consume_ZEOF(header) {
        if (this._file_offset !== header.get_offset()) {
            throw( "ZEOF offset mismatch; unimplemented (local: " + this._file_offset + "; ZEOF: " + header.get_offset() + ")" );
        }

        this._on_file_end();

        //Preserve these two so that file_end callbacks
        //will have the right information.
        this._file_info = null;
        this._current_transfer = null;
    }

    _consume_ZDATA(header) {
        if ( this._file_offset === header.get_offset() ) {
            this._offset_ok = true;
        }
        else {
            throw "Error correction is unimplemented.";
        }
    }

    _send_ZRPOS() {
        this._send_header( "ZRPOS", this._file_offset );
    }

    //----------------------------------------------------------------------
    //events

    _on_file_end() {
        this._Happen("file_end");

        if (this._current_transfer) {
            this._current_transfer._Happen("complete");
            this._current_transfer = null;
        }
    }

    _on_data_in(subpacket) {
        this._Happen("data_in", subpacket);

        if (this._current_transfer) {
            this._current_transfer._Happen("input", subpacket.get_payload());
        }
    }
}

Object.assign(
    Zmodem.Session.Receive.prototype,
    {
        type: "receive",
    }
);

//----------------------------------------------------------------------

/**
 * @typedef {Object} FileDetails
 *
 * @property {string} name - The name of the file.
 *
 * @property {number} [size] - The file size, in bytes.
 *
 * @property {number} [mode] - The file mode (e.g., 0100644).
 *
 * @property {Date|number} [mtime] - The file’s modification time.
 *  When expressed as a number, the unit is epoch seconds.
 *
 * @property {number} [files_remaining] - Inclusive of the current file,
 *  so this value is never less than 1.
 *
 * @property {number} [bytes_remaining] - Inclusive of the current file.
 */

/**
 * Common methods for Transfer and Offer objects.
 *
 * @mixin
 */
var Transfer_Offer_Mixin = {
    /**
     * Returns the file details object.
     * @returns {FileDetails} `mtime` is a Date.
     */
    get_details: function get_details() {
        return Object.assign( {}, this._file_info );
    },

    /**
     * Returns a parse of the ZFILE header’s payload.
     *
     * @returns {Object} Members are:
     *
     * - `conversion` (string | undefined)
     * - `management` (string | undefined)
     * - `transfer` (string | undefined)
     * - `sparse` (boolean)
     */
    get_options: function get_options() {
        return Object.assign( {}, this._zfile_opts );
    },

    /**
     * Returns the offset based on the last transferred chunk.
     * @returns {number} The file offset (i.e., number of bytes after
     *  the start of the file).
     */
    get_offset: function get_offset() {
        return this._file_offset;
    },
};

/**
 * A class to represent a sender’s interaction with a single file
 * transfer within a batch. When a receiver accepts an offer, the
 * Session instantiates this class and passes the instance as the
 * promise resolution from send_offer().
 *
 * @mixes Transfer_Offer_Mixin
 */
class Transfer {

    /**
     * Not called directly.
     */
    constructor(file_info, offset, send_func, end_func) {
        this._file_info = file_info;
        this._file_offset = offset || 0;

        this._send = send_func;
        this._end = end_func;
    }

    /**
     * Send a (non-terminal) piece of the file.
     *
     * @param { number[] | Uint8Array } array_like - The bytes to send.
     */
    send(array_like) {
        this._send(array_like);
        this._file_offset += array_like.length;
    }

    /**
     * Complete the file transfer.
     *
     * @param { number[] | Uint8Array } [array_like] - The last bytes to send.
     *
     * @return { Promise } Resolves when the receiver has indicated
     *      acceptance of the end of the file transfer.
     */
    end(array_like) {
        var ret = this._end(array_like || []);
        if (array_like) this._file_offset += array_like.length;
        return ret;
    }
}
Object.assign( Transfer.prototype, Transfer_Offer_Mixin );

/**
 * A class to represent a receiver’s interaction with a single file
 * transfer offer within a batch. There is functionality here to
 * skip or accept offered files and either to spool the packet
 * payloads or to handle them yourself.
 *
 * @mixes Transfer_Offer_Mixin
 */
class Offer extends _Eventer {

    /**
     * Not called directly.
     */
    constructor(zfile_opts, file_info, accept_func, skip_func) {
        super();

        this._zfile_opts = zfile_opts;
        this._file_info = file_info;

        this._accept_func = accept_func;
        this._skip_func = skip_func;

        this._Add_event("input");
        this._Add_event("complete");

        //Register this first so that application handlers receive
        //the updated offset.
        this.on("input", this._input_handler);
    }

    _verify_not_skipped() {
        if (this._skipped) {
            throw new Zmodem.Error("Already skipped!");
        }
    }

    /**
     * Tell the sender that you don’t want the offered file.
     *
     * You can send this in lieu of `accept()` or after it, e.g.,
     * if you find that the transfer is taking too long. Note that,
     * if you `skip()` after you `accept()`, you’ll likely have to
     * wait for buffers to clear out.
     *
     */
    skip() {
        this._verify_not_skipped();
        this._skipped = true;

        return this._skip_func.apply(this, arguments);
    }

    /**
     * Tell the sender to send the offered file.
     *
     * @param {Object} [opts] - Can be:
     * @param {string} [opts.oninput=spool_uint8array] - Can be:
     *
     * - `spool_uint8array`: Stores the ZMODEM
     *     packet payloads as Uint8Array instances.
     *     This makes for an easy transition to a Blob,
     *     which JavaScript can use to save the file to disk.
     *
     * - `spool_array`: Stores the ZMODEM packet payloads
     *     as Array instances. Each value is an octet value.
     *
     * - (function): A handler that receives each payload
     *     as it arrives. The Offer object does not store
     *     the payloads internally when thus configured.
     *
     * @return { Promise } Resolves when the file is fully received.
     *      If the Offer has been spooling
     *      the packet payloads, the promise resolves with an Array
     *      that contains those payloads.
     */
    accept(opts) {
        this._verify_not_skipped();

        if (this._accepted) {
            throw new Zmodem.Error("Already accepted!");
        }
        this._accepted = true;

        if (!opts) opts = {};

        this._file_offset = opts.offset || 0;

        switch (opts.on_input) {
            case null:
            case undefined:
            case "spool_array":
            case DEFAULT_RECEIVE_INPUT_MODE:    //default
                this._spool = [];
                break;
            default:
                if (typeof opts.on_input !== "function") {
                    throw "Invalid “on_input”: " + opts.on_input;
                }
        }

        this._input_handler_mode = opts.on_input || DEFAULT_RECEIVE_INPUT_MODE;

        return this._accept_func(this._file_offset).then( this._get_spool.bind(this) );
    }

    _input_handler(payload) {
        this._file_offset += payload.length;

        if (typeof this._input_handler_mode === "function") {
            this._input_handler_mode(payload);
        }
        else {
            if (this._input_handler_mode === DEFAULT_RECEIVE_INPUT_MODE) {
                payload = new Uint8Array(payload);
            }

            //sanity
            else if (this._input_handler_mode !== "spool_array") {
                throw new Zmodem.Error("WTF?? _input_handler_mode = " + this._input_handler_mode);
            }

            this._spool.push(payload);
        }
    }

    _get_spool() {
        return this._spool;
    }
}
Object.assign( Offer.prototype, Transfer_Offer_Mixin );

//Curious that ZSINIT isn’t here … but, lsz sends it as hex.
const SENDER_BINARY_HEADER = {
    ZFILE: true,
    ZDATA: true,
};

/**
 * A class that encapsulates behavior for a ZMODEM sender.
 *
 * @extends Session
 */
Zmodem.Session.Send = class ZmodemSendSession extends Zmodem.Session {

    /**
     * Not called directly.
     */
    constructor(zrinit_hdr) {
        super();

        if (!zrinit_hdr) {
            throw "Need first header!";
        }
        else if (zrinit_hdr.NAME !== "ZRINIT") {
            throw("First header should be ZRINIT, not " + zrinit_hdr.NAME);
        }

        this._last_header_name = 'ZRINIT';

        //We don’t need to send crc32. Even if the other side can grok it,
        //there’s no point to sending it since, for now, we assume we’re
        //on a reliable connection, e.g., TCP. Ideally we’d just forgo
        //CRC checks completely, but ZMODEM doesn’t allow that.
        //
        //If we *were* to start using crc32, we’d update this every time
        //we send a header.
        this._subpacket_encode_func = 'encode16';

        this._zencoder = new Zmodem.ZDLE();

        this._consume_ZRINIT(zrinit_hdr);

        this._file_offset = 0;

        var zrqinit_count = 0;

        this._start_keepalive_on_set_sender = true;

        //lrzsz will send ZRINIT until it gets an offer. (keep-alive?)
        //It sends 4 additional ones after the initial ZRINIT and, if
        //no response is received, starts sending “C” (0x43, 67) as if to
        //try to downgrade to XMODEM or YMODEM.
        //var sess = this;
        //this._prepare_to_receive_ZRINIT( function keep_alive() {
        //    sess._prepare_to_receive_ZRINIT(keep_alive);
        //} );

        //queue up the ZSINIT flag to send -- but seems useless??

        /*
        Object.assign(
            this._on_evt,
            {
                file_received: [],
            },
        };
        */
    }

    /**
     * Sets the sender function. The first time this is called,
     * it will also initiate a keepalive using ZSINIT until the
     * first file is sent.
     *
     * @param {Function} func - The function to call.
     *  It will receive an Array with the relevant octets.
     *
     * @return {Session} The session object (for chaining).
     */
    set_sender(func) {
        super.set_sender(func);

        if (this._start_keepalive_on_set_sender) {
            this._start_keepalive_on_set_sender = false;
            this._start_keepalive();
        }

        return this;
    }

    //7.3.3 .. The sender also uses hex headers when they are
    //not followed by binary data subpackets.
    //
    //FG: … or when the header is ZSINIT? That’s what lrzsz does, anyway.
    //Then it sends a single NUL byte as the payload to an end_ack subpacket.
    _get_header_formatter(name) {
        return SENDER_BINARY_HEADER[name] ? "to_binary16" : "to_hex";
    }

    //In order to keep lrzsz from timing out, we send ZSINIT every 5 seconds.
    //Maybe make this configurable?
    _start_keepalive() {
        //if (this._keepalive_promise) throw "Keep-alive already started!";
        if (!this._keepalive_promise) {
            var sess = this;

            this._keepalive_promise = new Promise(function(resolve) {
                //console.log("SETTING KEEPALIVE TIMEOUT");
                sess._keepalive_timeout = setTimeout(resolve, KEEPALIVE_INTERVAL);
            }).then( function() {
                sess._next_header_handler = {
                    ZACK: function() {

                        //We’re going to need to ensure that the
                        //receiver is ready for all control characters
                        //to be escaped. If we’ve already sent a ZSINIT
                        //and gotten a response, then we know that that
                        //work is already done later on when we actually
                        //send an offer.
                        sess._got_ZSINIT_ZACK = true;
                    },
                };
                sess._send_ZSINIT();

                sess._keepalive_promise = null;
                sess._start_keepalive();
            });
        }
    }

    _stop_keepalive() {
        if (this._keepalive_promise) {
            //console.log("STOPPING KEEPALIVE");
            clearTimeout(this._keepalive_timeout);
            this._keep_alive_promise = null;
        }
    }

    _send_ZSINIT() {
        //See note at _ensure_receiver_escapes_ctrl_chars()
        //for why we have to pass ESCCTL.

        var zsinit_flags = [];
        if (this._zencoder.escapes_ctrl_chars()) {
            zsinit_flags.push("ESCCTL");
        }

        this._send_header_and_data(
            ["ZSINIT", zsinit_flags],
            [0],
            "end_ack"
        );
    }

    _consume_ZRINIT(hdr) {
        this._last_ZRINIT = hdr;

        if (hdr.get_buffer_size()) {
            throw( "Buffer size (" + hdr.get_buffer_size() + ") is unsupported!" );
        }

        if (!hdr.can_full_duplex()) {
            throw( "Half-duplex I/O is unsupported!" );
        }

        if (!hdr.can_overlap_io()) {
            throw( "Non-overlap I/O is unsupported!" );
        }

        if (hdr.escape_8th_bit()) {
            throw( "8-bit escaping is unsupported!" );
        }

        if (FORCE_ESCAPE_CTRL_CHARS) {
            this._zencoder.set_escape_ctrl_chars(true);
            if (!hdr.escape_ctrl_chars()) {
                console.debug("Peer didn’t request escape of all control characters. Will send ZSINIT to force recognition of escaped control characters.");
            }
        }
        else {
            this._zencoder.set_escape_ctrl_chars(hdr.escape_ctrl_chars());
        }
    }

    //https://stackoverflow.com/questions/23155939/missing-0xf-and-0x16-when-binary-data-through-virtual-serial-port-pair-created-b
    //^^ Because of that, we always escape control characters.
    //The alternative would be that lrz would never receive those
    //two bytes from zmodem.js.
    _ensure_receiver_escapes_ctrl_chars() {
        var promise;

        var needs_ZSINIT = !this._last_ZRINIT.escape_ctrl_chars() && !this._got_ZSINIT_ZACK;

        if (needs_ZSINIT) {
            var sess = this;
            promise = new Promise( function(res) {
                sess._next_header_handler = {
                    ZACK: (hdr) => {
                        res();
                    },
                };
                sess._send_ZSINIT();
            } );
        }
        else {
            promise = Promise.resolve();
        }

        return promise;
    }

    _convert_params_to_offer_payload_array(params) {
        params = Zmodem.Validation.offer_parameters(params);

        var subpacket_payload = params.name + "\x00";

        var subpacket_space_pieces = [
            (params.size || 0).toString(10),
            params.mtime ? params.mtime.toString(8) : "0",
            params.mode ? (0x8000 | params.mode).toString(8) : "0",
            "0",    //serial
        ];

        if (params.files_remaining) {
            subpacket_space_pieces.push( params.files_remaining );

            if (params.bytes_remaining) {
                subpacket_space_pieces.push( params.bytes_remaining );
            }
        }

        subpacket_payload += subpacket_space_pieces.join(" ");
        return this._string_to_octets(subpacket_payload);
    }

    /**
     * Send an offer to the receiver.
     *
     * @param {FileDetails} params - All about the file you want to transfer.
     *
     * @returns {Promise} If the receiver accepts the offer, then the
     * resolution is a Transfer object; otherwise the resolution is
     * undefined.
     */
    send_offer(params) {
        if (Zmodem.DEBUG) {
            console.debug("SENDING OFFER", params);
        }

        if (!params) throw "need file params!";

        if (this._sending_file) throw "Already sending file!";

        var payload_array = this._convert_params_to_offer_payload_array(params);

        this._stop_keepalive();

        var sess = this;

        function zrpos_handler_setter_func() {
            sess._next_header_handler = {

                // The receiver may send ZRPOS in at least two cases:
                //
                // 1) A malformed subpacket arrived, so we need to
                // “rewind” a bit and continue from the receiver’s
                // last-successful location in the file.
                //
                // 2) The receiver hasn’t gotten any data for a bit,
                // so it sends ZRPOS as a “ping”.
                //
                // Case #1 shouldn’t happen since zmodem.js requires a
                // reliable transport. Case #2, though, can happen due
                // to either normal network congestion or errors in
                // implementation. In either case, there’s nothing for
                // us to do but to ignore the ZRPOS, with an optional
                // warning.
                //
                ZRPOS: function(hdr) {
                    if (Zmodem.DEBUG) {
                        console.warn("Mid-transfer ZRPOS … implementation error?");
                    }

                    zrpos_handler_setter_func();
                },
            };
        };

        var doer_func = function() {

            //return Promise object that is fulfilled when the ZRPOS or ZSKIP arrives.
            //The promise value is the byte offset, or undefined for ZSKIP.
            //If ZRPOS arrives, then send ZDATA(0) and set this._sending_file.
            var handler_setter_promise = new Promise( function(res) {
                sess._next_header_handler = {
                    ZSKIP: function() {
                        sess._start_keepalive();
                        res();
                    },
                    ZRPOS: function(hdr) {
                        sess._sending_file = true;

                        zrpos_handler_setter_func();

                        res(
                            new Transfer(
                                params,
                                hdr.get_offset(),
                                sess._send_interim_file_piece.bind(sess),
                                sess._end_file.bind(sess)
                            )
                        );
                    },
                };
            } );

            sess._send_header_and_data( ["ZFILE"], payload_array, "end_ack" );

            delete sess._sent_ZDATA;

            return handler_setter_promise;
        };

        if (FORCE_ESCAPE_CTRL_CHARS) {
            return this._ensure_receiver_escapes_ctrl_chars().then(doer_func);
        }

        return doer_func();
    }

    _send_header_and_data( hdr_name_and_args, data_arr, frameend ) {
        var bytes_hdr = this._create_header_bytes(hdr_name_and_args);

        var data_bytes = this._build_subpacket_bytes(data_arr, frameend);

        bytes_hdr[0].push.apply( bytes_hdr[0], data_bytes );

        if (Zmodem.DEBUG) {
            this._log_header( "SENDING HEADER", bytes_hdr[1] );
            console.debug( this.type, "-- HEADER PAYLOAD:", frameend, data_bytes.length );
        }

        this._sender( bytes_hdr[0] );

        this._last_sent_header = bytes_hdr[1];
    }

    _build_subpacket_bytes( bytes_arr, frameend ) {
        var subpacket = Zmodem.Subpacket.build(bytes_arr, frameend);

        return subpacket[this._subpacket_encode_func]( this._zencoder );
    }

    _build_and_send_subpacket( bytes_arr, frameend ) {
        this._sender( this._build_subpacket_bytes(bytes_arr, frameend) );
    }

    _string_to_octets(string) {
        if (!this._textencoder) {
            this._textencoder = new Zmodem.Text.Encoder();
        }

        var uint8arr = this._textencoder.encode(string);
        return Array.prototype.slice.call(uint8arr);
    }

    /*
    Potential future support for responding to ZRPOS:
    send_file_offset(offset) {
    }
    */

    /*
        Sending logic works thus:
            - ASSUME the receiver can overlap I/O (CANOVIO)
                (so fail if !CANFDX || !CANOVIO)
            - Sender opens the firehose … all ZCRCG (!end/!ack)
                until the end, when we send a ZCRCE (end/!ack)
                NB: try 8k/32k/64k chunk sizes? Looks like there’s
                no need to change the packet otherwise.
    */
    //TODO: Put this on a Transfer object similar to what Receive uses?
    _send_interim_file_piece(bytes_obj) {

        //We don’t ask the receiver to confirm because there’s no need.
        this._send_file_part(bytes_obj, "no_end_no_ack");

        //This pattern will allow
        //error-correction without buffering the entire stream in JS.
        //For now the promise is always resolved, but in the future we
        //can make it only resolve once we’ve gotten acknowledgement.
        return Promise.resolve();
    }

    _ensure_we_are_sending() {
        if (!this._sending_file) throw "Not sending a file currently!";
    }

    //This resolves once we receive ZEOF.
    _end_file(bytes_obj) {
        this._ensure_we_are_sending();

        //Is the frame-end-ness of this last packet redundant
        //with the ZEOF packet?? - No. It signals the receiver that
        //the next thing to expect is a header, not a packet.

        //no-ack, following lrzsz’s example
        this._send_file_part(bytes_obj, "end_no_ack");

        var sess = this;

        //Register this before we send ZEOF in case of local round-trip.
        //(Basically just for synchronous testing, but.)
        var ret = new Promise( function(res) {
            //console.log("UNSETTING SENDING FLAG");
            sess._sending_file = false;
            sess._prepare_to_receive_ZRINIT(res);
        } );

        this._send_header( "ZEOF", this._file_offset );

        this._file_offset = 0;

        return ret;
    }

    //Called at the beginning of our session
    //and also when we’re done sending a file.
    _prepare_to_receive_ZRINIT(after_consume) {
        this._next_header_handler = {
            ZRINIT: function(hdr) {
                this._consume_ZRINIT(hdr);
                if (after_consume) after_consume();
            },
        };
    }

    /**
     * Signal to the receiver that the ZMODEM session is wrapping up.
     *
     * @returns {Promise} Resolves when the receiver has responded to
     * our signal that the session is over.
     */
    close() {
        var ok_to_close = (this._last_header_name === "ZRINIT")
        if (!ok_to_close) {
            ok_to_close = (this._last_header_name === "ZSKIP");
        }
        if (!ok_to_close) {
            ok_to_close = (this._last_sent_header.name === "ZSINIT") &&  (this._last_header_name === "ZACK");
        }

        if (!ok_to_close) {
            throw( "Can’t close; last received header was “" + this._last_header_name + "”" );
        }

        var sess = this;

        var ret = new Promise( function(res, rej) {
            sess._next_header_handler = {
                ZFIN: function() {
                    sess._sender( OVER_AND_OUT );
                    sess._sent_OO = true;
                    sess._on_session_end();
                    res();
                },
            };
        } );

        this._send_header("ZFIN");

        return ret;
    }

    _has_ended() {
        return this.aborted() || !!this._sent_OO;
    }

    _send_file_part(bytes_obj, final_packetend) {
        if (!this._sent_ZDATA) {
            this._send_header( "ZDATA", this._file_offset );
            this._sent_ZDATA = true;
        }

        var obj_offset = 0;

        var bytes_count = bytes_obj.length;

        //We have to go through at least once in event of an
        //empty buffer, e.g., an empty end_file.
        while (true) {
            var chunk_size = Math.min(obj_offset + MAX_CHUNK_LENGTH, bytes_count) - obj_offset;

            var at_end = (chunk_size + obj_offset) >= bytes_count;

            var chunk = bytes_obj.slice( obj_offset, obj_offset + chunk_size );
            if (!(chunk instanceof Array)) {
                chunk = Array.prototype.slice.call(chunk);
            }

            this._build_and_send_subpacket(
                chunk,
                at_end ? final_packetend : "no_end_no_ack"
            );

            this._file_offset += chunk_size;
            obj_offset += chunk_size;

            if (obj_offset >= bytes_count) break;
        }
    }

    _consume_first() {
        if (!this._parse_and_consume_header()) {

            //When the ZMODEM receive program starts, it immediately sends
            //a ZRINIT header to initiate ZMODEM file transfers, or a
            //ZCHALLENGE header to verify the sending program. The receive
            //program resends its header at response time (default 10 second)
            //intervals for a suitable period of time (40 seconds total)
            //before falling back to YMODEM protocol.
            if (this._input_buffer.join() === "67") {
                throw "Receiver has fallen back to YMODEM.";
            }
        }
    }

    _on_session_end() {
        this._stop_keepalive();
        super._on_session_end();
    }
}

Object.assign(
    Zmodem.Session.Send.prototype,
    {
        type: "send",
    }
);
