package service_test

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"io/fs"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"os/signal"
	"path"
	"syscall"
	"testing"
	"time"

	"gitea.dwysokinski.me/twhelp/corev3/internal/adapter"
	"gitea.dwysokinski.me/twhelp/corev3/internal/app"
	"gitea.dwysokinski.me/twhelp/corev3/internal/domain"
	"gitea.dwysokinski.me/twhelp/corev3/internal/port"
	"gitea.dwysokinski.me/twhelp/corev3/internal/tw"
	"gitea.dwysokinski.me/twhelp/corev3/internal/watermill/watermillamqptest"
	"gitea.dwysokinski.me/twhelp/corev3/internal/watermill/watermillmsg"
	"gitea.dwysokinski.me/twhelp/corev3/internal/watermill/watermilltest"
	"github.com/ThreeDotsLabs/watermill"
	"github.com/ThreeDotsLabs/watermill-amqp/v2/pkg/amqp"
	"github.com/brianvoe/gofakeit/v6"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// we can't run the subtests in parallel

//nolint:tparallel
func TestDataSync(t *testing.T) {
	t.Parallel()

	if testing.Short() {
		t.Skip("skipping long-running test")
	}

	ctxTimeout, cancel := context.WithTimeout(context.Background(), 150*time.Second)
	defer cancel()

	ctx, stop := signal.NotifyContext(ctxTimeout, os.Interrupt, syscall.SIGTERM)
	defer stop()

	// bun
	db := postgres.NewDB(t)

	// watermill
	marshaler := watermillmsg.JSONMarshaler{NewUUID: watermill.NewUUID}
	generateExchangeAndRoutingKeyName := func(topic string) string {
		return topic + "_data_sync"
	}
	rmqConn := rabbitMQ.NewConnection(t)
	nopLogger := watermill.NopLogger{}
	serverPub, serverSub := watermillamqptest.NewPubSub(
		t,
		rmqConn,
		amqp.GenerateQueueNameTopicNameWithSuffix("_data_sync_server"),
		generateExchangeAndRoutingKeyName,
	)
	playerPub, playerSub := watermillamqptest.NewPubSub(
		t,
		rmqConn,
		amqp.GenerateQueueNameTopicNameWithSuffix("_data_sync_player"),
		generateExchangeAndRoutingKeyName,
	)
	tribePub, tribeSub := watermillamqptest.NewPubSub(
		t,
		rmqConn,
		amqp.GenerateQueueNameTopicNameWithSuffix("_data_sync_tribe"),
		generateExchangeAndRoutingKeyName,
	)
	villagePub, villageSub := watermillamqptest.NewPubSub(
		t,
		rmqConn,
		amqp.GenerateQueueNameTopicNameWithSuffix("_data_sync_village"),
		generateExchangeAndRoutingKeyName,
	)

	// events/commands
	serverCmdSync := gofakeit.UUID()
	serverEventSynced := gofakeit.UUID()
	playerEventSynced := gofakeit.UUID()
	tribeEventSynced := gofakeit.UUID()
	villageEventSynced := gofakeit.UUID()

	// TW service

	// filesys is set later in this test to os.DirFS("./testdata/datasync/stage{n}")
	var filesys fs.FS

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodGet {
			w.WriteHeader(http.StatusMethodNotAllowed)
			return
		}

		u, err := url.Parse(r.URL.Query().Get("url"))
		if err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}

		fname := u.Path + u.Query().Get("func")
		f, err := filesys.Open(path.Join(u.Host, fname))
		if err != nil {
			f, err = filesys.Open(path.Join("default", fname))
		}
		if err != nil {
			w.WriteHeader(http.StatusNotFound)
			return
		}
		defer func() {
			_ = f.Close()
		}()

		w.WriteHeader(http.StatusOK)
		_, _ = io.Copy(w, f)
	}))
	t.Cleanup(srv.Close)

	twSvc := adapter.NewTWHTTP(tw.NewClient(tw.WithHTTPClient(&http.Client{
		Transport: &urlChangerTransport{
			url:       srv.URL,
			transport: srv.Client().Transport,
		},
	})))

	// adapters
	versionRepo := adapter.NewVersionBunRepository(db)
	serverRepo := adapter.NewServerBunRepository(db)
	tribeRepo := adapter.NewTribeBunRepository(db)
	playerRepo := adapter.NewPlayerBunRepository(db)
	villageRepo := adapter.NewVillageBunRepository(db)
	tribeChangeRepo := adapter.NewTribeChangeBunRepository(db)
	serverPublisher := adapter.NewServerWatermillPublisher(serverPub, marshaler, serverCmdSync, serverEventSynced)
	playerPublisher := adapter.NewPlayerWatermillPublisher(playerPub, marshaler, playerEventSynced)
	tribePublisher := adapter.NewTribeWatermillPublisher(tribePub, marshaler, tribeEventSynced)
	villagePublisher := adapter.NewVillageWatermillPublisher(villagePub, marshaler, villageEventSynced)

	// services
	versionSvc := app.NewVersionService(versionRepo)
	serverSvc := app.NewServerService(serverRepo, twSvc, serverPublisher)
	tribeChangeSvc := app.NewTribeChangeService(tribeChangeRepo)
	tribeSvc := app.NewTribeService(tribeRepo, twSvc, tribePublisher)
	playerSvc := app.NewPlayerService(playerRepo, tribeChangeSvc, twSvc, playerPublisher)
	villageSvc := app.NewVillageService(villageRepo, twSvc, villagePublisher)
	dataSyncSvc := app.NewDataSyncService(versionSvc, serverSvc, serverPublisher, nil)

	watermilltest.RunRouterWithContext(
		t,
		ctx,
		port.NewServerWatermillConsumer(
			serverSvc,
			serverSub,
			nopLogger,
			marshaler,
			serverCmdSync,
			serverEventSynced,
			tribeEventSynced,
			playerEventSynced,
			villageEventSynced,
			"",
		),
		port.NewTribeWatermillConsumer(tribeSvc, tribeSub, nopLogger, marshaler, serverEventSynced, villageEventSynced),
		port.NewPlayerWatermillConsumer(playerSvc, playerSub, nopLogger, marshaler, serverEventSynced),
		port.NewVillageWatermillConsumer(villageSvc, villageSub, nopLogger, marshaler, serverEventSynced),
	)

	for _, stage := range []uint{1, 2} {
		t.Run(fmt.Sprintf("stage %d", stage), func(t *testing.T) {
			filesys = os.DirFS(fmt.Sprintf("./testdata/datasync/stage%d", stage))

			require.NoError(t, dataSyncSvc.TriggerDataSync(ctx))

			var expectedServers []map[string]any
			readJSONFile(t, filesys, path.Join("expected", "servers.json"), &expectedServers)
			assert.EventuallyWithTf(t, func(collect *assert.CollectT) {
				require.NoError(collect, ctx.Err())

				listParams := domain.NewListServersParams()
				require.NoError(collect, listParams.SetSort([]domain.ServerSort{
					domain.ServerSortKeyASC,
				}))
				require.NoError(collect, listParams.SetSpecial(domain.NullBool{
					Value: false,
					Valid: true,
				}))
				require.NoError(collect, listParams.SetLimit(domain.ServerListMaxLimit))

				allServers := make(domain.Servers, 0, len(expectedServers))

				for {
					servers, err := serverRepo.List(ctx, listParams)
					require.NoError(collect, err)

					if len(servers) == 0 {
						break
					}

					allServers = append(allServers, servers...)

					require.NoError(collect, listParams.SetKeyGT(domain.NullString{
						Value: servers[len(servers)-1].Key(),
						Valid: true,
					}))
				}

				if !assert.Len(collect, allServers, len(expectedServers)) {
					return
				}
				for i, expected := range expectedServers {
					actual := allServers[i]
					msg := fmt.Sprintf("Key=%s", expected["Key"])

					assert.Equal(collect, expected["Key"], actual.Key(), msg)
					assert.Equal(collect, expected["URL"], actual.URL().String(), msg)
					assert.Equal(collect, expected["Open"], actual.Open(), msg)
					assert.Equal(collect, expected["VersionCode"], actual.VersionCode(), msg)
					assert.EqualValues(collect, expected["NumPlayers"], actual.NumPlayers(), msg)
					assert.EqualValues(collect, expected["NumTribes"], actual.NumTribes(), msg)
					assert.EqualValues(collect, expected["NumVillages"], actual.NumVillages(), msg)
					assert.EqualValues(collect, expected["NumPlayerVillages"], actual.NumPlayerVillages(), msg)
					assert.EqualValues(collect, expected["NumBonusVillages"], actual.NumBonusVillages(), msg)
					assert.EqualValues(
						collect,
						expected["NumBarbarianVillages"],
						actual.NumBarbarianVillages(),
						"Key=%s",
						expected["Key"],
					)
					assert.WithinDuration(collect, time.Now(), actual.PlayerDataSyncedAt(), time.Minute, msg)
					assert.WithinDuration(collect, time.Now(), actual.TribeDataSyncedAt(), time.Minute, msg)
					assert.WithinDuration(collect, time.Now(), actual.VillageDataSyncedAt(), time.Minute, msg)
					assert.JSONEqf(
						collect,
						string(marshalJSON(collect, expected["Config"])),
						string(marshalJSON(collect, serverConfigToMap(actual.Config()))),
						"Key=%s",
						expected["Key"],
					)
					assert.JSONEq(
						collect,
						string(marshalJSON(collect, expected["BuildingInfo"])),
						string(marshalJSON(collect, buildingInfoToMap(actual.BuildingInfo()))),
						msg,
					)
					assert.JSONEq(
						collect,
						string(marshalJSON(collect, expected["UnitInfo"])),
						string(marshalJSON(collect, unitInfoToMap(actual.UnitInfo()))),
						msg,
					)
				}
			}, 60*time.Second, time.Second, "servers")

			var expectedTribes []map[string]any
			readJSONFile(t, filesys, path.Join("expected", "tribes.json"), &expectedTribes)
			assert.EventuallyWithT(t, func(collect *assert.CollectT) {
				require.NoError(collect, ctx.Err())

				listParams := domain.NewListTribesParams()
				require.NoError(collect, listParams.SetSort([]domain.TribeSort{
					domain.TribeSortServerKeyASC,
					domain.TribeSortIDASC,
				}))
				require.NoError(collect, listParams.SetLimit(domain.TribeListMaxLimit))

				allTribes := make(domain.Tribes, 0, len(expectedTribes))

				for {
					tribes, err := tribeRepo.List(ctx, listParams)
					require.NoError(collect, err)

					if len(tribes) == 0 {
						break
					}

					allTribes = append(allTribes, tribes...)

					require.NoError(collect, listParams.SetIDGT(domain.NullInt{
						Value: tribes[len(tribes)-1].ID(),
						Valid: true,
					}))
				}

				if !assert.Len(collect, allTribes, len(expectedTribes)) {
					return
				}
				for i, expected := range expectedTribes {
					actual := allTribes[i]
					msg := fmt.Sprintf("ID=%.0f,ServerKey=%s", expected["ID"], expected["ServerKey"])

					assert.EqualValues(collect, expected["ID"], actual.ID(), msg)
					assert.Equal(collect, expected["ServerKey"], actual.ServerKey(), msg)
					assert.Equal(collect, expected["Name"], actual.Name(), msg)
					assert.Equal(collect, expected["Tag"], actual.Tag(), msg)
					assert.EqualValues(collect, expected["NumMembers"], actual.NumMembers(), msg)
					assert.EqualValues(collect, expected["NumVillages"], actual.NumVillages(), msg)
					assert.EqualValues(collect, expected["Points"], actual.Points(), msg)
					assert.EqualValues(collect, expected["AllPoints"], actual.AllPoints(), msg)
					assert.EqualValues(collect, expected["Rank"], actual.Rank(), msg)
					assert.EqualValues(collect, expected["RankAtt"], actual.OD().RankAtt(), msg)
					assert.EqualValues(collect, expected["ScoreAtt"], actual.OD().ScoreAtt(), msg)
					assert.EqualValues(collect, expected["RankDef"], actual.OD().RankDef(), msg)
					assert.EqualValues(collect, expected["ScoreDef"], actual.OD().ScoreDef(), msg)
					assert.EqualValues(collect, expected["RankSup"], actual.OD().RankSup(), msg)
					assert.EqualValues(collect, expected["ScoreSup"], actual.OD().ScoreSup(), msg)
					assert.EqualValues(collect, expected["RankTotal"], actual.OD().RankTotal(), msg)
					assert.EqualValues(collect, expected["ScoreTotal"], actual.OD().ScoreTotal(), msg)
					assert.Equal(collect, expected["ProfileURL"], actual.ProfileURL().String(), msg)
					assert.EqualValues(collect, expected["Dominance"], actual.Dominance(), msg)
					assert.EqualValues(collect, expected["BestRank"], actual.BestRank(), msg)
					assert.NotEmpty(collect, actual.BestRankAt(), msg)
					assert.EqualValues(collect, expected["MostPoints"], actual.MostPoints(), msg)
					assert.NotEmpty(collect, actual.MostPointsAt(), msg)
					assert.EqualValues(collect, expected["MostVillages"], actual.MostVillages(), msg)
					assert.NotEmpty(collect, actual.MostVillagesAt(), msg)
					if deletedAt, ok := expected["DeletedAt"].(string); ok && (time.Time{}).Format(time.RFC3339) != deletedAt {
						assert.NotEmpty(collect, actual.DeletedAt(), msg)
					} else {
						assert.Empty(collect, actual.DeletedAt(), msg)
					}
				}
			}, 60*time.Second, time.Second, "tribes")

			var expectedPlayers []map[string]any
			readJSONFile(t, filesys, path.Join("expected", "players.json"), &expectedPlayers)
			assert.EventuallyWithT(t, func(collect *assert.CollectT) {
				require.NoError(collect, ctx.Err())

				listParams := domain.NewListPlayersParams()
				require.NoError(collect, listParams.SetSort([]domain.PlayerSort{
					domain.PlayerSortServerKeyASC,
					domain.PlayerSortIDASC,
				}))
				require.NoError(collect, listParams.SetLimit(domain.PlayerListMaxLimit))

				allPlayers := make(domain.Players, 0, len(expectedPlayers))

				for {
					players, err := playerRepo.List(ctx, listParams)
					require.NoError(collect, err)

					if len(players) == 0 {
						break
					}

					allPlayers = append(allPlayers, players...)

					require.NoError(collect, listParams.SetIDGT(domain.NullInt{
						Value: players[len(players)-1].ID(),
						Valid: true,
					}))
				}

				if !assert.Len(collect, allPlayers, len(expectedPlayers)) {
					return
				}
				for i, expected := range expectedPlayers {
					actual := allPlayers[i]
					msg := fmt.Sprintf("ID=%.0f,ServerKey=%s", expected["ID"], expected["ServerKey"])

					assert.EqualValues(collect, expected["ID"], actual.ID(), msg)
					assert.Equal(collect, expected["ServerKey"], actual.ServerKey(), msg)
					assert.Equal(collect, expected["Name"], actual.Name(), msg)
					assert.EqualValues(collect, expected["NumVillages"], actual.NumVillages(), msg)
					assert.EqualValues(collect, expected["Points"], actual.Points(), msg)
					assert.EqualValues(collect, expected["Rank"], actual.Rank(), msg)
					assert.EqualValues(collect, expected["TribeID"], actual.TribeID(), msg)
					assert.EqualValues(collect, expected["RankAtt"], actual.OD().RankAtt(), msg)
					assert.EqualValues(collect, expected["ScoreAtt"], actual.OD().ScoreAtt(), msg)
					assert.EqualValues(collect, expected["RankDef"], actual.OD().RankDef(), msg)
					assert.EqualValues(collect, expected["ScoreDef"], actual.OD().ScoreDef(), msg)
					assert.EqualValues(collect, expected["RankSup"], actual.OD().RankSup(), msg)
					assert.EqualValues(collect, expected["ScoreSup"], actual.OD().ScoreSup(), msg)
					assert.EqualValues(collect, expected["RankTotal"], actual.OD().RankTotal(), msg)
					assert.EqualValues(collect, expected["ScoreTotal"], actual.OD().ScoreTotal(), msg)
					assert.Equal(collect, expected["ProfileURL"], actual.ProfileURL().String(), msg)
					assert.EqualValues(collect, expected["BestRank"], actual.BestRank(), msg)
					assert.NotEmpty(collect, actual.BestRankAt(), msg)
					assert.EqualValues(collect, expected["MostPoints"], actual.MostPoints(), msg)
					assert.NotEmpty(collect, actual.MostPointsAt(), msg)
					assert.EqualValues(collect, expected["MostVillages"], actual.MostVillages(), msg)
					assert.NotEmpty(collect, actual.MostVillagesAt(), msg)
					if deletedAt, ok := expected["DeletedAt"].(string); ok && (time.Time{}).Format(time.RFC3339) != deletedAt {
						assert.NotEmpty(collect, actual.DeletedAt(), msg)
					} else {
						assert.Empty(collect, actual.DeletedAt(), msg)
					}
				}
			}, 60*time.Second, time.Second, "players")

			var expectedVillages []map[string]any
			readJSONFile(t, filesys, path.Join("expected", "villages.json"), &expectedVillages)
			assert.EventuallyWithT(t, func(collect *assert.CollectT) {
				require.NoError(collect, ctx.Err())

				listParams := domain.NewListVillagesParams()
				require.NoError(collect, listParams.SetSort([]domain.VillageSort{
					domain.VillageSortServerKeyASC,
					domain.VillageSortIDASC,
				}))
				require.NoError(collect, listParams.SetLimit(domain.VillageListMaxLimit))

				allVillages := make(domain.Villages, 0, len(expectedVillages))

				for {
					villages, err := villageRepo.List(ctx, listParams)
					require.NoError(collect, err)

					if len(villages) == 0 {
						break
					}

					allVillages = append(allVillages, villages...)

					require.NoError(collect, listParams.SetIDGT(domain.NullInt{
						Value: villages[len(villages)-1].ID(),
						Valid: true,
					}))
				}

				if !assert.Len(collect, allVillages, len(expectedVillages)) {
					return
				}
				for i, expected := range expectedVillages {
					actual := allVillages[i]
					msg := fmt.Sprintf("ID=%.0f,ServerKey=%s", expected["ID"], expected["ServerKey"])

					assert.EqualValues(collect, expected["ID"], actual.ID(), msg)
					assert.Equal(collect, expected["ServerKey"], actual.ServerKey(), msg)
					assert.Equal(collect, expected["Name"], actual.Name(), msg)
					assert.EqualValues(collect, expected["Points"], actual.Points(), msg)
					assert.EqualValues(collect, expected["X"], actual.X(), msg)
					assert.EqualValues(collect, expected["Y"], actual.Y(), msg)
					assert.Equal(collect, expected["Continent"], actual.Continent(), msg)
					assert.EqualValues(collect, expected["Bonus"], actual.Bonus(), msg)
					assert.EqualValues(collect, expected["PlayerID"], actual.PlayerID(), msg)
					assert.Equal(collect, expected["ProfileURL"], actual.ProfileURL().String(), msg)
				}
			}, 60*time.Second, time.Second, "villages")

			var expectedTribeChanges []map[string]any
			readJSONFile(t, filesys, path.Join("expected", "tribe-changes.json"), &expectedTribeChanges)
			assert.EventuallyWithT(t, func(collect *assert.CollectT) {
				require.NoError(collect, ctx.Err())

				listParams := domain.NewListTribeChangesParams()
				require.NoError(collect, listParams.SetSort([]domain.TribeChangeSort{
					domain.TribeChangeSortIDASC,
				}))
				require.NoError(collect, listParams.SetLimit(domain.TribeChangeListMaxLimit))

				allTribeChanges := make(domain.TribeChanges, 0, len(expectedTribeChanges))

				for {
					tcs, err := tribeChangeRepo.List(ctx, listParams)
					require.NoError(collect, err)

					if len(tcs) == 0 {
						break
					}

					allTribeChanges = append(allTribeChanges, tcs...)

					require.NoError(collect, listParams.SetOffset(listParams.Offset()+domain.TribeChangeListMaxLimit))
				}

				if !assert.Len(collect, allTribeChanges, len(expectedTribeChanges)) {
					return
				}
				for i, expected := range expectedTribeChanges {
					actual := allTribeChanges[i]
					msg := fmt.Sprintf("ID=%.0f,ServerKey=%s", expected["ID"], expected["ServerKey"])

					assert.EqualValues(collect, expected["ID"], actual.ID(), msg)
					assert.Equal(collect, expected["ServerKey"], actual.ServerKey(), msg)
					assert.EqualValues(collect, expected["PlayerID"], actual.PlayerID(), msg)
					assert.EqualValues(collect, expected["OldTribeID"], actual.OldTribeID(), msg)
					assert.EqualValues(collect, expected["NewTribeID"], actual.NewTribeID(), msg)
				}
			}, 60*time.Second, time.Second, "tribe changes")
		})
	}
}

type urlChangerTransport struct {
	url       string
	transport http.RoundTripper
}

func (t *urlChangerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
	u, err := url.Parse(t.url)
	if err != nil {
		return nil, err
	}
	u.RawQuery = url.Values{
		"url": []string{r.URL.String()},
	}.Encode()

	r2 := r.Clone(r.Context())
	r2.URL = u

	return t.transport.RoundTrip(r2)
}

func readJSONFile(tb testing.TB, filesys fs.FS, name string, v any) {
	tb.Helper()

	f, err := filesys.Open(name)
	require.NoError(tb, err)
	defer func() {
		_ = f.Close()
	}()

	require.NoError(tb, json.NewDecoder(f).Decode(v))
}

func serverConfigToMap(cfg domain.ServerConfig) map[string]any {
	return map[string]any{
		"Speed":     cfg.Speed(),
		"UnitSpeed": cfg.UnitSpeed(),
		"Moral":     cfg.Moral(),
		"Build":     cfg.Build(),
		"Misc":      cfg.Misc(),
		"Commands":  cfg.Commands(),
		"Newbie":    cfg.Newbie(),
		"Game":      cfg.Game(),
		"Buildings": cfg.Buildings(),
		"Snob":      cfg.Snob(),
		"Ally":      cfg.Ally(),
		"Coord":     cfg.Coord(),
		"Sitter":    cfg.Sitter(),
		"Sleep":     cfg.Sleep(),
		"Night":     cfg.Night(),
		"Win":       cfg.Win(),
	}
}

func unitInfoToMap(info domain.UnitInfo) map[string]any {
	return map[string]any{
		"Spear":    info.Spear(),
		"Sword":    info.Sword(),
		"Axe":      info.Axe(),
		"Archer":   info.Archer(),
		"Spy":      info.Spy(),
		"Light":    info.Light(),
		"Marcher":  info.Marcher(),
		"Heavy":    info.Heavy(),
		"Ram":      info.Ram(),
		"Catapult": info.Catapult(),
		"Knight":   info.Knight(),
		"Snob":     info.Snob(),
		"Militia":  info.Militia(),
	}
}

func buildingInfoToMap(info domain.BuildingInfo) map[string]any {
	return map[string]any{
		"Main":       info.Main(),
		"Barracks":   info.Barracks(),
		"Stable":     info.Stable(),
		"Garage":     info.Garage(),
		"Watchtower": info.Watchtower(),
		"Snob":       info.Snob(),
		"Smith":      info.Smith(),
		"Place":      info.Place(),
		"Statue":     info.Statue(),
		"Market":     info.Market(),
		"Wood":       info.Wood(),
		"Stone":      info.Stone(),
		"Iron":       info.Iron(),
		"Farm":       info.Farm(),
		"Storage":    info.Storage(),
		"Hide":       info.Hide(),
		"Wall":       info.Wall(),
	}
}

func marshalJSON(tb require.TestingT, v any) []byte {
	if h, ok := tb.(interface {
		Helper()
	}); ok {
		h.Helper()
	}
	b, err := json.Marshal(v)
	require.NoError(tb, err)
	return b
}
