package mindustry.entities.comp;

import arc.*;
import arc.graphics.*;
import arc.graphics.g2d.*;
import arc.math.*;
import arc.math.geom.*;
import arc.scene.ui.layout.*;
import arc.struct.*;
import arc.util.*;
import arc.util.pooling.*;
import mindustry.*;
import mindustry.ai.*;
import mindustry.ai.types.*;
import mindustry.annotations.Annotations.*;
import mindustry.content.*;
import mindustry.entities.units.*;
import mindustry.game.EventType.*;
import mindustry.game.*;
import mindustry.gen.*;
import mindustry.graphics.*;
import mindustry.input.InputHandler.*;
import mindustry.net.Administration.*;
import mindustry.net.*;
import mindustry.net.Packets.*;
import mindustry.ui.*;
import mindustry.world.*;
import mindustry.world.blocks.storage.*;
import mindustry.world.blocks.storage.CoreBlock.*;

import static mindustry.Vars.*;

@EntityDef(value = {Playerc.class}, serialize = false)
@Component(base = true)
abstract class PlayerComp implements UnitController, Entityc, Syncc, Timerc, Drawc{
    static final float deathDelay = 60f;
    static final float pingDuration = 20f * 60f;

    @Import float x, y;

    @ReadOnly @Nullable Unit unit;
    transient @Nullable NetConnection con;
    @ReadOnly Team team = Team.sharded;
    @SyncLocal boolean typing, shooting, boosting;
    @SyncLocal @Nullable Block selectedBlock;
    @SyncLocal int selectedRotation;
    @SyncLocal float mouseX, mouseY;
    /** command the unit had before it was controlled. */
    @Nullable @NoSync UnitCommand lastCommand;
    boolean admin;
    String name = "frog";
    Color color = new Color();

    transient String locale = "en";
    transient float deathTimer;
    transient String lastText = "";
    transient float textFadeTime;
    transient Ratekeeper itemDepositRate = new Ratekeeper();
    transient float pingX, pingY, pingTime;
    transient @Nullable String pingText;

    transient private @Nullable Unit lastReadUnit;
    transient private int wrongReadUnits;
    transient @Nullable Unit justSwitchFrom, justSwitchTo;

    transient int lastPreviewPlanGroup = -1, lastPreviewPlanGroupServer = -1;
    transient long lastPreviewPlanTimestamp;
    transient boolean receivingNewPlanGroup;
    transient Seq<BuildPlan> previewPlansCurrent = new Seq<>(BuildPlan.class);
    transient Seq<BuildPlan> previewPlansAssembling = new Seq<>(BuildPlan.class);
    transient @Nullable QuadTree<BuildPlan> previewPlanTree;
    transient @Nullable QueryEachable planEachable;
    transient boolean previewPlansDirty;

    public Seq<BuildPlan> getPreviewPlans(){
        long timeToCommit = 100; //ms needed after first plan is received to "commit" the plans.
        if(Time.timeSinceMillis(lastPreviewPlanTimestamp) >= timeToCommit && receivingNewPlanGroup){
            receivingNewPlanGroup = false;
            previewPlansDirty = true;
            previewPlansCurrent.set(previewPlansAssembling);
            previewPlansAssembling.clear();
        }

        return previewPlansCurrent;
    }

    public void handlePreviewPlans(int groupId, Seq<BuildPlan> plans){
        if(groupId > lastPreviewPlanGroup){ //new group received, prepare to add plans for this group
            previewPlansAssembling.clear();
            lastPreviewPlanGroup = groupId;
            receivingNewPlanGroup = true;
            lastPreviewPlanTimestamp = Time.millis();
        }else if(groupId < lastPreviewPlanGroup){ //packet is outdated, likely sent out of order
            return;
        }else if(!receivingNewPlanGroup){ //the window has closed, no more plans will be received
            return;
        }

        if(plans == null) return;

        int added = Math.min(plans.size, maxPlayerPreviewPlans - previewPlansAssembling.size);
        if(added > 0){
            previewPlansAssembling.addAll(plans, 0, added);
        }
    }

    public boolean isBuilder(){
        return unit != null && unit.canBuild();
    }

    public @Nullable CoreBuild closestCore(){
        return state.teams.closestCore(x, y, team);
    }

    public @Nullable CoreBuild core(){
        return team.core();
    }

    /** @return largest/closest core, with the largest cores getting priority */
    @Nullable
    public CoreBuild bestCore(){
        var cores = team.cores();
        //if someone screws up the map and adds an invalid core, prioritize the core that's supported
        //if there's only one core, there are no other options
        return cores.min(b -> cores.size == 1 || ((CoreBlock)b.block).unitType.supportsEnv(state.rules.env), Structs.comps(Structs.comparingInt(c -> -c.block.size), Structs.comparingFloat(c -> c.dst2(x, y))));
    }

    public TextureRegion icon(){
        //display default icon for dead players
        if(dead()){
            if(core() == null){
                return UnitTypes.alpha.uiIcon;
            }
            var bestCore = (CoreBuild)bestCore();
            if(bestCore == null){
                return UnitTypes.alpha.uiIcon;
            }
            return ((CoreBlock)bestCore.block).unitType.uiIcon;
        }

        return unit.icon();
    }

    public boolean displayAmmo(){
        return unit instanceof BlockUnitc;
    }

    public void reset(){
        team = state.rules.defaultTeam;
        admin = typing = false;
        textFadeTime = 0f;
        x = y = 0f;
        lastPreviewPlanTimestamp = 0;
        lastPreviewPlanGroup = -1;
        lastPreviewPlanGroupServer = -1;
        previewPlanTree = null;
        planEachable = null;
        previewPlansCurrent.clear();
        previewPlansAssembling.clear();
        receivingNewPlanGroup = false;
        previewPlansDirty = false;
        if(!dead()){
            unit.resetController();
            unit = null;
        }
    }

    @Override
    public boolean isValidController(){
        return isAdded();
    }

    @Override
    public boolean isLogicControllable(){
        return false;
    }

    @Replace
    public float clipSize(){
        return Float.MAX_VALUE;
    }

    @Override
    public void afterSync(){
        //fix rubberbanding:
        //when the player recs a unit that they JUST transitioned away from, use the new unit instead
        //reason: we know the server is lying here, essentially skip the unit snapshot because we know the client's information is more recent
        if(isLocal() && unit == justSwitchFrom && justSwitchFrom != null && justSwitchTo != null){
            unit = justSwitchTo;
            //if several snapshots have passed and this unit is still incorrect, something's wrong
            if(++wrongReadUnits >= 2){
                justSwitchFrom = null;
                wrongReadUnits = 0;
            }
        }else{
            justSwitchFrom = null;
            justSwitchTo = null;
            wrongReadUnits = 0;
        }

        //simulate a unit change after sync
        Unit set = unit;
        unit = lastReadUnit;
        unit(set);
        lastReadUnit = unit;
        if(unit != null){
            unit.aim(mouseX, mouseY);
            //this is only necessary when the thing being controlled isn't synced
            unit.controlWeapons(shooting, shooting);
            //extra precaution, necessary for non-synced things
            unit.controller(this);
        }
    }

    @Override
    public void update(){
        if(unit != null && !unit.isValid()){
            clearUnit();
        }

        CoreBuild core;

        if(!dead()){
            set(unit);
            unit.team(team);
            deathTimer = 0;

            //update some basic state to sync things
            if(unit.type.canBoost){
                boolean shouldBoost = unit.onSolid() || boosting || (unit.isFlying() && !unit.canLand());
                unit.elevation = Mathf.approachDelta(unit.elevation, shouldBoost ? 1f : 0f, shouldBoost ? unit.type.riseSpeed : unit.type.descentSpeed);
            }
        }else if((core = bestCore()) != null){
            //have a small delay before death to prevent the camera from jumping around too quickly
            //(this is not for balance, it just looks better this way)
            deathTimer += Time.delta;
            if(deathTimer >= deathDelay){
                //request spawn - this happens serverside only
                core.requestSpawn(self());
                deathTimer = 0;
            }
        }

        textFadeTime -= Time.delta / (60 * 5);

    }

    public void checkSpawn(){
        CoreBuild core = bestCore();
        if(core != null){
            core.requestSpawn(self());
        }
    }

    @Override
    public void remove(){
        //clear unit upon removal
        if(unit != null){
            clearUnit();
        }

        lastReadUnit = null;
        justSwitchTo = justSwitchFrom = null;
    }

    public void team(Team team){
        this.team = team;
        if(unit != null){
            unit.team(team);
        }
    }

    public void clearUnit(){
        unit(null);
    }

    public @Nullable Unit unit(){
        return unit;
    }

    public void unit(@Nullable Unit unit){
        //refuse to switch when the unit was just transitioned from
        if(isLocal() && unit == justSwitchFrom && justSwitchFrom != null && justSwitchTo != null){
            return;
        }

        if(this.unit == unit) return;

        //save last command this unit had
        if(unit != null && unit.controller() instanceof CommandAI ai){
            lastCommand = ai.command;
        }

        if(this.unit != null){
            //un-control the old unit
            this.unit.resetController();
            //restore last command issued before it was controlled
            if(lastCommand != null && this.unit.controller() instanceof CommandAI ai){
                ai.command(lastCommand);
            }
        }
        this.unit = unit;
        if(unit != null){
            unit.team(team);
            unit.controller(this);

            //this player just became remote, snap the interpolation so it doesn't go wild
            if(unit.isRemote() && !net.client()){
                unit.snapInterpolation();
            }

            //reset selected block when switching units
            if(!headless && isLocal()){
                control.input.block = null;
            }
        }

        Events.fire(new UnitChangeEvent(self(), unit));
    }

    boolean dead(){
        return unit == null || !unit.isValid();
    }

    String ip(){
        return con == null ? "localhost" : con.address;
    }

    String uuid(){
        return con == null ? "[LOCAL]" : con.uuid;
    }

    String usid(){
        return con == null ? "[LOCAL]" : con.usid;
    }

    void kick(KickReason reason){
        con.kick(reason);
    }

    void kick(KickReason reason, long duration){
        con.kick(reason, duration);
    }

    void kick(String reason){
        con.kick(reason);
    }

    void kick(String reason, long duration){
        con.kick(reason, duration);
    }

    @Override
    public void draw(){
        drawPing();
        drawName();
    }

    public boolean isPinging(){
        return pingTime > 0f;
    }

    void drawPing(){
        if(pingTime <= 0f || !renderer.showPings || name == null || (!state.rules.showOtherTeamPings && team != Vars.player.team())) return;

        float alpha = Math.min(Interp.pow5Out.apply(Mathf.clamp(Mathf.map(pingTime, 1f / 20f, 0f, 1f, 0f))), Interp.pow5Out.apply(Mathf.map(pingTime, 1f, 0.98f, 0f, 1f)));

        Tmp.c1.set(color).a(alpha);

        pingTime -= Time.delta / pingDuration;

        float s = Scl.scl(4) / renderer.getDisplayScale();

        Draw.z(Layer.playerName);
        float z = Drawf.text();
        float hover = Mathf.absin(5f, 1f);
        float scaling = 1f + Mathf.clamp(Interp.pow5In.apply(Mathf.map(pingTime, 1f, 0.96f, 1f, 0f))) * 3f;

        Drawf.square(pingX, pingY, 2f * scaling * s, 45f, Tmp.c1, Tmp.c3.set(Color.darkGray).mul(color).a(Tmp.c1.a), s);
        Drawf.fillPoly(pingX, pingY + 9f * s + hover * s, 3, 3f * s, -90f, Tmp.c1, Tmp.c3, s);

        if(pingText != null){
            Drawf.text(name, pingX, pingY + (20f + hover)*s, Tmp.c1, 0.7f * s);
            Drawf.text(pingText, pingX, pingY + (16f + hover)*s, Tmp.c2.set(1f, 1f, 1f, Tmp.c1.a), s);
        }else{
            Drawf.text(name, pingX, pingY + (16f + hover)*s, Tmp.c1, s);
        }

        Draw.reset();
        Draw.z(z);
    }

    void drawName(){
        //check clipping for name
        if(unit == null || name == null) return;

        float clip = unit.type.hitSize * 2f;
        if(!Core.camera.bounds(Tmp.r1).overlaps(x - clip/2f, y - clip/2f, clip, clip)) return;

        if(name == null || unit.inFogTo(Vars.player.team())) return;

        Draw.z(Layer.playerName);
        float z = Drawf.text();

        Font font = Fonts.outline;
        GlyphLayout layout = Pools.obtain(GlyphLayout.class, GlyphLayout::new);
        final float nameHeight = 11;
        final float textHeight = 15;

        boolean ints = font.usesIntegerPositions();
        font.setUseIntegerPositions(false);
        font.getData().setScale(0.25f / Scl.scl(1f));
        layout.setText(font, name);

        if(!isLocal()){
            Draw.color(0f, 0f, 0f, 0.3f);
            Fill.rect(unit.x, unit.y + nameHeight - layout.height / 2, layout.width + 2, layout.height + 3);
            Draw.color();
            font.setColor(color);
            font.draw(name, unit.x, unit.y + nameHeight, 0, Align.center, false);

            if(admin){
                float s = 3f;
                Draw.color(color.r * 0.5f, color.g * 0.5f, color.b * 0.5f, 1f);
                Draw.rect(Icon.adminSmall.getRegion(), unit.x + layout.width / 2f + 2 + 1, unit.y + nameHeight - 1.5f, s, s);
                Draw.color(color);
                Draw.rect(Icon.adminSmall.getRegion(), unit.x + layout.width / 2f + 2 + 1, unit.y + nameHeight - 1f, s, s);
            }
        }

        if(Core.settings.getBool("playerchat") && ((textFadeTime > 0 && lastText != null) || typing)){
            String text = textFadeTime <= 0 || lastText == null ? "[lightgray]" + Strings.animated(Time.time, 4, 15f, ".") : lastText;
            float width = 100f;
            float visualFadeTime = 1f - Mathf.curve(1f - textFadeTime, 0.9f);
            font.setColor(1f, 1f, 1f, textFadeTime <= 0 || lastText == null ? 1f : visualFadeTime);

            layout.setText(font, text, Color.white, width, Align.bottom, true);

            Draw.color(0f, 0f, 0f, 0.3f * (textFadeTime <= 0 || lastText == null ? 1f : visualFadeTime));
            Fill.rect(unit.x, unit.y + textHeight + layout.height - layout.height / 2f, layout.width + 2, layout.height + 3);
            font.draw(text, unit.x - width / 2f, unit.y + textHeight + layout.height, width, Align.center, true);
        }

        Draw.reset();
        Pools.free(layout);
        font.getData().setScale(1f);
        font.setColor(Color.white);
        font.setUseIntegerPositions(ints);

        Draw.z(z);
    }

    /** @return name with a markup color prefix */
    String coloredName(){
        return  "[#" + color.toString().toUpperCase() + "]" + name;
    }

    String plainName(){
        return Strings.stripColors(name);
    }

    void sendMessage(String text){
        sendMessage(text, null, null);
    }

    void sendMessage(String text, Player from){
        sendMessage(text, from, null);
    }

    void sendMessage(String text, Player from, String unformatted){
        if(isLocal()){
            if(ui != null){
                ui.chatfrag.addMessage(text);
            }
        }else{
            Call.sendMessage(con, text, unformatted, from);
        }
    }

    void sendUnformatted(String unformatted){
        sendUnformatted(null, unformatted);
    }

    void sendUnformatted(Player from, String unformatted){
        sendMessage(netServer.chatFormatter.format(from, unformatted), from, unformatted);
    }

    PlayerInfo getInfo(){
        if(isLocal()){
            throw new IllegalArgumentException("Local players cannot be traced and do not have info.");
        }else{
            return netServer.admins.getInfo(uuid());
        }
    }
}
