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 = "http://" + 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 = "http://" + 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 = "http://" + 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 = "http://" + 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 = "http://" + 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: 'PUT',
                    headers: {
                        'Content-Type': 'application/json',
                        ...auth.buildAuthHeader(url, data)
                    },
                    credentials: 'omit'
                }).catch(err => {
                        console.error('get from server failed', server, err)
                        this.unreachable_neighbors.unreachable(server)
                    }
                )
            } 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')
    }
}

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')
        }
    }
}


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')
        }
    }
}


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};