package main

import (
	"bytes"
	"crypto/rand"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"
)

// Match and game fields use the same names as the wire protocol. A nil View
// means the match has not started; a nil Inventory means it is not visible.
type Identity struct {
	TeamID      string `json:"teamId"`
	DisplayName string `json:"displayName"`
}
type Position struct {
	X int `json:"x"`
	Y int `json:"y"`
}
type SolarPosition struct {
	X float64 `json:"x"`
	Y float64 `json:"y"`
}
type Inventory struct {
	Gold int `json:"gold"`
	Iron int `json:"iron"`
}
type Worker struct {
	UnitID            string     `json:"unitId"`
	TeamID            string     `json:"teamId"`
	Position          Position   `json:"position"`
	ReadyOnTurn       int        `json:"readyOnTurn"`
	InventoryCapacity int        `json:"inventoryCapacity"`
	Inventory         *Inventory `json:"inventory"`
}
type Base struct {
	BaseID   string   `json:"baseId"`
	TeamID   string   `json:"teamId"`
	Position Position `json:"position"`
}
type Tile struct {
	Type      string                    `json:"type"`
	Amount    int                       `json:"amount"`
	Formation *struct{ X, Y, Size int } `json:"formation"`
	UnitID    string                    `json:"unitId"`
	TeamID    string                    `json:"teamId"`
}
type GameMap struct {
	Width  int    `json:"width"`
	Height int    `json:"height"`
	Cells  []Tile `json:"cells"`
}
type Notice struct {
	EntityID string `json:"entityId"`
	Error    string `json:"error"`
	Note     string `json:"note"`
}
type Effect struct {
	Type       string   `json:"type"`
	Coordinate Position `json:"coordinate"`
	EntityIDs  []string `json:"entityIds"`
	Note       string   `json:"note"`
	BaseID     string   `json:"baseId"`
	Resource   string   `json:"resource"`
	Amount     int      `json:"amount"`
	Total      int      `json:"total"`
}
type Placement struct {
	TeamID    string  `json:"teamId"`
	Placement int     `json:"placement"`
	Score     float64 `json:"score"`
}
type Planet struct {
	Name        string        `json:"name"`
	Position    SolarPosition `json:"position"`
	OrbitRadius float64       `json:"orbitRadius"`
}
type Rocket struct {
	TeamID           string         `json:"teamId"`
	Position         SolarPosition  `json:"position"`
	PreviousPosition SolarPosition  `json:"previousPosition"`
	Heading          float64        `json:"heading"`
	Target           *string        `json:"target"`
	TargetPosition   *SolarPosition `json:"targetPosition"`
	Orbit            *struct {
		Planet string  `json:"planet"`
		Angle  float64 `json:"angle"`
	} `json:"orbit"`
}
type GameView struct {
	Turn int `json:"turn"`
	// LPC fields.
	Map                  GameMap              `json:"map"`
	Units                []Worker             `json:"units"`
	Bases                []Base               `json:"bases"`
	Scores               map[string]float64   `json:"scores"`
	TeamResources        map[string]Inventory `json:"teamResources"`
	BaseResourceDeposits map[string]Inventory `json:"baseResourceDeposits"`
	HighGoldArea         struct {
		Width, Height, Min, Max int
		SpawnPercent            int `json:"spawnPercent"`
	} `json:"highGoldArea"`
	Warnings   []Notice    `json:"warnings"`
	Info       []Notice    `json:"info"`
	Effects    []Effect    `json:"effects"`
	Placements []Placement `json:"placements"`
	// Solar Orbitor fields.
	EntityID string   `json:"entityId"`
	Planets  []Planet `json:"planets"`
	Rockets  []Rocket `json:"rockets"`
}
type Team struct {
	ID          string          `json:"id"`
	DisplayName string          `json:"displayName"`
	BotType     json.RawMessage `json:"botType"`
}
type MatchSummary struct {
	MatchID            string   `json:"matchId"`
	Mode               string   `json:"mode"`
	Status             string   `json:"status"`
	RulesVersion       string   `json:"rulesVersion"`
	Turn               int      `json:"turn"`
	MaxTurns           int      `json:"maxTurns"`
	TurnAdvanceMode    string   `json:"turnAdvanceMode"`
	Teams              []Team   `json:"teams"`
	ExpectedTeamIDs    []string `json:"expectedTeamIds"`
	TeamIDs            []string `json:"teamIds"`
	ActiveTeamIDs      []string `json:"activeTeamIds"`
	StartedAtMs        *int64   `json:"startedAtMs"`
	CompletedAtMs      *int64   `json:"completedAtMs"`
	CancellationReason string   `json:"cancellationReason"`
}
type GameState struct {
	Match           MatchSummary      `json:"match"`
	View            *GameView         `json:"view"`
	ActionRevisions map[string]uint64 `json:"actionRevisions"`
	ServerTimeMs    int64             `json:"serverTimeMs"`
	TurnEndsAtMs    *int64            `json:"turnEndsAtMs"`
}

// LPC: type = move/mine/deposit/spawn/build-base/trade/pass. Move needs
// Direction (e.g. north-east); trade needs From (iron/gold).
// Solar: Type = planet name (e.g. Earth), or move with X and Y.
type Action struct {
	Type      string   `json:"type"`
	Direction string   `json:"direction,omitempty"`
	From      string   `json:"from,omitempty"`
	X         *float64 `json:"x,omitempty"`
	Y         *float64 `json:"y,omitempty"`
}
type Actions map[string]Action
type Strategy func(*GameState, *Identity) Actions

const currentPath = "/api/team/matches/current"
const maxRevision uint64 = 9007199254740991

type apiError struct {
	status        int
	code, message string
}

func (e *apiError) inactive() bool {
	return e.status == 403 && e.code == "forbidden" && e.message == "Team is not in an active match."
}
func (e *apiError) fatal() bool {
	return e.status == 401 || e.status == 403 || e.code == "bot_session_conflict"
}
func (e *apiError) Error() string {
	switch {
	case e.inactive():
		return "No current match is available; join a match before starting the bot."
	case e.code == "pvp_disabled":
		return "No match assigned. PvP practice is currently disabled. Create a practice match on the website or ask an organizer to assign one, then run your bot again."
	case e.code == "bot_session_conflict":
		return "This bot was replaced by another client for this team and match."
	case e.status == 401 || e.status == 403:
		return "API key was rejected; check API_KEY."
	case e.status == 404:
		return "Cannot reach the competition API; check LUCID_API_BASE_URL."
	default:
		return fmt.Sprintf("Server request failed (HTTP %d).", e.status)
	}
}

type BotClient struct {
	http                                     *http.Client
	baseURL, key, session, matchID, revision string
	recovering                               bool
}

func NewBotClient() (*BotClient, error) {
	key := strings.TrimSpace(os.Getenv("API_KEY"))
	if key == "" || key == "replace-with-api-key" {
		return nil, errors.New("Set API_KEY to your team's API key before starting the bot.")
	}
	base := strings.TrimRight(strings.TrimSpace(os.Getenv("LUCID_API_BASE_URL")), "/")
	if base == "" {
		base = "http://localhost:5173"
		fmt.Fprintln(os.Stderr, "LUCID_API_BASE_URL is not set; using", base)
	}
	parsed, err := url.Parse(base)
	if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
		return nil, errors.New("Invalid LUCID_API_BASE_URL; use the server's HTTP or HTTPS URL.")
	}
	return &BotClient{baseURL: base, key: key, http: &http.Client{
		Timeout: 5 * time.Second,
		// Do not forward a team key to a redirect target. Use the final HTTPS URL.
		CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
	}}, nil
}
func token() (string, error) {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	b[6] = (b[6] & 15) | 64
	b[8] = (b[8] & 63) | 128
	return fmt.Sprintf("%x-%x-%x-%x-%x", b[:4], b[4:6], b[6:8], b[8:10], b[10:]), nil
}
func (c *BotClient) request(method, path string, body []byte, retry bool, out any) error {
	attempts := 1
	if retry {
		attempts = 3
	}
	for attempt := 0; attempt < attempts; attempt++ {
		req, err := http.NewRequest(method, c.baseURL+path, bytes.NewReader(body))
		if err != nil {
			return err
		}
		req.Header.Set("x-api-key", c.key)
		if c.session != "" {
			req.Header.Set("x-bot-session", c.session)
		}
		if body != nil {
			req.Header.Set("Content-Type", "application/json")
		}
		if path == currentPath+"/register" && c.matchID != "" {
			req.Header.Set("x-bot-match-id", c.matchID)
			if c.recovering {
				req.Header.Set("x-bot-session-recover", "1")
			} else {
				req.Header.Set("x-bot-session-revision", c.revision)
			}
		}
		response, err := c.http.Do(req)
		if err != nil {
			if attempt+1 == attempts {
				return errors.New("Cannot reach " + c.baseURL + "; check the URL and your connection.")
			}
		} else {
			data, readErr := io.ReadAll(response.Body)
			response.Body.Close()
			if readErr != nil {
				if attempt+1 == attempts {
					return readErr
				}
			} else if response.StatusCode >= 200 && response.StatusCode < 300 {
				if revision := response.Header.Get("x-bot-session-revision"); revision != "" {
					c.revision = revision
				}
				if path == currentPath+"/register" {
					if session := response.Header.Get("x-bot-session"); session != "" {
						c.session = session
					}
				}
				if len(bytes.TrimSpace(data)) == 0 || bytes.TrimSpace(data)[0] != '{' || json.Unmarshal(data, out) != nil {
					return errors.New("Cannot read the competition API at " + c.baseURL + "; check LUCID_API_BASE_URL.")
				}
				return nil
			} else {
				var envelope struct {
					Error struct{ Code, Message string }
				}
				_ = json.Unmarshal(data, &envelope)
				failure := &apiError{response.StatusCode, envelope.Error.Code, envelope.Error.Message}
				if failure.code == "bot_session_missing" && !c.recovering && c.matchID != "" && c.session != "" && path != currentPath+"/register" {
					c.recovering = true
					defer func() { c.recovering = false }()
					var registration GameState
					if err := c.request("POST", currentPath+"/register", nil, true, &registration); err != nil {
						return err
					}
					return c.request(method, path, body, retry, out)
				}
				transient := response.StatusCode == 408 || response.StatusCode == 429 || response.StatusCode == 500 || response.StatusCode == 502 || response.StatusCode == 503 || response.StatusCode == 504
				if !transient || attempt+1 == attempts {
					return failure
				}
			}
		}
		time.Sleep(time.Duration(250<<attempt) * time.Millisecond)
	}
	return errors.New("Request failed")
}
func terminal(s *GameState) bool {
	return s.Match.Status == "completed" || s.Match.Status == "cancelled"
}
func safelyCalculate(strategy Strategy, state *GameState, identity *Identity) (actions Actions) {
	defer func() {
		if recover() != nil {
			fmt.Fprintf(os.Stderr, "Strategy failed on turn %d; submitting no actions.\n", state.Match.Turn)
			actions = Actions{}
		}
	}()
	return strategy(state, identity)
}
func (c *BotClient) Run(rulesVersion string, strategy Strategy) error {
	defer c.http.CloseIdleConnections()
	var identity Identity
	if err := c.request("POST", "/api/auth/api-key", nil, true, &identity); err != nil {
		return err
	}
	var state GameState
	if err := c.request("GET", currentPath+"/state", nil, true, &state); err != nil {
		var api *apiError
		if !errors.As(err, &api) || !api.inactive() {
			return err
		}
		announced := ""
		for {
			var enrollment map[string]json.RawMessage
			if err := c.request("POST", currentPath+"/register", nil, false, &enrollment); err != nil {
				return err
			}
			if _, ok := enrollment["match"]; ok {
				if err := c.request("GET", currentPath+"/state", nil, true, &state); err != nil {
					return err
				}
				break
			}
			if _, ok := enrollment["queue"]; !ok {
				return errors.New("Unexpected registration response.")
			}
			var queue struct {
				AutoQueue *bool `json:"autoQueue"`
			}
			if err := json.Unmarshal(enrollment["queue"], &queue); err != nil {
				return err
			}
			waiting := "Waiting in the PvP practice queue; keep your bot running."
			if queue.AutoQueue != nil && !*queue.AutoQueue {
				waiting = "Waiting for a private practice match. Create one on the website; keep your bot running."
			}
			if announced != waiting {
				fmt.Println(waiting)
				announced = waiting
			}
			time.Sleep(time.Second)
		}
	}
	c.matchID = state.Match.MatchID
	if c.matchID == "" {
		return errors.New("Missing match ID.")
	}
	var err error
	c.session, err = token()
	if err != nil {
		return err
	}
	if err = c.request("POST", currentPath+"/register", nil, true, &state); err != nil {
		return err
	}
	if state.Match.RulesVersion != rulesVersion {
		return errors.New("This starter does not support the current game. Download its starter from Help.")
	}
	fmt.Println("registered for match", c.matchID)
	path := "/api/team/matches/" + url.PathEscape(c.matchID)
	revisions := state.ActionRevisions
	if revisions == nil {
		revisions = map[string]uint64{}
	}
	for _, revision := range revisions {
		if revision > maxRevision {
			return errors.New("Invalid action revision")
		}
	}
	runID := c.session
	submittedTurn := -1
	receivedAt := time.Now()
	for !terminal(&state) {
		turn := state.Match.Turn
		if state.Match.Status == "active" && state.View != nil && submittedTurn != turn {
			actions := safelyCalculate(strategy, &state, &identity)
			submissions := make([]map[string]any, 0, len(actions))
			for entity, action := range actions {
				if revisions[entity] >= maxRevision {
					return errors.New("Action revision exceeds the server's safe integer range")
				}
				revisions[entity]++
				submissions = append(submissions, map[string]any{"entityId": entity, "revision": revisions[entity], "action": action})
			}
			body, err := json.Marshal(map[string]any{"submissionId": fmt.Sprintf("%s-%d", runID, turn), "submissions": submissions})
			if err != nil {
				return err
			}
			var receipt struct {
				Accepted []struct {
					AcceptedForTurn int `json:"acceptedForTurn"`
				} `json:"accepted"`
			}
			err = c.request("PUT", path+"/action", body, true, &receipt)
			if err != nil {
				ended, fatal := c.submissionFailure(err, path)
				if fatal != nil {
					return fatal
				}
				if ended {
					break
				}
				fmt.Fprintf(os.Stderr, "Could not submit turn %d: %v; continuing.\n", turn, err)
				empty, _ := json.Marshal(map[string]any{"submissionId": fmt.Sprintf("%s-%d-empty", runID, turn), "submissions": []any{}})
				if err = c.request("PUT", path+"/action", empty, true, &receipt); err != nil {
					ended, fatal = c.submissionFailure(err, path)
					if fatal != nil {
						return fatal
					}
					if ended {
						break
					}
					fmt.Fprintln(os.Stderr, "Could not submit empty actions:", err)
				}
			} else {
				late := state.TurnEndsAtMs != nil && state.ServerTimeMs+time.Since(receivedAt).Milliseconds() >= *state.TurnEndsAtMs
				for _, action := range receipt.Accepted {
					late = late || action.AcceptedForTurn > turn+1
				}
				if late {
					fmt.Fprintf(os.Stderr, "Turn %d actions were late; use a faster strategy. Fetching the newest state.\n", turn)
				}
			}
			submittedTurn = turn
			var latest GameState
			if err := c.request("GET", path+"/state", nil, true, &latest); err == nil {
				state = latest
				receivedAt = time.Now()
				if state.Match.Turn != turn {
					continue
				}
			} else if fatalError(err) {
				return err
			} else {
				fmt.Fprintln(os.Stderr, err)
			}
		}
		delay := 100 * time.Millisecond
		if state.Match.TurnAdvanceMode == "timed" && state.TurnEndsAtMs != nil {
			remaining := time.Duration(*state.TurnEndsAtMs-state.ServerTimeMs)*time.Millisecond - time.Since(receivedAt)
			if remaining > 0 {
				delay = remaining - 25*time.Millisecond
				if delay < 25*time.Millisecond {
					delay = 25 * time.Millisecond
				}
				if delay > time.Second {
					delay = time.Second
				}
			}
		}
		time.Sleep(delay)
		var latest GameState
		if err := c.request("GET", path+"/state", nil, true, &latest); err == nil {
			state = latest
			receivedAt = time.Now()
		} else if fatalError(err) {
			return err
		} else {
			fmt.Fprintln(os.Stderr, err)
		}
	}
	if state.Match.CancellationReason != "" {
		fmt.Println(state.Match.CancellationReason)
	} else {
		fmt.Printf("Match %s ended.\n", c.matchID)
	}
	return nil
}
func fatalError(err error) bool { var api *apiError; return errors.As(err, &api) && api.fatal() }
func (c *BotClient) submissionFailure(err error, path string) (bool, error) {
	var api *apiError
	if !errors.As(err, &api) || !api.fatal() {
		return false, nil
	}
	if !api.inactive() {
		return false, err
	}
	var latest GameState
	if err := c.request("GET", path+"/state", nil, true, &latest); err != nil {
		if fatalError(err) {
			return false, err
		}
		return false, nil
	}
	return terminal(&latest), nil
}
