package tw_test

import (
	"context"
	"encoding/csv"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"slices"
	"strconv"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"gitea.dwysokinski.me/twhelp/corev3/internal/tw"

	"github.com/stretchr/testify/assert"
)

const testUserAgent = "tribalwarshelp.com/test"

func TestClient_GetOpenServers(t *testing.T) {
	t.Parallel()

	resp, err := os.ReadFile("./testdata/get_servers_resp.txt")
	require.NoError(t, err)

	mux := http.NewServeMux()
	mux.HandleFunc("/backend/get_servers.php", newPlainTextHandler(resp))

	srv := httptest.NewTLSServer(mux)
	t.Cleanup(srv.Close)

	expectedServerKeys := []string{
		"plp8",
		"pl170",
		"pl171",
		"pl172",
		"pl173",
		"plc1",
		"plp7",
		"pl168",
		"pl174",
		"pl159",
		"pl167",
		"pl165",
		"pl169",
		"pls1",
		"pl161",
		"pl164",
	}

	servers, err := newClient(srv.Client()).GetOpenServers(context.Background(), parseURL(t, srv.URL))
	require.NoError(t, err)
	assert.Len(t, servers, len(expectedServerKeys))

	for _, key := range expectedServerKeys {
		assert.True(t, slices.ContainsFunc(servers, func(server tw.Server) bool {
			return server.Key == key && *server.URL == url.URL{
				Scheme: "https",
				Host:   key + ".plemiona.pl",
			}
		}), "key '%s' not found", key)
	}
}

func TestClient_GetServerConfig(t *testing.T) {
	t.Parallel()

	resp, err := os.ReadFile("./testdata/server_config.xml")
	require.NoError(t, err)

	mux := http.NewServeMux()
	mux.HandleFunc("/interface.php", newInterfaceXMLHandler("get_config", resp))

	srv := httptest.NewTLSServer(mux)
	t.Cleanup(srv.Close)

	cfg, err := newClient(srv.Client()).GetServerConfig(context.Background(), parseURL(t, srv.URL))
	require.NoError(t, err)
	assert.InEpsilon(t, 4.5, cfg.Speed, 0.01)
	assert.InEpsilon(t, 0.5, cfg.UnitSpeed, 0.01)
	assert.Equal(t, 1, cfg.Moral)

	assert.Equal(t, 1, cfg.Build.Destroy)

	assert.Equal(t, 2, cfg.Misc.KillRanking)
	assert.Equal(t, 0, cfg.Misc.Tutorial)
	assert.Equal(t, 300, cfg.Misc.TradeCancelTime)

	assert.Equal(t, 0, cfg.Commands.MillisArrival)
	assert.Equal(t, 600, cfg.Commands.CommandCancelTime)

	assert.Equal(t, 3, cfg.Newbie.Days)
	assert.Equal(t, 15, cfg.Newbie.RatioDays)
	assert.Equal(t, 10, cfg.Newbie.Ratio)
	assert.Equal(t, 1, cfg.Newbie.RemoveNewbieVillages)

	assert.Equal(t, 2, cfg.Game.BuildtimeFormula)
	assert.Equal(t, 0, cfg.Game.Knight)
	assert.Equal(t, 1, cfg.Game.KnightNewItems.Int())
	assert.Equal(t, 1, cfg.Game.Archer)
	assert.Equal(t, 2, cfg.Game.Tech)
	assert.Equal(t, 0, cfg.Game.FarmLimit)
	assert.Equal(t, 0, cfg.Game.Church)
	assert.Equal(t, 0, cfg.Game.Watchtower)
	assert.Equal(t, 0, cfg.Game.Stronghold)
	assert.InEpsilon(t, 0.5, cfg.Game.FakeLimit, 0.01)
	assert.InEpsilon(t, 0.001, cfg.Game.BarbarianRise, 0)
	assert.Equal(t, 1, cfg.Game.BarbarianShrink)
	assert.Equal(t, 800, cfg.Game.BarbarianMaxPoints)
	assert.Equal(t, 1, cfg.Game.Scavenging)
	assert.Equal(t, 2, cfg.Game.Hauls)
	assert.Equal(t, 5000, cfg.Game.HaulsBase)
	assert.Equal(t, 1000000, cfg.Game.HaulsMax)
	assert.Equal(t, 0, cfg.Game.Event)
	assert.Equal(t, 1, cfg.Game.SuppressEvents)

	assert.Equal(t, -1, cfg.Buildings.CustomMain)
	assert.Equal(t, -1, cfg.Buildings.CustomFarm)
	assert.Equal(t, -1, cfg.Buildings.CustomStorage)
	assert.Equal(t, -1, cfg.Buildings.CustomPlace)
	assert.Equal(t, -1, cfg.Buildings.CustomBarracks)
	assert.Equal(t, -1, cfg.Buildings.CustomChurch)
	assert.Equal(t, -1, cfg.Buildings.CustomSmith)
	assert.Equal(t, -1, cfg.Buildings.CustomWood)
	assert.Equal(t, -1, cfg.Buildings.CustomStone)
	assert.Equal(t, -1, cfg.Buildings.CustomIron)
	assert.Equal(t, -1, cfg.Buildings.CustomMarket)
	assert.Equal(t, -1, cfg.Buildings.CustomStable)
	assert.Equal(t, -1, cfg.Buildings.CustomWall)
	assert.Equal(t, -1, cfg.Buildings.CustomGarage)
	assert.Equal(t, -1, cfg.Buildings.CustomHide)
	assert.Equal(t, -1, cfg.Buildings.CustomSnob)
	assert.Equal(t, -1, cfg.Buildings.CustomStatue)
	assert.Equal(t, -1, cfg.Buildings.CustomWatchtower)

	assert.Equal(t, 1, cfg.Snob.Gold)
	assert.Equal(t, 0, cfg.Snob.CheapRebuild)
	assert.Equal(t, 2, cfg.Snob.Rise)
	assert.Equal(t, 50, cfg.Snob.MaxDist)
	assert.InEpsilon(t, 1.0, cfg.Snob.Factor, 0.01)
	assert.Equal(t, 28000, cfg.Snob.CoinWood)
	assert.Equal(t, 30000, cfg.Snob.CoinStone)
	assert.Equal(t, 25000, cfg.Snob.CoinIron)
	assert.Equal(t, 0, cfg.Snob.NoBarbConquer)

	assert.Equal(t, 0, cfg.Ally.NoHarm)
	assert.Equal(t, 0, cfg.Ally.NoOtherSupport)
	assert.Equal(t, 0, cfg.Ally.NoOtherSupportType)
	assert.Equal(t, 0, cfg.Ally.AllytimeSupport)
	assert.Equal(t, 0, cfg.Ally.NoLeave)
	assert.Equal(t, 0, cfg.Ally.NoJoin)
	assert.Equal(t, 15, cfg.Ally.Limit)
	assert.Equal(t, 0, cfg.Ally.FixedAllies)
	assert.Equal(t, 5, cfg.Ally.WarsMemberRequirement)
	assert.Equal(t, 15000, cfg.Ally.WarsPointsRequirement)
	assert.Equal(t, 7, cfg.Ally.WarsAutoacceptDays)
	assert.Equal(t, 1, cfg.Ally.Levels)
	assert.Equal(t, "v1", cfg.Ally.XpRequirements)

	assert.Equal(t, 1000, cfg.Coord.MapSize)
	assert.Equal(t, 4, cfg.Coord.Func)
	assert.Equal(t, 11, cfg.Coord.EmptyVillages)
	assert.Equal(t, 1, cfg.Coord.BonusVillages)
	assert.Equal(t, 550, cfg.Coord.Inner)
	assert.Equal(t, 0, cfg.Coord.SelectStart)
	assert.Equal(t, 336, cfg.Coord.VillageMoveWait)
	assert.Equal(t, 1, cfg.Coord.NobleRestart)
	assert.Equal(t, 1, cfg.Coord.StartVillages)

	assert.Equal(t, 1, cfg.Sitter.Allow)

	assert.Equal(t, 0, cfg.Sleep.Active)
	assert.Equal(t, 60, cfg.Sleep.Delay)
	assert.Equal(t, 6, cfg.Sleep.Min)
	assert.Equal(t, 10, cfg.Sleep.Max)
	assert.Equal(t, 12, cfg.Sleep.MinAwake)
	assert.Equal(t, 36, cfg.Sleep.MaxAwake)
	assert.Equal(t, 10, cfg.Sleep.WarnTime)

	assert.Equal(t, 2, cfg.Night.Active)
	assert.Equal(t, 23, cfg.Night.StartHour)
	assert.Equal(t, 7, cfg.Night.EndHour)
	assert.InEpsilon(t, 3.5, cfg.Night.DefFactor, 0.01)
	assert.Equal(t, 14, cfg.Night.Duration)

	assert.Equal(t, 3, cfg.Win.Check)
}

func TestClient_GetUnitInfo(t *testing.T) {
	t.Parallel()

	resp, err := os.ReadFile("./testdata/unit_info.xml")
	require.NoError(t, err)

	mux := http.NewServeMux()
	mux.HandleFunc("/interface.php", newInterfaceXMLHandler("get_unit_info", resp))

	srv := httptest.NewTLSServer(mux)
	t.Cleanup(srv.Close)

	unitInfo, err := newClient(srv.Client()).GetUnitInfo(context.Background(), parseURL(t, srv.URL))
	require.NoError(t, err)

	assert.InEpsilon(t, 226.66666666667, unitInfo.Spear.BuildTime, 0.01)
	assert.Equal(t, 1, unitInfo.Spear.Pop)
	assert.InEpsilon(t, 8.0, unitInfo.Spear.Speed, 0.01)
	assert.Equal(t, 10, unitInfo.Spear.Attack)
	assert.Equal(t, 15, unitInfo.Spear.Defense)
	assert.Equal(t, 45, unitInfo.Spear.DefenseCavalry)
	assert.Equal(t, 20, unitInfo.Spear.DefenseArcher)
	assert.Equal(t, 25, unitInfo.Spear.Carry)

	assert.InEpsilon(t, 333.33333333333, unitInfo.Sword.BuildTime, 0.01)
	assert.Equal(t, 1, unitInfo.Sword.Pop)
	assert.InEpsilon(t, 9.7777777777778, unitInfo.Sword.Speed, 0.01)
	assert.Equal(t, 25, unitInfo.Sword.Attack)
	assert.Equal(t, 50, unitInfo.Sword.Defense)
	assert.Equal(t, 15, unitInfo.Sword.DefenseCavalry)
	assert.Equal(t, 40, unitInfo.Sword.DefenseArcher)
	assert.Equal(t, 15, unitInfo.Sword.Carry)

	assert.InEpsilon(t, 293.33333333333, unitInfo.Axe.BuildTime, 0.01)
	assert.Equal(t, 1, unitInfo.Axe.Pop)
	assert.InEpsilon(t, 8.0, unitInfo.Axe.Speed, 0.01)
	assert.Equal(t, 40, unitInfo.Axe.Attack)
	assert.Equal(t, 10, unitInfo.Axe.Defense)
	assert.Equal(t, 5, unitInfo.Axe.DefenseCavalry)
	assert.Equal(t, 10, unitInfo.Axe.DefenseArcher)
	assert.Equal(t, 10, unitInfo.Axe.Carry)

	assert.InEpsilon(t, 200.0, unitInfo.Spy.BuildTime, 0.01)
	assert.Equal(t, 2, unitInfo.Spy.Pop)
	assert.InEpsilon(t, 4.0, unitInfo.Spy.Speed, 0.01)
	assert.Equal(t, 0, unitInfo.Spy.Attack)
	assert.Equal(t, 2, unitInfo.Spy.Defense)
	assert.Equal(t, 1, unitInfo.Spy.DefenseCavalry)
	assert.Equal(t, 2, unitInfo.Spy.DefenseArcher)
	assert.Equal(t, 0, unitInfo.Spy.Carry)

	assert.InEpsilon(t, 400.0, unitInfo.Light.BuildTime, 0.01)
	assert.Equal(t, 4, unitInfo.Light.Pop)
	assert.InEpsilon(t, 4.4444444444444, unitInfo.Light.Speed, 0.01)
	assert.Equal(t, 130, unitInfo.Light.Attack)
	assert.Equal(t, 30, unitInfo.Light.Defense)
	assert.Equal(t, 40, unitInfo.Light.DefenseCavalry)
	assert.Equal(t, 30, unitInfo.Light.DefenseArcher)
	assert.Equal(t, 80, unitInfo.Light.Carry)

	assert.InEpsilon(t, 600.0, unitInfo.Marcher.BuildTime, 0.01)
	assert.Equal(t, 5, unitInfo.Marcher.Pop)
	assert.InEpsilon(t, 4.4444444444444, unitInfo.Marcher.Speed, 0.01)
	assert.Equal(t, 120, unitInfo.Marcher.Attack)
	assert.Equal(t, 40, unitInfo.Marcher.Defense)
	assert.Equal(t, 30, unitInfo.Marcher.DefenseCavalry)
	assert.Equal(t, 50, unitInfo.Marcher.DefenseArcher)
	assert.Equal(t, 50, unitInfo.Marcher.Carry)

	assert.InEpsilon(t, 800.0, unitInfo.Heavy.BuildTime, 0.01)
	assert.Equal(t, 6, unitInfo.Heavy.Pop)
	assert.InEpsilon(t, 4.8888888888889, unitInfo.Heavy.Speed, 0.01)
	assert.Equal(t, 150, unitInfo.Heavy.Attack)
	assert.Equal(t, 200, unitInfo.Heavy.Defense)
	assert.Equal(t, 80, unitInfo.Heavy.DefenseCavalry)
	assert.Equal(t, 180, unitInfo.Heavy.DefenseArcher)
	assert.Equal(t, 50, unitInfo.Heavy.Carry)

	assert.InEpsilon(t, 1066.6666666667, unitInfo.Ram.BuildTime, 0.01)
	assert.Equal(t, 5, unitInfo.Ram.Pop)
	assert.InEpsilon(t, 13.333333333333, unitInfo.Ram.Speed, 0.01)
	assert.Equal(t, 2, unitInfo.Ram.Attack)
	assert.Equal(t, 20, unitInfo.Ram.Defense)
	assert.Equal(t, 50, unitInfo.Ram.DefenseCavalry)
	assert.Equal(t, 20, unitInfo.Ram.DefenseArcher)
	assert.Equal(t, 0, unitInfo.Ram.Carry)

	assert.InEpsilon(t, 1600.0, unitInfo.Catapult.BuildTime, 0.01)
	assert.Equal(t, 8, unitInfo.Catapult.Pop)
	assert.InEpsilon(t, 13.333333333333, unitInfo.Catapult.Speed, 0.01)
	assert.Equal(t, 100, unitInfo.Catapult.Attack)
	assert.Equal(t, 100, unitInfo.Catapult.Defense)
	assert.Equal(t, 50, unitInfo.Catapult.DefenseCavalry)
	assert.Equal(t, 100, unitInfo.Catapult.DefenseArcher)
	assert.Equal(t, 0, unitInfo.Catapult.Carry)

	assert.InEpsilon(t, 4000.0, unitInfo.Snob.BuildTime, 0.01)
	assert.Equal(t, 100, unitInfo.Snob.Pop)
	assert.InEpsilon(t, 15.555555555556, unitInfo.Snob.Speed, 0.01)
	assert.Equal(t, 30, unitInfo.Snob.Attack)
	assert.Equal(t, 100, unitInfo.Snob.Defense)
	assert.Equal(t, 50, unitInfo.Snob.DefenseCavalry)
	assert.Equal(t, 100, unitInfo.Snob.DefenseArcher)
	assert.Equal(t, 0, unitInfo.Snob.Carry)

	assert.InEpsilon(t, 1.0, unitInfo.Militia.BuildTime, 0.01)
	assert.Equal(t, 0, unitInfo.Militia.Pop)
	assert.InEpsilon(t, 0.016666666666667, unitInfo.Militia.Speed, 0.01)
	assert.Equal(t, 0, unitInfo.Militia.Attack)
	assert.Equal(t, 15, unitInfo.Militia.Defense)
	assert.Equal(t, 45, unitInfo.Militia.DefenseCavalry)
	assert.Equal(t, 25, unitInfo.Militia.DefenseArcher)
	assert.Equal(t, 0, unitInfo.Militia.Carry)
}

func TestClient_GetBuildingInfo(t *testing.T) {
	t.Parallel()

	resp, err := os.ReadFile("./testdata/building_info.xml")
	require.NoError(t, err)

	mux := http.NewServeMux()
	mux.HandleFunc("/interface.php", newInterfaceXMLHandler("get_building_info", resp))

	srv := httptest.NewTLSServer(mux)
	t.Cleanup(srv.Close)

	buildingInfo, err := newClient(srv.Client()).GetBuildingInfo(context.Background(), parseURL(t, srv.URL))
	require.NoError(t, err)

	assert.Equal(t, 30, buildingInfo.Main.MaxLevel)
	assert.Equal(t, 1, buildingInfo.Main.MinLevel)
	assert.Equal(t, 90, buildingInfo.Main.Wood)
	assert.Equal(t, 80, buildingInfo.Main.Stone)
	assert.Equal(t, 70, buildingInfo.Main.Iron)
	assert.Equal(t, 5, buildingInfo.Main.Pop)
	assert.InEpsilon(t, 1.26, buildingInfo.Main.WoodFactor, 0.01)
	assert.InEpsilon(t, 1.275, buildingInfo.Main.StoneFactor, 0.01)
	assert.InEpsilon(t, 1.26, buildingInfo.Main.IronFactor, 0.01)
	assert.InEpsilon(t, 1.17, buildingInfo.Main.PopFactor, 0.01)
	assert.InEpsilon(t, 900.0, buildingInfo.Main.BuildTime, 0.01)
	assert.InEpsilon(t, 1.2, buildingInfo.Main.BuildTimeFactor, 0.01)

	assert.Equal(t, 25, buildingInfo.Barracks.MaxLevel)
	assert.Equal(t, 0, buildingInfo.Barracks.MinLevel)
	assert.Equal(t, 200, buildingInfo.Barracks.Wood)
	assert.Equal(t, 170, buildingInfo.Barracks.Stone)
	assert.Equal(t, 90, buildingInfo.Barracks.Iron)
	assert.Equal(t, 7, buildingInfo.Barracks.Pop)
	assert.InEpsilon(t, 1.26, buildingInfo.Barracks.WoodFactor, 0.01)
	assert.InEpsilon(t, 1.28, buildingInfo.Barracks.StoneFactor, 0.01)
	assert.InEpsilon(t, 1.26, buildingInfo.Barracks.IronFactor, 0.01)
	assert.InEpsilon(t, 1.17, buildingInfo.Barracks.PopFactor, 0.01)
	assert.InEpsilon(t, 1800.0, buildingInfo.Barracks.BuildTime, 0.01)
	assert.InEpsilon(t, 1.2, buildingInfo.Barracks.BuildTimeFactor, 0.01)

	assert.Equal(t, 20, buildingInfo.Stable.MaxLevel)
	assert.Equal(t, 0, buildingInfo.Stable.MinLevel)
	assert.Equal(t, 270, buildingInfo.Stable.Wood)
	assert.Equal(t, 240, buildingInfo.Stable.Stone)
	assert.Equal(t, 260, buildingInfo.Stable.Iron)
	assert.Equal(t, 8, buildingInfo.Stable.Pop)
	assert.InEpsilon(t, 1.26, buildingInfo.Stable.WoodFactor, 0.01)
	assert.InEpsilon(t, 1.28, buildingInfo.Stable.StoneFactor, 0.01)
	assert.InEpsilon(t, 1.26, buildingInfo.Stable.IronFactor, 0.01)
	assert.InEpsilon(t, 1.17, buildingInfo.Stable.PopFactor, 0.01)
	assert.InEpsilon(t, 6000.0, buildingInfo.Stable.BuildTime, 0.01)
	assert.InEpsilon(t, 1.2, buildingInfo.Stable.BuildTimeFactor, 0.01)

	assert.Equal(t, 15, buildingInfo.Garage.MaxLevel)
	assert.Equal(t, 0, buildingInfo.Garage.MinLevel)
	assert.Equal(t, 300, buildingInfo.Garage.Wood)
	assert.Equal(t, 240, buildingInfo.Garage.Stone)
	assert.Equal(t, 260, buildingInfo.Garage.Iron)
	assert.Equal(t, 8, buildingInfo.Garage.Pop)
	assert.InEpsilon(t, 1.26, buildingInfo.Garage.WoodFactor, 0.01)
	assert.InEpsilon(t, 1.28, buildingInfo.Garage.StoneFactor, 0.01)
	assert.InEpsilon(t, 1.26, buildingInfo.Garage.IronFactor, 0.01)
	assert.InEpsilon(t, 1.17, buildingInfo.Garage.PopFactor, 0.01)
	assert.InEpsilon(t, 6000.0, buildingInfo.Garage.BuildTime, 0.01)
	assert.InEpsilon(t, 1.2, buildingInfo.Garage.BuildTimeFactor, 0.01)

	assert.Equal(t, 20, buildingInfo.Watchtower.MaxLevel)
	assert.Equal(t, 0, buildingInfo.Watchtower.MinLevel)
	assert.Equal(t, 12000, buildingInfo.Watchtower.Wood)
	assert.Equal(t, 14000, buildingInfo.Watchtower.Stone)
	assert.Equal(t, 10000, buildingInfo.Watchtower.Iron)
	assert.Equal(t, 500, buildingInfo.Watchtower.Pop)
	assert.InEpsilon(t, 1.17, buildingInfo.Watchtower.WoodFactor, 0.01)
	assert.InEpsilon(t, 1.17, buildingInfo.Watchtower.StoneFactor, 0.01)
	assert.InEpsilon(t, 1.18, buildingInfo.Watchtower.IronFactor, 0.01)
	assert.InEpsilon(t, 1.18, buildingInfo.Watchtower.PopFactor, 0.01)
	assert.InEpsilon(t, 13200.0, buildingInfo.Watchtower.BuildTime, 0.01)
	assert.InEpsilon(t, 1.2, buildingInfo.Watchtower.BuildTimeFactor, 0.01)

	assert.Equal(t, 1, buildingInfo.Snob.MaxLevel)
	assert.Equal(t, 0, buildingInfo.Snob.MinLevel)
	assert.Equal(t, 15000, buildingInfo.Snob.Wood)
	assert.Equal(t, 25000, buildingInfo.Snob.Stone)
	assert.Equal(t, 10000, buildingInfo.Snob.Iron)
	assert.Equal(t, 80, buildingInfo.Snob.Pop)
	assert.InEpsilon(t, 2.0, buildingInfo.Snob.WoodFactor, 0.01)
	assert.InEpsilon(t, 2.0, buildingInfo.Snob.StoneFactor, 0.01)
	assert.InEpsilon(t, 2.0, buildingInfo.Snob.IronFactor, 0.01)
	assert.InEpsilon(t, 1.17, buildingInfo.Snob.PopFactor, 0.01)
	assert.InEpsilon(t, 586800.0, buildingInfo.Snob.BuildTime, 0.01)
	assert.InEpsilon(t, 1.2, buildingInfo.Snob.BuildTimeFactor, 0.01)

	assert.Equal(t, 20, buildingInfo.Smith.MaxLevel)
	assert.Equal(t, 0, buildingInfo.Smith.MinLevel)
	assert.Equal(t, 220, buildingInfo.Smith.Wood)
	assert.Equal(t, 180, buildingInfo.Smith.Stone)
	assert.Equal(t, 240, buildingInfo.Smith.Iron)
	assert.Equal(t, 20, buildingInfo.Smith.Pop)
	assert.InEpsilon(t, 1.26, buildingInfo.Smith.WoodFactor, 0.01)
	assert.InEpsilon(t, 1.275, buildingInfo.Smith.StoneFactor, 0.01)
	assert.InEpsilon(t, 1.26, buildingInfo.Smith.IronFactor, 0.01)
	assert.InEpsilon(t, 1.17, buildingInfo.Smith.PopFactor, 0.01)
	assert.InEpsilon(t, 6000.0, buildingInfo.Smith.BuildTime, 0.01)
	assert.InEpsilon(t, 1.2, buildingInfo.Smith.BuildTimeFactor, 0.01)

	assert.Equal(t, 1, buildingInfo.Place.MaxLevel)
	assert.Equal(t, 0, buildingInfo.Place.MinLevel)
	assert.Equal(t, 10, buildingInfo.Place.Wood)
	assert.Equal(t, 40, buildingInfo.Place.Stone)
	assert.Equal(t, 30, buildingInfo.Place.Iron)
	assert.Equal(t, 0, buildingInfo.Place.Pop)
	assert.InEpsilon(t, 1.26, buildingInfo.Place.WoodFactor, 0.01)
	assert.InEpsilon(t, 1.275, buildingInfo.Place.StoneFactor, 0.01)
	assert.InEpsilon(t, 1.26, buildingInfo.Place.IronFactor, 0.01)
	assert.InEpsilon(t, 1.17, buildingInfo.Place.PopFactor, 0.01)
	assert.InEpsilon(t, 10860.0, buildingInfo.Place.BuildTime, 0.01)
	assert.InEpsilon(t, 1.2, buildingInfo.Place.BuildTimeFactor, 0.01)

	assert.Equal(t, 1, buildingInfo.Statue.MaxLevel)
	assert.Equal(t, 0, buildingInfo.Statue.MinLevel)
	assert.Equal(t, 220, buildingInfo.Statue.Wood)
	assert.Equal(t, 220, buildingInfo.Statue.Stone)
	assert.Equal(t, 220, buildingInfo.Statue.Iron)
	assert.Equal(t, 10, buildingInfo.Statue.Pop)
	assert.InEpsilon(t, 1.26, buildingInfo.Statue.WoodFactor, 0.01)
	assert.InEpsilon(t, 1.275, buildingInfo.Statue.StoneFactor, 0.01)
	assert.InEpsilon(t, 1.26, buildingInfo.Statue.IronFactor, 0.01)
	assert.InEpsilon(t, 1.17, buildingInfo.Statue.PopFactor, 0.01)
	assert.InEpsilon(t, 1500.0, buildingInfo.Statue.BuildTime, 0.01)
	assert.InEpsilon(t, 1.2, buildingInfo.Statue.BuildTimeFactor, 0.01)

	assert.Equal(t, 25, buildingInfo.Market.MaxLevel)
	assert.Equal(t, 0, buildingInfo.Market.MinLevel)
	assert.Equal(t, 100, buildingInfo.Market.Wood)
	assert.Equal(t, 100, buildingInfo.Market.Stone)
	assert.Equal(t, 100, buildingInfo.Market.Iron)
	assert.Equal(t, 20, buildingInfo.Market.Pop)
	assert.InEpsilon(t, 1.26, buildingInfo.Market.WoodFactor, 0.01)
	assert.InEpsilon(t, 1.275, buildingInfo.Market.StoneFactor, 0.01)
	assert.InEpsilon(t, 1.26, buildingInfo.Market.IronFactor, 0.01)
	assert.InEpsilon(t, 1.17, buildingInfo.Market.PopFactor, 0.01)
	assert.InEpsilon(t, 2700.0, buildingInfo.Market.BuildTime, 0.01)
	assert.InEpsilon(t, 1.2, buildingInfo.Market.BuildTimeFactor, 0.01)

	assert.Equal(t, 30, buildingInfo.Wood.MaxLevel)
	assert.Equal(t, 0, buildingInfo.Wood.MinLevel)
	assert.Equal(t, 50, buildingInfo.Wood.Wood)
	assert.Equal(t, 60, buildingInfo.Wood.Stone)
	assert.Equal(t, 40, buildingInfo.Wood.Iron)
	assert.Equal(t, 5, buildingInfo.Wood.Pop)
	assert.InEpsilon(t, 1.25, buildingInfo.Wood.WoodFactor, 0.01)
	assert.InEpsilon(t, 1.275, buildingInfo.Wood.StoneFactor, 0.01)
	assert.InEpsilon(t, 1.245, buildingInfo.Wood.IronFactor, 0.01)
	assert.InEpsilon(t, 1.155, buildingInfo.Wood.PopFactor, 0.01)
	assert.InEpsilon(t, 900.0, buildingInfo.Wood.BuildTime, 0.01)
	assert.InEpsilon(t, 1.2, buildingInfo.Wood.BuildTimeFactor, 0.01)

	assert.Equal(t, 30, buildingInfo.Stone.MaxLevel)
	assert.Equal(t, 0, buildingInfo.Stone.MinLevel)
	assert.Equal(t, 65, buildingInfo.Stone.Wood)
	assert.Equal(t, 50, buildingInfo.Stone.Stone)
	assert.Equal(t, 40, buildingInfo.Stone.Iron)
	assert.Equal(t, 10, buildingInfo.Stone.Pop)
	assert.InEpsilon(t, 1.27, buildingInfo.Stone.WoodFactor, 0.01)
	assert.InEpsilon(t, 1.265, buildingInfo.Stone.StoneFactor, 0.01)
	assert.InEpsilon(t, 1.24, buildingInfo.Stone.IronFactor, 0.01)
	assert.InEpsilon(t, 1.14, buildingInfo.Stone.PopFactor, 0.01)
	assert.InEpsilon(t, 900.0, buildingInfo.Stone.BuildTime, 0.01)
	assert.InEpsilon(t, 1.2, buildingInfo.Stone.BuildTimeFactor, 0.01)

	assert.Equal(t, 30, buildingInfo.Iron.MaxLevel)
	assert.Equal(t, 0, buildingInfo.Iron.MinLevel)
	assert.Equal(t, 75, buildingInfo.Iron.Wood)
	assert.Equal(t, 65, buildingInfo.Iron.Stone)
	assert.Equal(t, 70, buildingInfo.Iron.Iron)
	assert.Equal(t, 10, buildingInfo.Iron.Pop)
	assert.InEpsilon(t, 1.252, buildingInfo.Iron.WoodFactor, 0.01)
	assert.InEpsilon(t, 1.275, buildingInfo.Iron.StoneFactor, 0.01)
	assert.InEpsilon(t, 1.24, buildingInfo.Iron.IronFactor, 0.01)
	assert.InEpsilon(t, 1.17, buildingInfo.Iron.PopFactor, 0.01)
	assert.InEpsilon(t, 1080.0, buildingInfo.Iron.BuildTime, 0.01)
	assert.InEpsilon(t, 1.2, buildingInfo.Iron.BuildTimeFactor, 0.01)

	assert.Equal(t, 30, buildingInfo.Farm.MaxLevel)
	assert.Equal(t, 1, buildingInfo.Farm.MinLevel)
	assert.Equal(t, 45, buildingInfo.Farm.Wood)
	assert.Equal(t, 40, buildingInfo.Farm.Stone)
	assert.Equal(t, 30, buildingInfo.Farm.Iron)
	assert.Equal(t, 0, buildingInfo.Farm.Pop)
	assert.InEpsilon(t, 1.3, buildingInfo.Farm.WoodFactor, 0.01)
	assert.InEpsilon(t, 1.32, buildingInfo.Farm.StoneFactor, 0.01)
	assert.InEpsilon(t, 1.29, buildingInfo.Farm.IronFactor, 0.01)
	assert.InEpsilon(t, 1.0, buildingInfo.Farm.PopFactor, 0.01)
	assert.InEpsilon(t, 1200.0, buildingInfo.Farm.BuildTime, 0.01)
	assert.InEpsilon(t, 1.2, buildingInfo.Farm.BuildTimeFactor, 0.01)

	assert.Equal(t, 30, buildingInfo.Storage.MaxLevel)
	assert.Equal(t, 1, buildingInfo.Storage.MinLevel)
	assert.Equal(t, 60, buildingInfo.Storage.Wood)
	assert.Equal(t, 50, buildingInfo.Storage.Stone)
	assert.Equal(t, 40, buildingInfo.Storage.Iron)
	assert.Equal(t, 0, buildingInfo.Storage.Pop)
	assert.InEpsilon(t, 1.265, buildingInfo.Storage.WoodFactor, 0.01)
	assert.InEpsilon(t, 1.27, buildingInfo.Storage.StoneFactor, 0.01)
	assert.InEpsilon(t, 1.245, buildingInfo.Storage.IronFactor, 0.01)
	assert.InEpsilon(t, 1.15, buildingInfo.Storage.PopFactor, 0.01)
	assert.InEpsilon(t, 1020.0, buildingInfo.Storage.BuildTime, 0.01)
	assert.InEpsilon(t, 1.2, buildingInfo.Storage.BuildTimeFactor, 0.01)

	assert.Equal(t, 10, buildingInfo.Hide.MaxLevel)
	assert.Equal(t, 0, buildingInfo.Hide.MinLevel)
	assert.Equal(t, 50, buildingInfo.Hide.Wood)
	assert.Equal(t, 60, buildingInfo.Hide.Stone)
	assert.Equal(t, 50, buildingInfo.Hide.Iron)
	assert.Equal(t, 2, buildingInfo.Hide.Pop)
	assert.InEpsilon(t, 1.25, buildingInfo.Hide.WoodFactor, 0.01)
	assert.InEpsilon(t, 1.25, buildingInfo.Hide.StoneFactor, 0.01)
	assert.InEpsilon(t, 1.25, buildingInfo.Hide.IronFactor, 0.01)
	assert.InEpsilon(t, 1.17, buildingInfo.Hide.PopFactor, 0.01)
	assert.InEpsilon(t, 1800.0, buildingInfo.Hide.BuildTime, 0.01)
	assert.InEpsilon(t, 1.2, buildingInfo.Hide.BuildTimeFactor, 0.01)

	assert.Equal(t, 20, buildingInfo.Wall.MaxLevel)
	assert.Equal(t, 0, buildingInfo.Wall.MinLevel)
	assert.Equal(t, 50, buildingInfo.Wall.Wood)
	assert.Equal(t, 100, buildingInfo.Wall.Stone)
	assert.Equal(t, 20, buildingInfo.Wall.Iron)
	assert.Equal(t, 5, buildingInfo.Wall.Pop)
	assert.InEpsilon(t, 1.26, buildingInfo.Wall.WoodFactor, 0.01)
	assert.InEpsilon(t, 1.275, buildingInfo.Wall.StoneFactor, 0.01)
	assert.InEpsilon(t, 1.26, buildingInfo.Wall.IronFactor, 0.01)
	assert.InEpsilon(t, 1.17, buildingInfo.Wall.PopFactor, 0.01)
	assert.InEpsilon(t, 3600.0, buildingInfo.Wall.BuildTime, 0.01)
	assert.InEpsilon(t, 1.2, buildingInfo.Wall.BuildTimeFactor, 0.01)
}

func TestClient_GetTribes(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name           string
		respTribe      string
		respOD         string
		respODA        string
		respODD        string
		checkErr       func(err error) bool
		expectedTribes func(t *testing.T, url string) []tw.Tribe
	}{
		{
			name:      "OK",
			respTribe: "1,name%201,tag%201,100,101,102,103,104\n2,name2,tag2,200,202,202,203,204\n3,name3,tag3,300,303,302,303,304\n4,name4,tag4,400,404,402,403,404", //nolint:lll
			respOD:    "1,1,1\n2,2,2\n3,3,3",
			respODA:   "1,1,1\n2,2,2\n3,3,3",
			respODD:   "1,1,1\n2,2,2\n3,3,3",
			expectedTribes: func(t *testing.T, baseURL string) []tw.Tribe {
				t.Helper()
				return []tw.Tribe{
					{
						ID:          1,
						Name:        "name 1",
						Tag:         "tag 1",
						ProfileURL:  parseURL(t, baseURL+"/game.php?screen=info_ally&id=1"),
						NumMembers:  100,
						NumVillages: 101,
						Points:      102,
						AllPoints:   103,
						Rank:        104,
						OpponentsDefeated: tw.OpponentsDefeated{
							RankAtt:    1,
							ScoreAtt:   1,
							RankDef:    1,
							ScoreDef:   1,
							RankSup:    0,
							ScoreSup:   0,
							RankTotal:  1,
							ScoreTotal: 1,
						},
					},
					{
						ID:          2,
						Name:        "name2",
						Tag:         "tag2",
						ProfileURL:  parseURL(t, baseURL+"/game.php?screen=info_ally&id=2"),
						NumMembers:  200,
						NumVillages: 202,
						Points:      202,
						AllPoints:   203,
						Rank:        204,
						OpponentsDefeated: tw.OpponentsDefeated{
							RankAtt:    2,
							ScoreAtt:   2,
							RankDef:    2,
							ScoreDef:   2,
							RankSup:    0,
							ScoreSup:   0,
							RankTotal:  2,
							ScoreTotal: 2,
						},
					},
					{
						ID:          3,
						Name:        "name3",
						Tag:         "tag3",
						ProfileURL:  parseURL(t, baseURL+"/game.php?screen=info_ally&id=3"),
						NumMembers:  300,
						NumVillages: 303,
						Points:      302,
						AllPoints:   303,
						Rank:        304,
						OpponentsDefeated: tw.OpponentsDefeated{
							RankAtt:    3,
							ScoreAtt:   3,
							RankDef:    3,
							ScoreDef:   3,
							RankSup:    0,
							ScoreSup:   0,
							RankTotal:  3,
							ScoreTotal: 3,
						},
					},
					{
						ID:                4,
						Name:              "name4",
						Tag:               "tag4",
						ProfileURL:        parseURL(t, baseURL+"/game.php?screen=info_ally&id=4"),
						NumMembers:        400,
						NumVillages:       404,
						Points:            402,
						AllPoints:         403,
						Rank:              404,
						OpponentsDefeated: tw.OpponentsDefeated{},
					},
				}
			},
		},
		{
			name:   "ERR: OD.Rank can't be converted to int64",
			respOD: "test,1,1001",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Rank" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:   "ERR: OD.ID -can't be converted to int64",
			respOD: "1,test,1001",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.ID" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:   "ERR: OD.Score can't be converted to int64",
			respOD: "1,1,test",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Score" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:    "ERR: ODA.Rank can't be converted to int64",
			respODA: "test,1,1001",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Rank" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:    "ERR: ODA.ID can't be converted to int64",
			respODA: "1,test,1001",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.ID" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:    "ERR: ODA.Score can't be converted to int64",
			respODA: "1,1,test",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Score" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:    "ERR: ODD.Rank can't be converted to int64",
			respODD: "test,1,1001",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Rank" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:    "ERR: ODD.ID can't be converted to int64",
			respODD: "1,test,1001",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.ID" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:    "ERR: ODD.Score can't be converted to int64",
			respODD: "1,1,test",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Score" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:      "ERR: too few fields",
			respTribe: "1,name%201,tag%201,100,101,102,103",
			checkErr: func(err error) bool {
				return errors.Is(err, csv.ErrFieldCount)
			},
		},
		{
			name:      "ERR: too many fields",
			respTribe: "1,name%201,tag%201,100,101,102,103,104,105",
			checkErr: func(err error) bool {
				return errors.Is(err, csv.ErrFieldCount)
			},
		},
		{
			name:      "ERR: Tribe.ID can't be converted to int64",
			respTribe: "1.23,name1,tag1,100,101,102,103,104",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "Tribe.ID" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:      "ERR: Tribe.NumMembers can't be converted to int64",
			respTribe: "1,name1,tag1,100.23,101,102,103,104",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "Tribe.NumMembers" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:      "ERR: Tribe.NumVillages can't be converted to int64",
			respTribe: "1,name1,tag1,100,101.23,102,103,104",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "Tribe.NumVillages" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:      "ERR: Tribe.Points can't be converted to int64",
			respTribe: "1,name1,tag1,100,101,102.23,103,104",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "Tribe.Points" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:      "ERR: Tribe.AllPoints can't be converted to int64",
			respTribe: "1,name1,tag1,100,101,102,103.23,104",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "Tribe.AllPoints" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:      "ERR: Tribe.Rank can't be converted to int64",
			respTribe: "1,name1,tag1,100,101,102,103,104.23",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "Tribe.Rank" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
	}

	for _, tt := range tests {
		tt := tt

		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			mux := http.NewServeMux()
			mux.HandleFunc("/map/kill_all_tribe.txt", newPlainTextHandlerString(tt.respOD))
			mux.HandleFunc("/map/kill_att_tribe.txt", newPlainTextHandlerString(tt.respODA))
			mux.HandleFunc("/map/kill_def_tribe.txt", newPlainTextHandlerString(tt.respODD))
			mux.HandleFunc("/map/ally.txt", newPlainTextHandlerString(tt.respTribe))

			srv := httptest.NewTLSServer(mux)
			t.Cleanup(srv.Close)

			tribes, err := newClient(srv.Client()).GetTribes(context.Background(), parseURL(t, srv.URL))

			checkErr := func(err error) bool {
				return errors.Is(err, nil)
			}
			if tt.checkErr != nil {
				checkErr = tt.checkErr
			}

			var expectedTribes []tw.Tribe
			if tt.expectedTribes != nil {
				expectedTribes = tt.expectedTribes(t, srv.URL)
			}

			assert.True(t, checkErr(err))
			require.Len(t, tribes, len(expectedTribes))
			for i, tribe := range tribes {
				assert.Equal(t, expectedTribes[i], tribe)
			}
		})
	}
}

func TestClient_GetPlayers(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name            string
		respPlayer      string
		respOD          string
		respODA         string
		respODD         string
		respODS         string
		checkErr        func(err error) bool
		expectedPlayers func(t *testing.T, url string) []tw.Player
	}{
		{
			name:       "OK",
			respPlayer: "1,name%201,123,124,125,126\n2,name2,256,257,258,259\n3,name3,356,357,358,359\n4,name4,456,457,458,459",
			respOD:     "1,1,1001\n2,2,252\n3,3,125",
			respODA:    "1,1,1000\n2,2,253\n3,3,100",
			respODD:    "1,1,1002\n2,2,251\n3,3,155",
			respODS:    "1,1,1003\n2,2,250\n3,3,166",
			expectedPlayers: func(t *testing.T, baseURL string) []tw.Player {
				t.Helper()
				return []tw.Player{
					{
						ID:          1,
						Name:        "name 1",
						ProfileURL:  parseURL(t, baseURL+"/game.php?screen=info_player&id=1"),
						TribeID:     123,
						NumVillages: 124,
						Points:      125,
						Rank:        126,
						OpponentsDefeated: tw.OpponentsDefeated{
							RankAtt:    1,
							ScoreAtt:   1000,
							RankDef:    1,
							ScoreDef:   1002,
							RankSup:    1,
							ScoreSup:   1003,
							RankTotal:  1,
							ScoreTotal: 1001,
						},
					},
					{
						ID:          2,
						Name:        "name2",
						ProfileURL:  parseURL(t, baseURL+"/game.php?screen=info_player&id=2"),
						TribeID:     256,
						NumVillages: 257,
						Points:      258,
						Rank:        259,
						OpponentsDefeated: tw.OpponentsDefeated{
							RankAtt:    2,
							ScoreAtt:   253,
							RankDef:    2,
							ScoreDef:   251,
							RankSup:    2,
							ScoreSup:   250,
							RankTotal:  2,
							ScoreTotal: 252,
						},
					},
					{
						ID:          3,
						Name:        "name3",
						ProfileURL:  parseURL(t, baseURL+"/game.php?screen=info_player&id=3"),
						TribeID:     356,
						NumVillages: 357,
						Points:      358,
						Rank:        359,
						OpponentsDefeated: tw.OpponentsDefeated{
							RankAtt:    3,
							ScoreAtt:   100,
							RankDef:    3,
							ScoreDef:   155,
							RankSup:    3,
							ScoreSup:   166,
							RankTotal:  3,
							ScoreTotal: 125,
						},
					},
					{
						ID:                4,
						Name:              "name4",
						ProfileURL:        parseURL(t, baseURL+"/game.php?screen=info_player&id=4"),
						TribeID:           456,
						NumVillages:       457,
						Points:            458,
						Rank:              459,
						OpponentsDefeated: tw.OpponentsDefeated{},
					},
				}
			},
		},
		{
			name:   "ERR: OD.Rank can't be converted to int64",
			respOD: "test,1,1001",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Rank" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:   "ERR: OD.ID can't be converted to int64",
			respOD: "1,test,1001",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.ID" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:   "ERR: OD.Score can't be converted to int64",
			respOD: "1,1,test",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Score" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:    "ERR: ODA.Rank can't be converted to int64",
			respODA: "test,1,1001",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Rank" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:    "ERR: ODA.ID can't be converted to int64",
			respODA: "1,test,1001",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.ID" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:    "ERR: ODA.Score can't be converted to int64",
			respODA: "1,1,test",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Score" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:    "ERR: ODD.Rank can't be converted to int64",
			respODD: "test,1,1001",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Rank" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:    "ERR: ODD.ID can't be converted to int64",
			respODD: "1,test,1001",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.ID" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:    "ERR: ODD.Score can't be converted to int64",
			respODD: "1,1,test",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Score" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:    "ERR: ODS.Rank can't be converted to int64",
			respODS: "test,1,1001",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Rank" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:    "ERR: ODS.ID can't be converted to int64",
			respODS: "1,test,1001",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.ID" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:    "ERR: ODS.Score can't be converted to int64",
			respODS: "1,1,test",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Score" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:       "ERR: too few fields",
			respPlayer: "1,name%201,123,124,125",
			checkErr: func(err error) bool {
				return errors.Is(err, csv.ErrFieldCount)
			},
		},
		{
			name:       "ERR: too many fields",
			respPlayer: "1,name%201,123,124,125,126,127",
			checkErr: func(err error) bool {
				return errors.Is(err, csv.ErrFieldCount)
			},
		},
		{
			name:       "ERR: Player.ID can't be converted to int64",
			respPlayer: "1.25,name,123,124,125,126",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "Player.ID" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:       "ERR: Player.TribeID can't be converted to int64",
			respPlayer: "1,name,1.23,124,125,126",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "Player.TribeID" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:       "ERR: Player.NumVillages can't be converted to int64",
			respPlayer: "1,name,123,1.24,125,126",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "Player.NumVillages" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:       "ERR: Player.Points can't be converted to int64",
			respPlayer: "1,name,123,124,1.25,126",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "Player.Points" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name:       "ERR: Player.Rank can't be converted to int64",
			respPlayer: "1,name,123,124,125,1.26",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "Player.Rank" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
	}

	for _, tt := range tests {
		tt := tt

		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			mux := http.NewServeMux()
			mux.HandleFunc("/map/kill_all.txt", newPlainTextHandlerString(tt.respOD))
			mux.HandleFunc("/map/kill_att.txt", newPlainTextHandlerString(tt.respODA))
			mux.HandleFunc("/map/kill_def.txt", newPlainTextHandlerString(tt.respODD))
			mux.HandleFunc("/map/kill_sup.txt", newPlainTextHandlerString(tt.respODS))
			mux.HandleFunc("/map/player.txt", newPlainTextHandlerString(tt.respPlayer))

			srv := httptest.NewTLSServer(mux)
			t.Cleanup(srv.Close)

			players, err := newClient(srv.Client()).GetPlayers(context.Background(), parseURL(t, srv.URL))

			checkErr := func(err error) bool {
				return errors.Is(err, nil)
			}
			if tt.checkErr != nil {
				checkErr = tt.checkErr
			}

			var expectedPlayers []tw.Player
			if tt.expectedPlayers != nil {
				expectedPlayers = tt.expectedPlayers(t, srv.URL)
			}

			assert.True(t, checkErr(err))
			require.Len(t, players, len(expectedPlayers))
			for i, player := range players {
				assert.Equal(t, expectedPlayers[i], player)
			}
		})
	}
}

func TestClient_GetVillages(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name             string
		resp             string
		checkErr         func(err error) bool
		expectedVillages func(t *testing.T, baseURL string) []tw.Village
	}{
		{
			name: "OK",
			//nolint:lll
			resp: "123,village%201,500,501,502,503,504\n124,village 2,100,201,102,103,104\n125,village%203,1,501,502,503,504\n126,village%204,1,1,502,503,504\n127,village%205,103,1,502,503,504",
			expectedVillages: func(t *testing.T, baseURL string) []tw.Village {
				t.Helper()
				return []tw.Village{
					{
						ID:         123,
						Name:       "village 1",
						ProfileURL: parseURL(t, baseURL+"/game.php?screen=info_village&id=123"),
						X:          500,
						Y:          501,
						Continent:  "K55",
						PlayerID:   502,
						Points:     503,
						Bonus:      504,
					},
					{
						ID:         124,
						Name:       "village 2",
						ProfileURL: parseURL(t, baseURL+"/game.php?screen=info_village&id=124"),
						X:          100,
						Y:          201,
						Continent:  "K21",
						PlayerID:   102,
						Points:     103,
						Bonus:      104,
					},
					{
						ID:         125,
						Name:       "village 3",
						ProfileURL: parseURL(t, baseURL+"/game.php?screen=info_village&id=125"),
						X:          1,
						Y:          501,
						Continent:  "K50",
						PlayerID:   502,
						Points:     503,
						Bonus:      504,
					},
					{
						ID:         126,
						Name:       "village 4",
						ProfileURL: parseURL(t, baseURL+"/game.php?screen=info_village&id=126"),
						X:          1,
						Y:          1,
						Continent:  "K0",
						PlayerID:   502,
						Points:     503,
						Bonus:      504,
					},
					{
						ID:         127,
						Name:       "village 5",
						ProfileURL: parseURL(t, baseURL+"/game.php?screen=info_village&id=127"),
						X:          103,
						Y:          1,
						Continent:  "K1",
						PlayerID:   502,
						Points:     503,
						Bonus:      504,
					},
				}
			},
		},
		{
			name: "ERR: too few fields",
			resp: "123,village 1,500,501,502,503",
			checkErr: func(err error) bool {
				return errors.Is(err, csv.ErrFieldCount)
			},
		},
		{
			name: "ERR: too many fields",
			resp: "123,village 1,500,501,502,503,504,505",
			checkErr: func(err error) bool {
				return errors.Is(err, csv.ErrFieldCount)
			},
		},
		{
			name: "ERR: Village.ID can't be converted to int64",
			resp: "123.23,village 1,500,501,502,503,504",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "Village.ID" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name: "ERR: Village.X can't be converted to int64",
			resp: "123,village 1,123.23,501,502,503,504",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "Village.X" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name: "ERR: Village.Y can't be converted to int64",
			resp: "123,village 1,500,123.23,502,503,504",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "Village.Y" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name: "ERR: Village.PlayerID can't be converted to int64",
			resp: "123,village 1,500,501,123.23,503,504",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "Village.PlayerID" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name: "ERR: Village.Points can't be converted to int64",
			resp: "123,village 1,500,501,502,123.23,504",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "Village.Points" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
		{
			name: "ERR: Village.Bonus can't be converted to int64",
			resp: "123,village 1,500,501,502,503,123.23",
			checkErr: func(err error) bool {
				var parseErr tw.ParseError
				return errors.As(err, &parseErr) && parseErr.Field == "Village.Bonus" && errors.Is(parseErr, strconv.ErrSyntax)
			},
		},
	}

	for _, tt := range tests {
		tt := tt

		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			mux := http.NewServeMux()
			mux.HandleFunc("/map/village.txt", newPlainTextHandlerString(tt.resp))

			srv := httptest.NewTLSServer(mux)
			t.Cleanup(srv.Close)

			villages, err := newClient(srv.Client()).GetVillages(context.Background(), parseURL(t, srv.URL))

			checkErr := func(err error) bool {
				return errors.Is(err, nil)
			}
			if tt.checkErr != nil {
				checkErr = tt.checkErr
			}

			var expectedVillages []tw.Village
			if tt.expectedVillages != nil {
				expectedVillages = tt.expectedVillages(t, srv.URL)
			}

			assert.True(t, checkErr(err))
			require.Len(t, villages, len(expectedVillages))
			for i, village := range villages {
				assert.Equal(t, expectedVillages[i], village)
			}
		})
	}
}

func TestClient_GetEnnoblements(t *testing.T) {
	t.Parallel()

	t.Run("map/conquer_extended.txt", func(t *testing.T) {
		t.Parallel()

		tests := []struct {
			name                 string
			since                time.Time
			resp                 string
			checkErr             func(err error) bool
			expectedEnnoblements []tw.Ennoblement
		}{
			{
				name:  "OK",
				since: time.Unix(1657842401, 0),
				//nolint:lll
				resp:     "1424,1657842406,698953084,699589674,0,122,380\n3150,1657882879,6461674,0,0,179,111\n1025,1657947400,9157005,698942601,0,52,461\n1025,1657842400,9157005,698942601,0,52,461",
				checkErr: nil,
				expectedEnnoblements: []tw.Ennoblement{ // the last one should be skipped (1657842401 > 1657842400)
					{
						VillageID:  1424,
						NewOwnerID: 698953084,
						NewTribeID: 122,
						OldOwnerID: 699589674,
						OldTribeID: 0,
						Points:     380,
						CreatedAt:  time.Unix(1657842406, 0),
					},
					{
						VillageID:  3150,
						NewOwnerID: 6461674,
						NewTribeID: 179,
						OldOwnerID: 0,
						OldTribeID: 0,
						Points:     111,
						CreatedAt:  time.Unix(1657882879, 0),
					},
					{
						VillageID:  1025,
						NewOwnerID: 9157005,
						NewTribeID: 52,
						OldOwnerID: 698942601,
						OldTribeID: 0,
						Points:     461,
						CreatedAt:  time.Unix(1657947400, 0),
					},
				},
			},
			{
				name: "ERR: too few fields",
				resp: "1424,1657842406,698953084,699589674,0,122",
				checkErr: func(err error) bool {
					return errors.Is(err, csv.ErrFieldCount)
				},
			},
			{
				name: "ERR: too many fields",
				resp: "1424,1657842406,698953084,699589674,0,122,380,0",
				checkErr: func(err error) bool {
					return errors.Is(err, csv.ErrFieldCount)
				},
			},
			{
				name: "ERR: Ennoblement.VillageID can't be converted to int64",
				resp: "asd,1657842406,698953084,699589674,0,122,380",
				checkErr: func(err error) bool {
					var parseErr tw.ParseError
					return errors.As(err, &parseErr) &&
						parseErr.Field == "Ennoblement.VillageID" &&
						errors.Is(parseErr, strconv.ErrSyntax)
				},
			},
			{
				name: "ERR: Ennoblement.VillageID can't be converted to int64",
				resp: "1424,asd,698953084,699589674,0,122,380",
				checkErr: func(err error) bool {
					var parseErr tw.ParseError
					return errors.As(err, &parseErr) &&
						parseErr.Field == "Ennoblement.CreatedAt" &&
						errors.Is(parseErr, strconv.ErrSyntax)
				},
			},
			{
				name: "ERR: Ennoblement.NewOwnerID can't be converted to int64",
				resp: "1424,1657842406,asd,699589674,0,122,380",
				checkErr: func(err error) bool {
					var parseErr tw.ParseError
					return errors.As(err, &parseErr) &&
						parseErr.Field == "Ennoblement.NewOwnerID" &&
						errors.Is(parseErr, strconv.ErrSyntax)
				},
			},
			{
				name: "ERR: Ennoblement.OldOwnerID can't be converted to int64",
				resp: "1424,1657842406,698953084,asd,0,122,380",
				checkErr: func(err error) bool {
					var parseErr tw.ParseError
					return errors.As(err, &parseErr) &&
						parseErr.Field == "Ennoblement.OldOwnerID" &&
						errors.Is(parseErr, strconv.ErrSyntax)
				},
			},
			{
				name: "ERR: Ennoblement.OldTribeID can't be converted to int64",
				resp: "1424,1657842406,698953084,699589674,asd,122,380",
				checkErr: func(err error) bool {
					var parseErr tw.ParseError
					return errors.As(err, &parseErr) &&
						parseErr.Field == "Ennoblement.OldTribeID" &&
						errors.Is(parseErr, strconv.ErrSyntax)
				},
			},
			{
				name: "ERR: Ennoblement.NewTribeID can't be converted to int64",
				resp: "1424,1657842406,698953084,699589674,0,asd,380",
				checkErr: func(err error) bool {
					var parseErr tw.ParseError
					return errors.As(err, &parseErr) &&
						parseErr.Field == "Ennoblement.NewTribeID" &&
						errors.Is(parseErr, strconv.ErrSyntax)
				},
			},
			{
				name: "ERR: Ennoblement.Points can't be converted to int64",
				resp: "1424,1657842406,698953084,699589674,0,122,asd",
				checkErr: func(err error) bool {
					var parseErr tw.ParseError
					return errors.As(err, &parseErr) &&
						parseErr.Field == "Ennoblement.Points" &&
						errors.Is(parseErr, strconv.ErrSyntax)
				},
			},
		}

		for _, tt := range tests {
			tt := tt

			t.Run(tt.name, func(t *testing.T) {
				t.Parallel()

				mux := http.NewServeMux()
				mux.HandleFunc("/map/conquer_extended.txt", newPlainTextHandlerString(tt.resp))

				srv := httptest.NewTLSServer(mux)
				t.Cleanup(srv.Close)

				ennoblements, err := newClient(srv.Client()).GetEnnoblements(context.Background(), parseURL(t, srv.URL), tt.since)

				checkErr := func(err error) bool {
					return errors.Is(err, nil)
				}
				if tt.checkErr != nil {
					checkErr = tt.checkErr
				}

				assert.True(t, checkErr(err))
				require.Len(t, ennoblements, len(tt.expectedEnnoblements))
				for i, ennoblement := range ennoblements {
					assert.Equal(t, tt.expectedEnnoblements[i], ennoblement)
				}
			})
		}
	})

	t.Run("interface.php?func=get_conquer_extended", func(t *testing.T) {
		t.Parallel()

		now := time.Now()
		tests := []struct {
			name                 string
			since                time.Time
			resp                 string
			checkErr             func(err error) bool
			clientOpts           []tw.ClientOption
			expectedEnnoblements []tw.Ennoblement
		}{
			{
				name:  "OK: without custom ennoblementsUseInterfaceFunc",
				since: now.Add(-22 * time.Hour),
				resp: fmt.Sprintf(
					"1424,%d,698953084,699589674,0,122,380\n3150,%d,6461674,0,0,179,111\n1025,%d,9157005,698942601,0,52,461",
					now.Add(-21*time.Hour).Unix(),
					now.Add(-15*time.Minute).Unix(),
					now.Add(-15*time.Second).Unix(),
				),
				checkErr: nil,
				expectedEnnoblements: []tw.Ennoblement{
					{
						VillageID:  1424,
						NewOwnerID: 698953084,
						NewTribeID: 122,
						OldOwnerID: 699589674,
						OldTribeID: 0,
						Points:     380,
						CreatedAt:  time.Unix(now.Add(-21*time.Hour).Unix(), 0),
					},
					{
						VillageID:  3150,
						NewOwnerID: 6461674,
						NewTribeID: 179,
						OldOwnerID: 0,
						OldTribeID: 0,
						Points:     111,
						CreatedAt:  time.Unix(now.Add(-15*time.Minute).Unix(), 0),
					},
					{
						VillageID:  1025,
						NewOwnerID: 9157005,
						NewTribeID: 52,
						OldOwnerID: 698942601,
						OldTribeID: 0,
						Points:     461,
						CreatedAt:  time.Unix(now.Add(-15*time.Second).Unix(), 0),
					},
				},
			},
			{
				name:  "OK: with custom ennoblementsUseInterfaceFunc",
				since: now.Add(-47 * time.Hour),
				resp: fmt.Sprintf(
					"1424,%d,698953084,699589674,0,122,380\n3150,%d,6461674,0,0,179,111\n1025,%d,9157005,698942601,0,52,461",
					now.Add(-44*time.Hour).Unix(),
					now.Add(-40*time.Minute).Unix(),
					now.Add(-40*time.Second).Unix(),
				),
				checkErr: nil,
				clientOpts: []tw.ClientOption{
					tw.WithEnnoblementsUseInterfaceFunc(func(since time.Time) bool {
						return since.After(time.Now().Add(-48 * time.Hour))
					}),
				},
				expectedEnnoblements: []tw.Ennoblement{
					{
						VillageID:  1424,
						NewOwnerID: 698953084,
						NewTribeID: 122,
						OldOwnerID: 699589674,
						OldTribeID: 0,
						Points:     380,
						CreatedAt:  time.Unix(now.Add(-44*time.Hour).Unix(), 0),
					},
					{
						VillageID:  3150,
						NewOwnerID: 6461674,
						NewTribeID: 179,
						OldOwnerID: 0,
						OldTribeID: 0,
						Points:     111,
						CreatedAt:  time.Unix(now.Add(-40*time.Minute).Unix(), 0),
					},
					{
						VillageID:  1025,
						NewOwnerID: 9157005,
						NewTribeID: 52,
						OldOwnerID: 698942601,
						OldTribeID: 0,
						Points:     461,
						CreatedAt:  time.Unix(now.Add(-40*time.Second).Unix(), 0),
					},
				},
			},
			{
				name:  "ERR: too few fields",
				since: now.Add(-22 * time.Hour),
				resp:  fmt.Sprintf("1424,%d,698953084,699589674,0,122", now.Add(-21*time.Hour).Unix()),
				checkErr: func(err error) bool {
					return errors.Is(err, csv.ErrFieldCount)
				},
			},
			{
				name:  "ERR: too many fields",
				since: now.Add(-22 * time.Hour),
				resp:  fmt.Sprintf("1424,%d,698953084,699589674,0,122,380,0", now.Add(-21*time.Hour).Unix()),
				checkErr: func(err error) bool {
					return errors.Is(err, csv.ErrFieldCount)
				},
			},
			{
				name:  "ERR: Ennoblement.VillageID can't be converted to int64",
				since: now.Add(-22 * time.Hour),
				resp:  fmt.Sprintf("asd,%d,698953084,699589674,0,122,380", now.Add(-21*time.Hour).Unix()),
				checkErr: func(err error) bool {
					var parseErr tw.ParseError
					return errors.As(err, &parseErr) &&
						parseErr.Field == "Ennoblement.VillageID" &&
						errors.Is(parseErr, strconv.ErrSyntax)
				},
			},
			{
				name:  "ERR: Ennoblement.CreatedAt can't be converted to int64",
				since: now.Add(-22 * time.Hour),
				resp:  "1424,asd,698953084,699589674,0,122,380",
				checkErr: func(err error) bool {
					var parseErr tw.ParseError
					return errors.As(err, &parseErr) &&
						parseErr.Field == "Ennoblement.CreatedAt" &&
						errors.Is(parseErr, strconv.ErrSyntax)
				},
			},
			{
				name:  "ERR: Ennoblement.NewOwnerID can't be converted to int64",
				since: now.Add(-22 * time.Hour),
				resp:  fmt.Sprintf("1424,%d,asd,699589674,0,122,380", now.Add(-21*time.Hour).Unix()),
				checkErr: func(err error) bool {
					var parseErr tw.ParseError
					return errors.As(err, &parseErr) &&
						parseErr.Field == "Ennoblement.NewOwnerID" &&
						errors.Is(parseErr, strconv.ErrSyntax)
				},
			},
			{
				name:  "ERR: Ennoblement.OldOwnerID can't be converted to int64",
				since: now.Add(-22 * time.Hour),
				resp:  fmt.Sprintf("1424,%d,698953084,asd,0,122,380", now.Add(-21*time.Hour).Unix()),
				checkErr: func(err error) bool {
					var parseErr tw.ParseError
					return errors.As(err, &parseErr) &&
						parseErr.Field == "Ennoblement.OldOwnerID" &&
						errors.Is(parseErr, strconv.ErrSyntax)
				},
			},
			{
				name:  "ERR: Ennoblement.OldTribeID can't be converted to int64",
				since: now.Add(-22 * time.Hour),
				resp:  fmt.Sprintf("1424,%d,698953084,699589674,asd,122,380", now.Add(-21*time.Hour).Unix()),
				checkErr: func(err error) bool {
					var parseErr tw.ParseError
					return errors.As(err, &parseErr) &&
						parseErr.Field == "Ennoblement.OldTribeID" &&
						errors.Is(parseErr, strconv.ErrSyntax)
				},
			},
			{
				name:  "ERR: Ennoblement.NewTribeID can't be converted to int64",
				since: now.Add(-22 * time.Hour),
				resp:  fmt.Sprintf("1424,%d,698953084,699589674,0,asd,380", now.Add(-21*time.Hour).Unix()),
				checkErr: func(err error) bool {
					var parseErr tw.ParseError
					return errors.As(err, &parseErr) &&
						parseErr.Field == "Ennoblement.NewTribeID" &&
						errors.Is(parseErr, strconv.ErrSyntax)
				},
			},
			{
				name:  "ERR: Ennoblement.Points can't be converted to int64",
				since: now.Add(-22 * time.Hour),
				resp:  fmt.Sprintf("1424,%d,698953084,699589674,0,122,asd", now.Add(-21*time.Hour).Unix()),
				checkErr: func(err error) bool {
					var parseErr tw.ParseError
					return errors.As(err, &parseErr) &&
						parseErr.Field == "Ennoblement.Points" &&
						errors.Is(parseErr, strconv.ErrSyntax)
				},
			},
		}

		for _, tt := range tests {
			tt := tt

			t.Run(tt.name, func(t *testing.T) {
				t.Parallel()

				mux := http.NewServeMux()
				mux.HandleFunc("/interface.php", newInterfacePlainTextHandlerString("get_conquer_extended", tt.resp))

				srv := httptest.NewTLSServer(mux)
				t.Cleanup(srv.Close)

				client := newClient(srv.Client(), tt.clientOpts...)

				ennoblements, err := client.GetEnnoblements(context.Background(), parseURL(t, srv.URL), tt.since)

				checkErr := func(err error) bool {
					return errors.Is(err, nil)
				}
				if tt.checkErr != nil {
					checkErr = tt.checkErr
				}

				assert.True(t, checkErr(err))
				require.Len(t, ennoblements, len(tt.expectedEnnoblements))
				for i, ennoblement := range ennoblements {
					assert.Equal(t, tt.expectedEnnoblements[i], ennoblement)
				}
			})
		}
	})
}

func parseURL(tb testing.TB, rawURL string) *url.URL {
	tb.Helper()
	u, err := url.ParseRequestURI(rawURL)
	require.NoError(tb, err)
	return u
}

// user agent and http client can't be overridden via opts
func newClient(hc *http.Client, opts ...tw.ClientOption) *tw.Client {
	return tw.NewClient(append(opts, tw.WithHTTPClient(hc), tw.WithUserAgent(testUserAgent))...)
}

func newInterfaceXMLHandler(funcName string, result []byte) func(w http.ResponseWriter, r *http.Request) {
	return newInterfaceHandler(funcName, "text/xml;charset=utf-8", result)
}

func newInterfacePlainTextHandler(funcName string, result []byte) func(w http.ResponseWriter, r *http.Request) {
	return newInterfaceHandler(funcName, "text/plain", result)
}

func newInterfacePlainTextHandlerString(funcName, result string) func(w http.ResponseWriter, r *http.Request) {
	return newInterfacePlainTextHandler(funcName, []byte(result))
}

func newInterfaceHandler(funcName, contentType string, result []byte) func(w http.ResponseWriter, r *http.Request) {
	next := newHandler(contentType, result)

	return func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Query().Get("func") != funcName {
			w.WriteHeader(http.StatusNotFound)
			return
		}

		next(w, r)
	}
}

func newPlainTextHandler(result []byte) func(w http.ResponseWriter, r *http.Request) {
	return newHandler("text/plain", result)
}

func newPlainTextHandlerString(result string) func(w http.ResponseWriter, r *http.Request) {
	return newPlainTextHandler([]byte(result))
}

func newHandler(contentType string, result []byte) func(w http.ResponseWriter, r *http.Request) {
	return func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodGet {
			w.WriteHeader(http.StatusMethodNotAllowed)
			return
		}

		if r.Header.Get("User-Agent") != testUserAgent {
			w.WriteHeader(http.StatusBadRequest)
			return
		}

		w.Header().Set("Content-Type", contentType)
		_, _ = w.Write(result)
	}
}
