package tw

import (
	"cmp"
	"context"
	"encoding/csv"
	"encoding/xml"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"slices"
	"strconv"
	"time"

	"github.com/elliotchance/phpserialize"
)

const (
	endpointPlayers      = "/map/player.txt"
	endpointTribes       = "/map/ally.txt"
	endpointVillages     = "/map/village.txt"
	endpointPlayersODA   = "/map/kill_att.txt"
	endpointPlayersODD   = "/map/kill_def.txt"
	endpointPlayersODS   = "/map/kill_sup.txt"
	endpointPlayersOD    = "/map/kill_all.txt"
	endpointTribesODA    = "/map/kill_att_tribe.txt"
	endpointTribesODD    = "/map/kill_def_tribe.txt"
	endpointTribesOD     = "/map/kill_all_tribe.txt"
	endpointEnnoblements = "/map/conquer_extended.txt"
	endpointInterface    = "/interface.php"
	endpointGetServers   = "/backend/get_servers.php"
	endpointGame         = "/game.php"
)

const (
	queryConfig                = "func=get_config"
	queryUnitInfo              = "func=get_unit_info"
	queryBuildingInfo          = "func=get_building_info"
	queryInterfaceEnnoblements = "func=get_conquer_extended&since=%d"
	queryPlayerProfile         = "screen=info_player&id=%d"
	queryTribeProfile          = "screen=info_ally&id=%d"
	queryVillageProfile        = "screen=info_village&id=%d"
)

type Client struct {
	client                       *http.Client
	userAgent                    string
	ennoblementsUseInterfaceFunc func(since time.Time) bool
}

func NewClient(opts ...ClientOption) *Client {
	cfg := newClientConfig(opts...)
	return &Client{
		client:                       cfg.client,
		userAgent:                    cfg.userAgent,
		ennoblementsUseInterfaceFunc: cfg.ennoblementsUseInterfaceFunc,
	}
}

var ErrBaseURLCantBeNil = errors.New("baseURL can't be nil")

var (
	ErrInvalidServerKey = errors.New("invalid server key")
	ErrInvalidServerURL = errors.New("invalid server URL")
)

func (c *Client) GetOpenServers(ctx context.Context, baseURL *url.URL) ([]Server, error) {
	if baseURL == nil {
		return nil, ErrBaseURLCantBeNil
	}

	resp, err := c.get(ctx, buildURL(baseURL, endpointGetServers))
	if err != nil {
		return nil, err
	}
	defer func() {
		_ = resp.Body.Close()
	}()

	return serverParser{r: resp.Body}.parse()
}

func (c *Client) GetServerConfig(ctx context.Context, baseURL *url.URL) (ServerConfig, error) {
	if baseURL == nil {
		return ServerConfig{}, ErrBaseURLCantBeNil
	}

	var cfg ServerConfig

	if err := c.getXML(ctx, buildURLWithQuery(baseURL, endpointInterface, queryConfig), &cfg); err != nil {
		return ServerConfig{}, err
	}

	return cfg, nil
}

func (c *Client) GetBuildingInfo(ctx context.Context, baseURL *url.URL) (BuildingInfo, error) {
	if baseURL == nil {
		return BuildingInfo{}, ErrBaseURLCantBeNil
	}

	var info BuildingInfo

	if err := c.getXML(ctx, buildURLWithQuery(baseURL, endpointInterface, queryBuildingInfo), &info); err != nil {
		return BuildingInfo{}, err
	}

	return info, nil
}

func (c *Client) GetUnitInfo(ctx context.Context, baseURL *url.URL) (UnitInfo, error) {
	if baseURL == nil {
		return UnitInfo{}, ErrBaseURLCantBeNil
	}

	var info UnitInfo

	if err := c.getXML(ctx, buildURLWithQuery(baseURL, endpointInterface, queryUnitInfo), &info); err != nil {
		return UnitInfo{}, err
	}

	return info, nil
}

func (c *Client) GetTribes(ctx context.Context, baseURL *url.URL) ([]Tribe, error) {
	if baseURL == nil {
		return nil, ErrBaseURLCantBeNil
	}

	od, err := c.getOD(ctx, baseURL, true)
	if err != nil {
		return nil, err
	}

	resp, err := c.get(ctx, buildURL(baseURL, endpointTribes))
	if err != nil {
		return nil, err
	}
	defer func() {
		_ = resp.Body.Close()
	}()

	return tribeCSVParser{
		r:       resp.Body,
		baseURL: baseURL,
		od:      od,
	}.parse()
}

func (c *Client) GetPlayers(ctx context.Context, baseURL *url.URL) ([]Player, error) {
	if baseURL == nil {
		return nil, ErrBaseURLCantBeNil
	}

	od, err := c.getOD(ctx, baseURL, false)
	if err != nil {
		return nil, err
	}

	resp, err := c.get(ctx, buildURL(baseURL, endpointPlayers))
	if err != nil {
		return nil, err
	}
	defer func() {
		_ = resp.Body.Close()
	}()

	return playerCSVParser{
		r:       resp.Body,
		baseURL: baseURL,
		od:      od,
	}.parse()
}

func (c *Client) getOD(ctx context.Context, baseURL *url.URL, tribe bool) (map[int]OpponentsDefeated, error) {
	m := make(map[int]OpponentsDefeated)
	urls := buildODURLs(baseURL, tribe)

	for _, u := range urls {
		if u == nil {
			continue
		}

		records, err := c.getSingleODFile(ctx, u)
		if err != nil {
			return nil, err
		}

		for _, rec := range records {
			od := m[rec.ID]

			switch u {
			case urls[0]:
				od.RankTotal = rec.Rank
				od.ScoreTotal = rec.Score
			case urls[1]:
				od.RankAtt = rec.Rank
				od.ScoreAtt = rec.Score
			case urls[2]:
				od.RankDef = rec.Rank
				od.ScoreDef = rec.Score
			case urls[3]:
				od.RankSup = rec.Rank
				od.ScoreSup = rec.Score
			}

			m[rec.ID] = od
		}
	}

	return m, nil
}

func (c *Client) getSingleODFile(ctx context.Context, u *url.URL) ([]odRecord, error) {
	resp, err := c.get(ctx, u)
	if err != nil {
		return nil, err
	}
	defer func() {
		_ = resp.Body.Close()
	}()

	return odCSVParser{
		r: resp.Body,
	}.parse()
}

func (c *Client) GetVillages(ctx context.Context, baseURL *url.URL) ([]Village, error) {
	if baseURL == nil {
		return nil, ErrBaseURLCantBeNil
	}

	resp, err := c.get(ctx, buildURL(baseURL, endpointVillages))
	if err != nil {
		return nil, err
	}
	defer func() {
		_ = resp.Body.Close()
	}()

	return villageCSVParser{
		r:       resp.Body,
		baseURL: baseURL,
	}.parse()
}

func (c *Client) GetEnnoblements(ctx context.Context, baseURL *url.URL, since time.Time) ([]Ennoblement, error) {
	if baseURL == nil {
		return nil, ErrBaseURLCantBeNil
	}

	u := buildURL(baseURL, endpointEnnoblements)
	if c.ennoblementsUseInterfaceFunc(since) {
		u = buildURLWithQuery(baseURL, endpointInterface, fmt.Sprintf(queryInterfaceEnnoblements, since.Unix()))
	}

	resp, err := c.get(ctx, u)
	if err != nil {
		return nil, err
	}
	defer func() {
		_ = resp.Body.Close()
	}()

	return ennoblementCSVParser{
		r:     resp.Body,
		since: since,
	}.parse()
}

func (c *Client) getXML(ctx context.Context, u *url.URL, v any) error {
	resp, err := c.get(ctx, u)
	if err != nil {
		return err
	}
	defer func() {
		_ = resp.Body.Close()
	}()

	if err = xml.NewDecoder(resp.Body).Decode(v); err != nil {
		return fmt.Errorf("%s: %w", u, err)
	}

	return nil
}

func (c *Client) get(ctx context.Context, u *url.URL) (*http.Response, error) {
	urlStr := u.String()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
	if err != nil {
		return nil, err
	}

	// headers
	req.Header.Set("User-Agent", c.userAgent)

	resp, err := c.client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("%s: %w", urlStr, err)
	}

	if resp.StatusCode != http.StatusOK {
		_, _ = io.Copy(io.Discard, resp.Body)
		_ = resp.Body.Close()
		return nil, fmt.Errorf("%s: got non-ok HTTP status: %d", urlStr, resp.StatusCode)
	}

	return resp, nil
}

type serverParser struct {
	r io.Reader
}

func (p serverParser) parse() ([]Server, error) {
	b, err := io.ReadAll(p.r)
	if err != nil {
		return nil, fmt.Errorf("couldn't read data: %w", err)
	}

	m, err := phpserialize.UnmarshalAssociativeArray(b)
	if err != nil {
		return nil, fmt.Errorf("couldn't unmarshal response body: %w", err)
	}

	servers := make([]Server, 0, len(m))

	for key, val := range m {
		keyStr, ok := key.(string)
		if !ok || keyStr == "" {
			return nil, fmt.Errorf("parsing '%v': %w", key, ErrInvalidServerKey)
		}

		urlStr, ok := val.(string)
		if !ok || urlStr == "" {
			return nil, fmt.Errorf("parsing '%v': %w", val, ErrInvalidServerURL)
		}

		serverURL, parseErr := url.ParseRequestURI(urlStr)
		if parseErr != nil {
			return nil, fmt.Errorf("parsing '%v': %w", val, parseErr)
		}

		servers = append(servers, Server{
			Key: keyStr,
			URL: serverURL,
		})
	}

	return servers, nil
}

type tribeCSVParser struct {
	r       io.Reader
	baseURL *url.URL
	od      map[int]OpponentsDefeated
}

const fieldsPerRecordTribe = 8

func (p tribeCSVParser) parse() ([]Tribe, error) {
	csvR := newCSVReader(p.r, fieldsPerRecordTribe)

	var tribes []Tribe
	for {
		rec, err := csvR.Read()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			return nil, err
		}

		tribe, err := p.parseRecord(rec)
		if err != nil {
			return nil, err
		}

		tribes = append(tribes, tribe)
	}

	slices.SortFunc(tribes, func(a, b Tribe) int {
		return cmp.Compare(a.ID, b.ID)
	})

	return tribes, nil
}

func (p tribeCSVParser) parseRecord(record []string) (Tribe, error) {
	var err error
	var tribe Tribe

	// $id, $name, $tag, $members, $villages, $points, $all_points, $rank

	tribe.ID, err = strconv.Atoi(record[0])
	if err != nil {
		return Tribe{}, ParseError{Err: err, Str: record[0], Field: "Tribe.ID"}
	}

	tribe.Name, err = url.QueryUnescape(record[1])
	if err != nil {
		return Tribe{}, ParseError{Err: err, Str: record[1], Field: "Tribe.Name"}
	}

	tribe.Tag, err = url.QueryUnescape(record[2])
	if err != nil {
		return Tribe{}, ParseError{Err: err, Str: record[2], Field: "Tribe.Tag"}
	}

	tribe.NumMembers, err = strconv.Atoi(record[3])
	if err != nil {
		return Tribe{}, ParseError{Err: err, Str: record[3], Field: "Tribe.NumMembers"}
	}

	tribe.NumVillages, err = strconv.Atoi(record[4])
	if err != nil {
		return Tribe{}, ParseError{Err: err, Str: record[4], Field: "Tribe.NumVillages"}
	}

	tribe.Points, err = strconv.Atoi(record[5])
	if err != nil {
		return Tribe{}, ParseError{Err: err, Str: record[5], Field: "Tribe.Points"}
	}

	tribe.AllPoints, err = strconv.Atoi(record[6])
	if err != nil {
		return Tribe{}, ParseError{Err: err, Str: record[6], Field: "Tribe.AllPoints"}
	}

	tribe.Rank, err = strconv.Atoi(record[7])
	if err != nil {
		return Tribe{}, ParseError{Err: err, Str: record[7], Field: "Tribe.Rank"}
	}

	tribe.OpponentsDefeated = p.od[tribe.ID]

	tribe.ProfileURL = buildURLWithQuery(p.baseURL, endpointGame, fmt.Sprintf(queryTribeProfile, tribe.ID))

	return tribe, nil
}

type playerCSVParser struct {
	r       io.Reader
	od      map[int]OpponentsDefeated
	baseURL *url.URL
}

const fieldsPerRecordPlayer = 6

func (p playerCSVParser) parse() ([]Player, error) {
	csvR := newCSVReader(p.r, fieldsPerRecordPlayer)

	var players []Player
	for {
		rec, err := csvR.Read()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			return nil, err
		}

		player, err := p.parseRecord(rec)
		if err != nil {
			return nil, err
		}

		players = append(players, player)
	}

	slices.SortFunc(players, func(a, b Player) int {
		return cmp.Compare(a.ID, b.ID)
	})

	return players, nil
}

func (p playerCSVParser) parseRecord(record []string) (Player, error) {
	var err error
	var player Player

	// $id, $name, $ally, $villages, $points, $rank

	player.ID, err = strconv.Atoi(record[0])
	if err != nil {
		return Player{}, ParseError{Err: err, Str: record[0], Field: "Player.ID"}
	}

	player.Name, err = url.QueryUnescape(record[1])
	if err != nil {
		return Player{}, ParseError{Err: err, Str: record[1], Field: "Player.Name"}
	}

	player.TribeID, err = strconv.Atoi(record[2])
	if err != nil {
		return Player{}, ParseError{Err: err, Str: record[2], Field: "Player.TribeID"}
	}

	player.NumVillages, err = strconv.Atoi(record[3])
	if err != nil {
		return Player{}, ParseError{Err: err, Str: record[3], Field: "Player.NumVillages"}
	}

	player.Points, err = strconv.Atoi(record[4])
	if err != nil {
		return Player{}, ParseError{Err: err, Str: record[4], Field: "Player.Points"}
	}

	player.Rank, err = strconv.Atoi(record[5])
	if err != nil {
		return Player{}, ParseError{Err: err, Str: record[5], Field: "Player.Rank"}
	}

	player.OpponentsDefeated = p.od[player.ID]

	player.ProfileURL = buildURLWithQuery(p.baseURL, endpointGame, fmt.Sprintf(queryPlayerProfile, player.ID))

	return player, nil
}

type villageCSVParser struct {
	r       io.Reader
	baseURL *url.URL
}

const fieldsPerRecordVillage = 7

func (p villageCSVParser) parse() ([]Village, error) {
	csvR := newCSVReader(p.r, fieldsPerRecordVillage)

	var villages []Village
	for {
		rec, err := csvR.Read()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			return nil, err
		}
		village, err := p.parseRecord(rec)
		if err != nil {
			return nil, err
		}
		villages = append(villages, village)
	}

	slices.SortFunc(villages, func(a, b Village) int {
		return cmp.Compare(a.ID, b.ID)
	})

	return villages, nil
}

func (p villageCSVParser) parseRecord(record []string) (Village, error) {
	var err error
	var village Village

	// $id, $name, $x, $y, $player, $points, $bonus_id

	village.ID, err = strconv.Atoi(record[0])
	if err != nil {
		return Village{}, ParseError{Err: err, Str: record[0], Field: "Village.ID"}
	}

	village.Name, err = url.QueryUnescape(record[1])
	if err != nil {
		return Village{}, ParseError{Err: err, Str: record[1], Field: "Village.Name"}
	}

	village.X, err = strconv.Atoi(record[2])
	if err != nil {
		return Village{}, ParseError{Err: err, Str: record[2], Field: "Village.X"}
	}

	village.Y, err = strconv.Atoi(record[3])
	if err != nil {
		return Village{}, ParseError{Err: err, Str: record[3], Field: "Village.Y"}
	}

	village.PlayerID, err = strconv.Atoi(record[4])
	if err != nil {
		return Village{}, ParseError{Err: err, Str: record[4], Field: "Village.PlayerID"}
	}

	village.Points, err = strconv.Atoi(record[5])
	if err != nil {
		return Village{}, ParseError{Err: err, Str: record[5], Field: "Village.Points"}
	}

	village.Bonus, err = strconv.Atoi(record[6])
	if err != nil {
		return Village{}, ParseError{Err: err, Str: record[6], Field: "Village.Bonus"}
	}

	village.Continent = p.buildContinent(record, village.X, village.Y)

	village.ProfileURL = buildURLWithQuery(p.baseURL, endpointGame, fmt.Sprintf(queryVillageProfile, village.ID))

	return village, nil
}

func (p villageCSVParser) buildContinent(record []string, x, y int) string {
	continent := "K"

	switch {
	case x < 100 && y < 100:
		continent += "0"
	case x > 100 && y < 100:
		continent += string(record[2][0])
	case x < 100 && y > 100:
		continent += string(record[3][0]) + "0"
	default:
		continent += string(record[3][0]) + string(record[2][0])
	}

	return continent
}

type odCSVParser struct {
	r io.Reader
}

type odRecord struct {
	ID    int
	Rank  int
	Score int
}

const fieldsPerRecordOD = 3

func (p odCSVParser) parse() ([]odRecord, error) {
	csvR := newCSVReader(p.r, fieldsPerRecordOD)

	var odRecords []odRecord
	for {
		rec, err := csvR.Read()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			return nil, err
		}

		od, err := p.parseRecord(rec)
		if err != nil {
			return nil, err
		}

		odRecords = append(odRecords, od)
	}

	return odRecords, nil
}

func (p odCSVParser) parseRecord(record []string) (odRecord, error) {
	var err error
	var rec odRecord

	// $rank, $id, $score

	rec.Rank, err = strconv.Atoi(record[0])
	if err != nil {
		return odRecord{}, ParseError{Err: err, Str: record[0], Field: "odRecord.Rank"}
	}

	rec.ID, err = strconv.Atoi(record[1])
	if err != nil {
		return odRecord{}, ParseError{Err: err, Str: record[1], Field: "odRecord.ID"}
	}

	rec.Score, err = strconv.Atoi(record[2])
	if err != nil {
		return odRecord{}, ParseError{Err: err, Str: record[2], Field: "odRecord.Score"}
	}

	return rec, nil
}

type ennoblementCSVParser struct {
	r     io.Reader
	since time.Time
}

const fieldsPerRecordEnnoblement = 7

func (p ennoblementCSVParser) parse() ([]Ennoblement, error) {
	csvR := newCSVReader(p.r, fieldsPerRecordEnnoblement)

	var ennoblements []Ennoblement
	for {
		rec, err := csvR.Read()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			return nil, err
		}
		ennoblement, err := p.parseRecord(rec)
		if err != nil {
			return nil, err
		}

		if ennoblement.CreatedAt.Before(p.since) {
			continue
		}

		ennoblements = append(ennoblements, ennoblement)
	}

	slices.SortFunc(ennoblements, func(a, b Ennoblement) int {
		return a.CreatedAt.Compare(b.CreatedAt)
	})

	return ennoblements, nil
}

func (p ennoblementCSVParser) parseRecord(record []string) (Ennoblement, error) {
	var err error
	var ennoblement Ennoblement

	// $village_id, $unix_timestamp, $new_owner, $old_owner, $old_tribe_id, $new_tribe_id, $points

	ennoblement.VillageID, err = strconv.Atoi(record[0])
	if err != nil {
		return Ennoblement{}, ParseError{Err: err, Str: record[0], Field: "Ennoblement.VillageID"}
	}

	ennoblement.CreatedAt, err = p.parseTimestamp(record[1])
	if err != nil {
		return Ennoblement{}, ParseError{Err: err, Str: record[1], Field: "Ennoblement.CreatedAt"}
	}

	ennoblement.NewOwnerID, err = strconv.Atoi(record[2])
	if err != nil {
		return Ennoblement{}, ParseError{Err: err, Str: record[2], Field: "Ennoblement.NewOwnerID"}
	}

	ennoblement.OldOwnerID, err = strconv.Atoi(record[3])
	if err != nil {
		return Ennoblement{}, ParseError{Err: err, Str: record[3], Field: "Ennoblement.OldOwnerID"}
	}

	ennoblement.OldTribeID, err = strconv.Atoi(record[4])
	if err != nil {
		return Ennoblement{}, ParseError{Err: err, Str: record[4], Field: "Ennoblement.OldTribeID"}
	}

	ennoblement.NewTribeID, err = strconv.Atoi(record[5])
	if err != nil {
		return Ennoblement{}, ParseError{Err: err, Str: record[5], Field: "Ennoblement.NewTribeID"}
	}

	ennoblement.Points, err = strconv.Atoi(record[6])
	if err != nil {
		return Ennoblement{}, ParseError{Err: err, Str: record[6], Field: "Ennoblement.Points"}
	}

	return ennoblement, nil
}

func (p ennoblementCSVParser) parseTimestamp(s string) (time.Time, error) {
	timestamp, err := strconv.ParseInt(s, 10, 64)
	if err != nil {
		return time.Time{}, err
	}
	return time.Unix(timestamp, 0), nil
}

func newCSVReader(r io.Reader, fieldsPerRecord int) *csv.Reader {
	csvR := csv.NewReader(r)
	csvR.Comma = ','
	csvR.FieldsPerRecord = fieldsPerRecord
	csvR.ReuseRecord = true
	return csvR
}

//nolint:revive
func buildODURLs(base *url.URL, tribe bool) [4]*url.URL {
	// there is no endpoint to get opponents defeated as supporter for tribes :(
	if tribe {
		return [4]*url.URL{
			buildURL(base, endpointTribesOD),
			buildURL(base, endpointTribesODA),
			buildURL(base, endpointTribesODD),
			nil,
		}
	}
	return [4]*url.URL{
		buildURL(base, endpointPlayersOD),
		buildURL(base, endpointPlayersODA),
		buildURL(base, endpointPlayersODD),
		buildURL(base, endpointPlayersODS),
	}
}

func buildURL(base *url.URL, path string) *url.URL {
	return buildURLWithQuery(base, path, "")
}

func buildURLWithQuery(base *url.URL, path, query string) *url.URL {
	newURL := *base
	newURL.Path = path
	newURL.RawQuery = query
	return &newURL
}
