import { PersistentTransport } from '../communication-interface/PersistentTransport';
import { TransportInterface } from '../communication-interface/TransportInterface';
import { timeout } from '../helper';

enum ConnectionState {
    OFFER = 'cs_o',
    ANSWER = 'cs_a',
    ESTABLISHED = 'cs_e'
}

export class WebRTCTransport implements PersistentTransport {
    private userConnections: Map<string, { connection: RTCPeerConnection, channelPromise: Promise<RTCDataChannel>, candidates: RTCIceCandidate[], connectionState: ConnectionState }> = new Map();

    constructor(private dataChannelName: string) {
        this.dataChannelName = dataChannelName;

        this._messageReceived = this._messageReceived.bind(this);
    }
    requiresBootstrap(): boolean {
        return true;
    }

    /**
     * Returns a webRTC offer to send to the other user
     * A webRTC offer is the first step in establishing a connection
     * the connection establishment works as follows:
     * 1. getOwnAddress is called
     * 2. the returned offer is sent to the other user
     * 3. the other user calls addContact with the offer
     * 4. the returned answer is sent to the other user
     * 5. the other user calls addContact with the answer
     * 
     * @param userId the userId of the user to connect to (for internal book keeping)
     * @returns a webRTC offer to send to the other user
     */
    async getOwnAddress(userId?: string): Promise<string> {
        if (!userId) {
            throw new Error('Please specify a userId');
        }

        const candidates: RTCIceCandidate[] = [];
        const connection = new RTCPeerConnection({
            iceServers: [
                { urls: 'stun:stun.l.google.com:19302' },
                { urls: 'stun:stun.cloudflare.com:3478' },
                { urls: 'stun:freeturn.net:3478' },
                { urls: 'stun:freeturn.net:5349' },
                { urls: 'turns:freeturn.tel:5349', username: 'free', credential: 'free' },
                { urls: 'turn:freeturn.net:3478', username: 'free', credential: 'free' }]
        });
        const channelPromise: Promise<RTCDataChannel> = new Promise(resolve => {
            const c =
                connection.createDataChannel(this.dataChannelName);
            c.onopen = () => {
                if (c.readyState === 'open') {
                    resolve(c);
                    this.userConnections.get(userId)!.connectionState = ConnectionState.ESTABLISHED;
                    console.info(`connection established with ${userId}!`);
                }
            };
        });
        if (this.userConnections.has(userId)) {
            const existingConenction = this.userConnections.get(userId)!;
            if (existingConenction.connectionState === ConnectionState.ESTABLISHED) {
                this.userConnections.delete(userId);
            } else {
                await timeout(5000 * (Math.random() + 0.5));
                if (this.userConnections.has(userId) && this.userConnections.get(userId)!.connectionState === ConnectionState.ESTABLISHED) {
                    throw new Error('connection already established');
                }
            }
        }
        this.userConnections.set(userId, { connection, channelPromise, candidates, connectionState: ConnectionState.OFFER });
        channelPromise.then(c => c.onmessage = this._messageReceived);
        const offer = new RTCSessionDescription(await connection.createOffer());
        connection.setLocalDescription(offer);
        connection.onicecandidate = ({ candidate }) => {
            if (!candidate) {
                return;
            }
            console.log(`got ice candidate ${candidate.candidate}`);
            candidates.push(candidate);
        }
        const icePromise = new Promise<string>(resolve => {
            connection.onicegatheringstatechange = () => {
                if (connection?.iceGatheringState === 'complete') {
                    resolve(JSON.stringify({ candidates, offer }));
                }
            }
        });
        return await Promise.race<string>([icePromise,
            (async () => {
                await timeout(100000);
                if (candidates.length > 0) {
                    return JSON.stringify({ candidates, offer });
                }
                return icePromise
            })()
        ]);

    }

    /**
     * Adds a contact to the transport
     * Please see getOwnAddress for a description of the connection establishment process
     * @param userId the userId of the user to connect to (for internal book keeping)
     * @param address the address bundle retrieved by calling getOwnAddress on the other end (or the answer bundle retrieved by calling addContact on the other end)
     * @returns an address bundle to send to the other end (or undefined if the connection is already established)
     */
    async addContact(userId: string, address: any): Promise<void | string> {
        if (typeof address === 'string') {
            address = JSON.parse(address);
        }
        const connectionState = this.userConnections.get(userId)?.connectionState;
        if (address.answer) {
            if (connectionState !== ConnectionState.OFFER) {
                throw new Error('invalid connection state ' + connectionState);
            }
            const { answer, candidates } = address;
            this.userConnections.get(userId)?.connection.setRemoteDescription(answer);
            for (const candidate of candidates) {
                this.userConnections.get(userId)?.connection.addIceCandidate(candidate);
            }
            this.userConnections.get(userId)?.connection.addIceCandidate({ candidate: '' }); //trigger ice gathering
            return;
        }
        if (connectionState) {
            const existingConenction = this.userConnections.get(userId)!;
            if (existingConenction.connectionState === ConnectionState.ESTABLISHED) {
                this.userConnections.delete(userId);
            } else {
                await timeout(15000 * (Math.random() + 0.5));
                if (this.userConnections.has(userId) && this.userConnections.get(userId)!.connectionState === ConnectionState.ESTABLISHED) {
                    throw new Error('connection already established');
                }
            }
            console.log('got new offer from ' + userId + '. Reconnecting...');
            this.userConnections.delete(userId);
        }
        const connection = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
        const channelPromise: Promise<RTCDataChannel> = new Promise(resolve => {
            connection.ondatachannel = ({ channel }) => channel.onopen = () => {
                if (channel.readyState === 'open') {
                    resolve(channel);
                    this.userConnections.get(userId)!.connectionState = ConnectionState.ESTABLISHED;
                    console.info(`connection established with ${userId}!`);
                }
            }

        });
        channelPromise.then(c => c.onmessage = this._messageReceived);
        const { offer, candidates } = address;
        connection.setRemoteDescription(offer);
        for (const candidate of candidates) {
            connection.addIceCandidate(candidate);
        }
        connection.addIceCandidate({ candidate: '' }); //trigger ice gathering
        const answer = new RTCSessionDescription(await connection.createAnswer());
        connection.setLocalDescription(answer);
        const localCandidates: RTCIceCandidate[] = [];
        connection.onicecandidate = ({ candidate }) => {
            if (!candidate) {
                return;
            }
            console.log(`got ice candidate ${candidate.candidate}`);
            localCandidates.push(candidate);
        }
        this.userConnections.set(userId, { connection, channelPromise, candidates: localCandidates, connectionState: ConnectionState.ANSWER });

        const icePromise = new Promise<string>(resolve => {
            connection.onicegatheringstatechange = () => {
                if (connection?.iceGatheringState === 'complete') {
                    resolve(JSON.stringify({ candidates: localCandidates, answer }));
                }
            }
        });
        return await Promise.race<string>([icePromise,
            (async () => {
                await timeout(100000);
                if (localCandidates.length > 0) {
                    return JSON.stringify({ candidates: localCandidates, answer });
                }
                return icePromise
            })()
        ]);
    }

    /**
     * Disconnects the transport from the specified user
     * @param userId the userId of the user to remove
     */
    removeContact(userId: string): void {
        const connection = this.userConnections.get(userId);
        if (connection) {
            connection.connection.close();
            this.userConnections.delete(userId);
        }
    }



    async send(userIdTo: string, message: string): Promise<void> {
        if (this.userConnections.get(userIdTo)?.channelPromise) {
            const channel = await Promise.race([this.userConnections.get(userIdTo)!.channelPromise, timeout(120000)]);
            if (!channel) {
                this.userConnections.delete(userIdTo);
                throw new Error(`Connection channel to user ${userIdTo} not established`);
            }
            if (channel.readyState !== 'open') {
                throw new Error(`Connection channel to user ${userIdTo} not established`);
            }
            channel.send(message);
        }
        else {
            throw new Error(`Connection to user ${userIdTo} not established`);
        }
    }

    private _messageReceived(event: MessageEvent) {
        const { data } = event;
        this.messageListeners.forEach(cb => cb(data));
    }

    private messageListeners: ((payload: any) => void)[] = [];

    addMessageListener(listener: (payload: any) => void): void | Promise<void> {
        this.messageListeners.push(listener);
    }

    /**
     * get a list of clients directly connected via webRTC
     * @returns the user ids that currently have an established webRTC connection
     */
    getAllContacts(): string[] {
        const users = [];
        for (const [userId, { connectionState }] of this.userConnections) {
            if (connectionState === ConnectionState.ESTABLISHED) {
                users.push(userId);
            }
        }
        return users;
    }
}