class ServerSet {
    constructor(servers, unreachable_neighbors) {
        if (!servers || !Array.isArray(servers)) {
            throw new Error('no servers')
        }
        if (!unreachable_neighbors || typeof unreachable_neighbors.queryUnreachable !== 'function' || typeof unreachable_neighbors.unreachable !== 'function') {
            throw new Error('no unreachable_neighbors')
        }
        this.servers = [...new Set(servers)] // deduplicate
        this.unreachable_neighbors = unreachable_neighbors;
    }

    add(server) {
        console.log('adding server', server)
        if (!server || typeof server !== 'string') {
            throw new Error('server must be a string')
        }
        if (server in this.servers) {
            console.log('server already in set', server)
            return
        }
        this.servers.push(server);
    }

    async get(auth, target) {
        if (!auth || typeof auth.buildAuthHeader !== 'function') {
            throw new Error('no auth')
        }
        for (const server of this.servers) {
            try {
                if (this.unreachable_neighbors.queryUnreachable(server)) {
                    continue
                }
                const url = "https://" + server + target // TODO https
                return await fetch(url, {
                    method: 'GET', headers: {
                        ...auth.buildAuthHeader(url)
                    }, credentials: 'omit'
                }).catch(err => {
                    console.error('get from server failed', server, err)
                    this.unreachable_neighbors.unreachable(server)
                }).then(response => response.json())
            } catch (e) {
                console.error('get from server failed', server, e)
            }
        }
        throw new Error('all servers failed')
    }

    async post(auth, target, data) {
        if (!auth || typeof auth.buildAuthHeader !== 'function') {
            throw new Error('no auth')
        }
        for (const server of this.servers) {
            try {
                if (this.unreachable_neighbors.queryUnreachable(server)) {
                    continue
                }
                const url = "https://" + server + target // TODO https
                return await fetch(url, {
                    method: 'POST', headers: {
                        'Content-Type': 'application/json', ...auth.buildAuthHeader(url, data)
                    }, credentials: 'omit', body: JSON.stringify(data)
                }).catch(err => {
                    console.error('post to server failed', server, err)
                    this.unreachable_neighbors.unreachable(server)
                }).then(response => response.json())
            } catch (e) {
                console.error('post to server failed', server, e)
            }
        }
        throw new Error('all servers failed')
    }

    async patch(auth, target, data) {
        if (!auth || typeof auth.buildAuthHeader !== 'function') {
            throw new Error('no auth')
        }
        for (const server of this.servers) {
            try {
                if (this.unreachable_neighbors.queryUnreachable(server)) {
                    continue
                }
                const url = "https://" + server + target // TODO https
                return await fetch(url, {
                    method: 'PATCH', headers: {
                        'Content-Type': 'application/json', ...auth.buildAuthHeader(url, data)
                    }, credentials: 'omit', body: JSON.stringify(data)
                }).catch(err => {
                    console.error('patch to server failed', server, err)
                    this.unreachable_neighbors.unreachable(server)
                }).then(response => response.json())
            } catch (e) {
                console.error('patch to server failed', server, e)
            }
        }
        throw new Error('all servers failed')
    }

    async put(auth, target, data) {
        if (!auth || typeof auth.buildAuthHeader !== 'function') {
            throw new Error('no auth')
        }
        for (const server of this.servers) {
            try {
                if (this.unreachable_neighbors.queryUnreachable(server)) {
                    continue
                }
                const url = "https://" + server + target // TODO https
                return await fetch(url, {
                    method: 'PUT', headers: {
                        'Content-Type': 'application/json', ...auth.buildAuthHeader(url, data)
                    }, credentials: 'omit', body: JSON.stringify(data)
                }).catch(err => {
                    console.error('put to server failed', server, err)
                    this.unreachable_neighbors.unreachable(server)
                }).then(response => response.json())
            } catch (e) {
                console.error('put to server failed', server, e)
            }
        }
        throw new Error('all servers failed')
    }

    async delete(auth, target) {
        if (!auth || typeof auth.buildAuthHeader !== 'function') {
            throw new Error('no auth')
        }
        for (const server of this.servers) {
            try {
                if (this.unreachable_neighbors.queryUnreachable(server)) {
                    continue
                }
                const url = "https://" + server + target // TODO https
                return await fetch(url, {
                    method: 'DELETE', headers: {
                        ...auth.buildAuthHeader(url)
                    }, credentials: 'omit'
                }).catch(err => {
                    console.error('delete from server failed', server, err)
                    this.unreachable_neighbors.unreachable(server)
                })
            } catch (e) {
                console.error('delete from server failed', server, e)
            }
        }
        throw new Error('all servers failed')
    }

    async getRaw(auth, target) {
        if (!auth || typeof auth.buildAuthHeader !== 'function') {
            throw new Error('no auth')
        }
        for (const server of this.servers) {
            try {
                if (this.unreachable_neighbors.queryUnreachable(server)) {
                    continue
                }
                const url = "https://" + server + target // TODO https
                return await fetch(url, {
                    method: 'GET', headers: {
                        ...auth.buildAuthHeader(url)
                    }, credentials: 'omit'
                }).catch(err => {
                    console.error('get from server failed', server, err)
                    this.unreachable_neighbors.unreachable(server)
                })
            } catch (e) {
                console.error('get from server failed', server, e)
            }
        }
        throw new Error('all servers failed')
    }

    async postRaw(auth, target, data) {
        if (!auth || typeof auth.buildAuthHeader !== 'function') {
            throw new Error('no auth')
        }
        for (const server of this.servers) {
            try {
                if (this.unreachable_neighbors.queryUnreachable(server)) {
                    continue
                }
                const url = "https://" + server + target // TODO https
                return await fetch(url, {
                    method: 'POST', headers: {
                        'Content-Type': 'application/json', ...auth.buildAuthHeader(url, data)
                    }, credentials: 'omit', body: data
                }).catch(err => {
                    console.error('post to server failed', server, err)
                    this.unreachable_neighbors.unreachable(server)
                })
            } catch (e) {
                console.error('post to server failed', server, e)
            }
        }
        throw new Error('all servers failed')
    }
}

//class ServerSetUnion {
//    constructor(serverSets) {
//        if (!serverSets || !Array.isArray(serverSets)) {
//            throw new Error('no serverSets')
//        }
//        this.serverSets = serverSets;
//    }
//
//    add(serverset) {
//        if (!serverset || !(serverset instanceof ServerSet)) {
//            throw new Error('no serverset')
//        }
//        if (this.serverSets.find(s => serverset.servers.every(s2 => s.servers.includes(s2)))) {
//            console.warn('serverset already in union', serverset)
//            return
//        }
//        this.serverSets.push(serverset)
//    }
//
//    async get(auth, target) {
//        try {
//            return await this.serverSets.reduce(async (acc, serverset) => {
//                return acc.then(async (acc) => {
//                    return acc.concat(await serverset.get(auth, target))
//                })
//            }, Promise.resolve([]))
//        } catch (e) {
//            throw new Error('all servers failed')
//        }
//    }
//
//    async post(auth, target, data) {
//        try {
//            return await this.serverSets.reduce(async (acc, serverset) => {
//                return acc.then(async (acc) => {
//                    return acc.concat(await serverset.post(auth, target, data))
//                })
//            }, Promise.resolve([]))
//        } catch (e) {
//            throw new Error('all servers failed')
//        }
//    }
//
//    async patch(auth, target, data) {
//        try {
//            return await this.serverSets.reduce(async (acc, serverset) => {
//                return acc.then(async (acc) => {
//                    return acc.concat(await serverset.patch(auth, target, data))
//                })
//            }, Promise.resolve([]))
//        } catch (e) {
//            throw new Error('all servers failed')
//        }
//    }
//
//    async put(auth, target, data) {
//        try {
//            return await this.serverSets.reduce(async (acc, serverset) => {
//                return acc.then(async (acc) => {
//                    return acc.concat(await serverset.put(auth, target, data))
//                })
//            }, Promise.resolve([]))
//        } catch (e) {
//            throw new Error('all servers failed')
//        }
//    }
//
//    async delete(auth, target) {
//        try {
//            return await this.serverSets.reduce(async (acc, serverset) => {
//                return acc.then(async (acc) => {
//                    return acc.concat(await serverset.delete(auth, target))
//                })
//            }, Promise.resolve([]))
//        } catch (e) {
//            throw new Error('all servers failed')
//        }
//    }
//}


function ServerSetUnion(serverSets) {
    return new Proxy(serverSets, {
        get: function (target, prop, receiver) {
            // Must precede the generic forwarding check below: ServerSet.prototype also has its
            // own add(server), so funcs.includes('add') would otherwise always be true and this
            // union-specific add would never run.
            if (prop === 'add') {
                return function (serverset) {
                    if (!serverset || !(serverset instanceof ServerSet)) {
                        throw new Error('no serverset')
                    }
                    if (target.find(s => serverset.servers.every(s2 => s.servers.includes(s2)))) {
                        console.warn('serverset already in union', serverset)
                        return
                    }
                    target.push(serverset)
                }
            }
            const funcs = Object.getOwnPropertyNames(ServerSet.prototype)
            if (funcs.includes(prop)) {
                return async function (...args) {
                    try {
                        return await target.reduce(async (acc, serverset) => {
                            return acc.then(async (acc) => {
                                return acc.concat(await serverset[prop](...args))
                            })
                        }, Promise.resolve([]))
                    } catch (e) {
                        throw new Error('all servers failed')
                    }
                }
            } else {
                return target[prop]
            }
        },
    });
}


class authMethod {
    constructor(method, auth) {
        this.method = method;
        this.auth = auth;
    }

    buildAuthHeader(url, data) {
        return this.method(this.auth, {url, data})
    }

}

function createSignAuth(username, signKey) {
    const context = {username, signKey}
    if (!context.signKey || !context.username || typeof context.username !== 'string'
        || !(context.signKey instanceof Uint8Array) || context.signKey.length !== 64) {
        throw new Error('no signKey or username')
    }
    return new authMethod(({signKey, username}, {url, data}) => {
        const json = JSON.stringify(data)
        const signature = nacl.crypto_sign_detached(nacl.encode_utf8(url + (data ? json : "")), signKey)
        return {'Authorization': 'Signature ' + username + ':' + nacl.to_hex(signature)}
    }, context)
}

function createTokenAuth(token) {
    const context = {token}
    if (!context.token) {
        throw new Error('no token')
    }
    return new authMethod(({token}, {url, data}) => {
        return {'Authorization': 'Token ' + token}
    }, context)
}

function createNullAuth() {
    return new authMethod(() => {
        return {}
    }, {})
}

export {ServerSet, ServerSetUnion, createSignAuth, createTokenAuth, createNullAuth};


