import com.google.gson.*;
import com.google.gson.annotations.SerializedName;
import com.google.gson.reflect.TypeToken;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
import java.util.function.BiFunction;

record Identity(String teamId, String displayName) {}
record Match(String matchId, String mode, String status, String turnAdvanceMode,
                    String rulesVersion, int turn, int maxTurns, Long startedAtMs, Long completedAtMs,
                    List<String> expectedTeamIds, List<String> teamIds, List<String> activeTeamIds,
                    List<Team> teams) {}
record Team(String id, String displayName) {}
record GameState<V>(Match match, V view, long serverTimeMs, Long turnEndsAtMs,
                           Map<String, Long> actionRevisions) {}
record Coordinate(int x, int y) {}
record Base(String baseId, String teamId, Coordinate position) {}
record Inventory(long gold, long iron) {}
record Worker(String unitId, String teamId, Coordinate position, int readyOnTurn,
                     Inventory inventory, long inventoryCapacity) {}
record Formation(int x, int y, int size) {}
record Cell(String type, Long amount, Formation formation, String unitId, String teamId) {}
interface CoordinateGrid {
    int width();
    int height();
    String terrain(Coordinate point);
    default long amount(Coordinate point) { return 0; }
}
record Grid(int width, int height, List<Cell> cells) implements CoordinateGrid {
    public Cell cell(Coordinate p) { return cells.get(p.y() * width + p.x()); }
    public String terrain(Coordinate p) { return cell(p).type(); }
    public long amount(Coordinate p) { return cell(p).amount() == null ? 0 : cell(p).amount(); }
}
record HighGoldArea(int width, int height, long min, long max, int spawnPercent) {}
record Notice(String entityId, String error, String note) {}
record Effect(String type, Coordinate coordinate, List<String> entityIds, String note,
                     String baseId, String resource, Long amount, Long total) {}
record MarsView(int turn, Grid map, List<Worker> units, List<Base> bases,
                       Map<String, Long> scores, Map<String, Inventory> teamResources,
                       Map<String, Inventory> baseResourceDeposits, HighGoldArea highGoldArea,
                       List<Notice> warnings, List<Notice> info, List<Effect> effects)
        implements CoordinateGrid {
    public int width() { return map.width(); }
    public int height() { return map.height(); }
    public String terrain(Coordinate p) { return map.terrain(p); }
    public long amount(Coordinate p) { return map.amount(p); }
}
record Position(double x, double y) {}
enum Planet { Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, Neptune, Pluto }
record SolarPlanet(Planet name, Position position, double orbitRadius) {}
record Orbit(Planet planet, double angle) {}
record Rocket(String teamId, Position position, Position previousPosition, double heading,
                     Planet target, Position targetPosition, Orbit orbit) {}
record SolarView(int turn, List<SolarPlanet> planets, List<Rocket> rockets, String entityId) {}
enum Resource { @SerializedName("gold") GOLD, @SerializedName("iron") IRON }
enum Direction {
    @SerializedName("north") NORTH, @SerializedName("east") EAST,
    @SerializedName("south") SOUTH, @SerializedName("west") WEST,
    @SerializedName("north-east") NORTH_EAST, @SerializedName("south-east") SOUTH_EAST,
    @SerializedName("south-west") SOUTH_WEST, @SerializedName("north-west") NORTH_WEST
}
sealed interface Action permits MarsAction, SolarAction {}
record MarsAction(String type, Direction direction, Resource from) implements Action {
    public static MarsAction move(Direction direction) { return new MarsAction("move", Objects.requireNonNull(direction), null); }
    public static MarsAction mine() { return new MarsAction("mine", null, null); }
    public static MarsAction deposit() { return new MarsAction("deposit", null, null); }
    public static MarsAction spawn() { return new MarsAction("spawn", null, null); }
    public static MarsAction buildBase() { return new MarsAction("build-base", null, null); }
    public static MarsAction trade(Resource from) { return new MarsAction("trade", null, Objects.requireNonNull(from)); }
    public static MarsAction pass() { return new MarsAction("pass", null, null); }
}
record SolarAction(String type, Double x, Double y) implements Action {
    public static SolarAction target(Planet planet) { return new SolarAction(Objects.requireNonNull(planet).name(), null, null); }
    public static SolarAction move(double x, double y) { return new SolarAction("move", x, y); }
}

public final class BotClient {
    private static final Gson GSON = new Gson();
    private static final java.lang.reflect.Type MARS_STATE = new TypeToken<GameState<MarsView>>() {}.getType();
    private static final java.lang.reflect.Type SOLAR_STATE = new TypeToken<GameState<SolarView>>() {}.getType();
    public static GameState<MarsView> readMarsState(String json) { return GSON.fromJson(json, MARS_STATE); }
    public static GameState<SolarView> readSolarState(String json) { return GSON.fromJson(json, SOLAR_STATE); }
    private final String baseUrl;
    private final String apiKey;
    private String botSessionToken;
    private String botMatchId;
    private String botSessionRevision;
    private boolean recoveringSession;
    private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
    public BotClient() {
        this(configuredBaseUrl(), System.getenv().getOrDefault("API_KEY", "replace-with-api-key"));
    }
    private static String configuredBaseUrl() {
        var value = System.getenv("LUCID_API_BASE_URL");
        if (value != null && !value.isBlank()) return value;
        System.err.println("LUCID_API_BASE_URL is not set; using http://localhost:5173.");
        return "http://localhost:5173";
    }
    public BotClient(String baseUrl, String apiKey) {
        if (apiKey == null || apiKey.isBlank() || apiKey.equals("replace-with-api-key"))
            throw new IllegalArgumentException("Set API_KEY to your team's API key before starting the bot.");
        this.baseUrl = baseUrl.replaceAll("/$", "");
        this.apiKey = apiKey;
    }
    private JsonObject request(String method, String path, JsonObject body, boolean retry) throws Exception {
        int attempts = retry ? 3 : 1;
        boolean recoveredSession = false;
        for (int attempt = 0; ; attempt++) {
            var builder = HttpRequest.newBuilder(URI.create(baseUrl + path)).timeout(Duration.ofSeconds(5))
                .header("x-api-key", apiKey).header("content-type", "application/json")
                .method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body.toString()));
            if (botSessionToken != null) builder.header("x-bot-session", botSessionToken);
            if (path.equals("/api/team/matches/current/register") && botMatchId != null) builder.header("x-bot-match-id", botMatchId);
            if (path.equals("/api/team/matches/current/register")) {
                if (recoveringSession) builder.header("x-bot-session-recover", "1");
                else if (botSessionRevision != null) builder.header("x-bot-session-revision", botSessionRevision);
            }
            try {
                var response = http.send(builder.build(), HttpResponse.BodyHandlers.ofString());
                if (response.statusCode() >= 200 && response.statusCode() < 300) {
                    if (path.equals("/api/team/matches/current/register")) botSessionToken = response.headers().firstValue("x-bot-session").orElse(null);
                    botSessionRevision = response.headers().firstValue("x-bot-session-revision").orElse(botSessionRevision);
                    try {
                        return JsonParser.parseString(response.body()).getAsJsonObject();
                    } catch (RuntimeException error) {
                        throw new IllegalStateException("Cannot read the competition API at " + baseUrl + "; check LUCID_API_BASE_URL.", error);
                    }
                }
                if (response.statusCode() == 409 && response.body().contains("bot_session_missing") && !recoveringSession && !recoveredSession && botMatchId != null && botSessionToken != null && !path.equals("/api/team/matches/current/register")) {
                    recoveredSession = true;
                    recoveringSession = true;
                    try { request("POST", "/api/team/matches/current/register", null, true); }
                    finally { recoveringSession = false; }
                    attempt--;
                    continue;
                }
                if (!Set.of(408, 429, 500, 502, 503, 504).contains(response.statusCode()) || attempt + 1 >= attempts)
                    throw new ApiError(response.statusCode(), response.body());
            } catch (java.io.IOException error) {
                if (attempt + 1 >= attempts) throw new java.io.IOException("Cannot reach " + baseUrl + "; check the URL and your connection.", error);
            }
            Thread.sleep(250L << attempt);
        }
    }
    private static final class ApiError extends Exception {
        final int status;
        final String body;
        ApiError(int status, String body) { super(status == 409 && body.contains("pvp_disabled") ? "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." : status == 403 && body.contains("Team is not in an active match.") ? "No current match is available; join a match before starting the bot." : status == 409 && body.contains("bot_session_conflict") ? "This bot was replaced by another client; stopping." : status == 401 || status == 403 ? "API key was rejected; check API_KEY." : status == 404 ? "Cannot reach the competition API; check LUCID_API_BASE_URL." : "Server request failed (HTTP " + status + ")."); this.status = status; this.body = body; }
        boolean sessionConflict() { return status == 409 && body.contains("bot_session_conflict"); }
        boolean inactiveMatch() {
            if (status != 403) return false;
            try {
                var error = JsonParser.parseString(body).getAsJsonObject().getAsJsonObject("error");
                return error.get("code").getAsString().equals("forbidden") && error.get("message").getAsString().equals("Team is not in an active match.");
            } catch (RuntimeException ignored) { return false; }
        }
    }
    private static long checkedRevision(long revision) {
        if (revision < 0 || revision > 9_007_199_254_740_991L) throw new IllegalStateException("Action revision is outside the server's safe integer range.");
        return revision;
    }
    private static boolean terminal(JsonObject state) {
        return Set.of("completed", "cancelled").contains(state.getAsJsonObject("match").get("status").getAsString());
    }
    private boolean matchEnded(String path) throws Exception {
        try {
            return terminal(request("GET", path + "/state", null, true));
        } catch (ApiError error) {
            if (error.status == 401 || error.sessionConflict() || (error.status == 403 && !error.inactiveMatch())) throw error;
            System.err.println(error.getMessage());
        } catch (java.io.IOException error) {
            System.err.println(error.getMessage());
        }
        return false;
    }
    public void run(BiFunction<GameState<MarsView>, Identity, Map<String, MarsAction>> strategy,
                    BiFunction<GameState<SolarView>, Identity, Map<String, SolarAction>> solarStrategy) throws Exception {
        runForRules(null, strategy, solarStrategy);
    }

    public void runMars(BiFunction<GameState<MarsView>, Identity, Map<String, MarsAction>> strategy) throws Exception {
        runForRules("lpc2026", strategy, null);
    }

    public void runSolar(BiFunction<GameState<SolarView>, Identity, Map<String, SolarAction>> strategy) throws Exception {
        runForRules("SolarOrbitor-demo", null, strategy);
    }

    private void runForRules(String rulesVersion,
                    BiFunction<GameState<MarsView>, Identity, Map<String, MarsAction>> strategy,
                    BiFunction<GameState<SolarView>, Identity, Map<String, SolarAction>> solarStrategy) throws Exception {
        var identity = GSON.fromJson(request("POST", "/api/auth/api-key", null, true), Identity.class);
        JsonObject current;
        try { current = request("GET", "/api/team/matches/current/state", null, true); }
        catch (ApiError error) {
            if (!error.inactiveMatch()) throw error;
            String announced = "";
            for (;;) {
                var enrollment = request("POST", "/api/team/matches/current/register", null, false);
                if (enrollment.has("match")) { current = request("GET", "/api/team/matches/current/state", null, true); break; }
                if (!enrollment.has("queue")) throw new IllegalStateException("Unexpected registration response.");
                var queue = enrollment.getAsJsonObject("queue");
                String waiting = queue.has("autoQueue") && !queue.get("autoQueue").getAsBoolean()
                    ? "Waiting for a private practice match. Create one on the website; keep your bot running."
                    : "Waiting in the PvP practice queue; keep your bot running.";
                if (!announced.equals(waiting)) { System.out.println(waiting); announced = waiting; }
                Thread.sleep(1000);
            }
        }
        botMatchId = current.getAsJsonObject("match").get("matchId").getAsString();
        botSessionToken = UUID.randomUUID().toString();
        var state = request("POST", "/api/team/matches/current/register", null, true);
        if (rulesVersion != null && !state.getAsJsonObject("match").get("rulesVersion").getAsString().equals(rulesVersion)) {
            throw new IllegalStateException("This starter does not support the current game. Download its starter from Help.");
        }

        var matchId = state.getAsJsonObject("match").get("matchId").getAsString();
        var path = "/api/team/matches/" + URLEncoder.encode(matchId, StandardCharsets.UTF_8).replace("+", "%20");
        System.out.println("registered for match " + matchId);
        var revisions = new HashMap<String, Long>();
        if (state.has("actionRevisions")) state.getAsJsonObject("actionRevisions").entrySet().forEach(entry -> revisions.put(entry.getKey(), checkedRevision(entry.getValue().getAsLong())));
        int submittedTurn = -1;
        long stateReceivedAt = System.nanoTime();
        while (!terminal(state)) {
            var match = state.getAsJsonObject("match");
            int turn = match.get("turn").getAsInt();
            if (match.get("status").getAsString().equals("active") && state.has("view") && turn != submittedTurn) {
                Map<String, ? extends Action> actions;
                try {
                    actions = match.get("rulesVersion").getAsString().equals("SolarOrbitor-demo")
                        ? solarStrategy.apply(GSON.fromJson(state, SOLAR_STATE), identity)
                        : strategy.apply(GSON.fromJson(state, MARS_STATE), identity);
                    if (actions == null) throw new IllegalArgumentException("Strategy returned null.");
                } catch (RuntimeException error) {
                    System.err.println("Strategy failed on turn " + turn + ": " + error + "; submitting no actions.");
                    actions = Map.of();
                }
                var submissions = new JsonArray();
                for (var entry : actions.entrySet()) {
                    long revision = checkedRevision(revisions.getOrDefault(entry.getKey(), 0L) + 1);
                    revisions.put(entry.getKey(), revision);
                    var submission = new JsonObject();
                    submission.addProperty("entityId", entry.getKey()); submission.addProperty("revision", revision); submission.add("action", GSON.toJsonTree(entry.getValue()));
                    submissions.add(submission);
                }
                var body = new JsonObject();
                body.addProperty("submissionId", UUID.randomUUID().toString()); body.add("submissions", submissions);
                try {
                    var result = request("PUT", path + "/action", body, true);
                    boolean late = state.has("turnEndsAtMs") && !state.get("turnEndsAtMs").isJsonNull()
                        && state.get("serverTimeMs").getAsLong() + (System.nanoTime() - stateReceivedAt) / 1_000_000 >= state.get("turnEndsAtMs").getAsLong();
                    if (result.has("accepted")) for (var accepted : result.getAsJsonArray("accepted")) {
                        var action = accepted.getAsJsonObject();
                        if (action.has("acceptedForTurn") && action.get("acceptedForTurn").getAsInt() > turn + 1) late = true;
                    }
                    if (late) System.err.println("Turn " + turn + " actions were late; use a faster strategy. Fetching the newest state.");
                } catch (Exception error) {
                    if (error instanceof ApiError apiError && (apiError.status == 401 || apiError.status == 403 || apiError.sessionConflict())) {
                        if (!apiError.inactiveMatch()) throw error;
                        if (matchEnded(path)) break;
                    }
                    System.err.println("Could not submit turn " + turn + ": " + error.getMessage() + "; continuing.");
                    try {
                        var empty = new JsonObject();
                        empty.addProperty("submissionId", UUID.randomUUID().toString());
                        empty.add("submissions", new JsonArray());
                        request("PUT", path + "/action", empty, true);
                    } catch (ApiError retryError) {
                        if (retryError.status == 401 || retryError.status == 403 || retryError.sessionConflict()) {
                            if (!retryError.inactiveMatch()) throw retryError;
                            if (matchEnded(path)) break;
                        }
                        System.err.println("Could not submit empty actions: " + retryError.getMessage());
                    } catch (Exception retryError) {
                        System.err.println("Could not submit empty actions: " + retryError.getMessage());
                    }
                }
                submittedTurn = turn;
                try {
                    state = request("GET", path + "/state", null, true);
                    stateReceivedAt = System.nanoTime();
                    if (state.getAsJsonObject("match").get("turn").getAsInt() != turn) continue;
                } catch (Exception error) {
                    if (error instanceof ApiError api && (api.status == 401 || api.status == 403 || api.sessionConflict())) throw error;
                    System.err.println(error.getMessage());
                }
            }
            long delay = 100;
            if (match.has("turnAdvanceMode") && match.get("turnAdvanceMode").getAsString().equals("timed") && state.has("turnEndsAtMs") && !state.get("turnEndsAtMs").isJsonNull()) {
                long remaining = state.get("turnEndsAtMs").getAsLong() - state.get("serverTimeMs").getAsLong() - (System.nanoTime() - stateReceivedAt) / 1_000_000;
                delay = remaining > 0 ? Math.clamp(remaining - 25, 25, 1000) : 100;
            }
            Thread.sleep(delay);
            try {
                state = request("GET", path + "/state", null, true);
                stateReceivedAt = System.nanoTime();
            } catch (Exception error) {
                if (error instanceof ApiError api && (api.status == 401 || api.status == 403 || api.sessionConflict())) throw error;
                System.err.println(error.getMessage());
            }
        }
        System.out.println(state.getAsJsonObject("match").has("cancellationReason") ? state.getAsJsonObject("match").get("cancellationReason").getAsString() : "Match " + matchId + " ended.");
    }
}
