// Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
//
// SPDX-License-Identifier: LicenseRef-CCPL

package dan200.computercraft.shared.peripheral.monitor;

import com.google.common.annotations.VisibleForTesting;
import dan200.computercraft.api.peripheral.AttachedComputerSet;
import dan200.computercraft.api.peripheral.IComputerAccess;
import dan200.computercraft.api.peripheral.IPeripheral;
import dan200.computercraft.core.terminal.Terminal;
import dan200.computercraft.shared.computer.terminal.TerminalState;
import dan200.computercraft.shared.config.Config;
import dan200.computercraft.shared.util.BlockEntityHelpers;
import dan200.computercraft.shared.util.TickScheduler;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.world.phys.AABB;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Objects;
import java.util.function.Consumer;

public class MonitorBlockEntity extends BlockEntity {
    private static final Logger LOG = LoggerFactory.getLogger(MonitorBlockEntity.class);

    public static final double RENDER_BORDER = 2.0 / 16.0;
    public static final double RENDER_MARGIN = 0.5 / 16.0;
    public static final double RENDER_PIXEL_SCALE = 1.0 / 64.0;

    private static final String NBT_X = "XIndex";
    private static final String NBT_Y = "YIndex";
    private static final String NBT_WIDTH = "Width";
    private static final String NBT_HEIGHT = "Height";

    private final boolean advanced;

    private @Nullable ServerMonitor serverMonitor;

    /**
     * The monitor's state on the client. This is defined iff we're the origin monitor
     * ({@code xIndex == 0 && yIndex == 0}).
     */
    private @Nullable ClientMonitor clientMonitor;

    private @Nullable MonitorPeripheral peripheral;
    private final AttachedComputerSet computers = new AttachedComputerSet();

    private boolean needsUpdate = false;
    private boolean needsValidating = false;

    /**
     * Whether this monitor is in the process of being removed (see {@link #preRemoveSideEffects(BlockPos, BlockState)},
     * and so should be ignored.
     */
    private boolean isRemoving = false;

    // MonitorWatcher state.
    boolean enqueued;
    @Nullable
    TerminalState cached;

    private int width = 1;
    private int height = 1;
    private int xIndex = 0;
    private int yIndex = 0;

    private @Nullable BlockPos bbPos;
    private @Nullable BlockState bbState;
    private int bbX, bbY, bbWidth, bbHeight;
    private @Nullable AABB boundingBox;

    TickScheduler.Token tickToken = new TickScheduler.Token(this);

    public MonitorBlockEntity(BlockEntityType<? extends MonitorBlockEntity> type, BlockPos pos, BlockState state, boolean advanced) {
        super(type, pos, state);
        this.advanced = advanced;
    }

    @Override
    public void clearRemoved() {
        super.clearRemoved();
        isRemoving = false;
        needsValidating = true; // Same, tbh
        TickScheduler.schedule(tickToken);
    }

    @Override
    public void preRemoveSideEffects(BlockPos blockPos, BlockState blockState) {
        super.preRemoveSideEffects(blockPos, blockState);
        isRemoving = true;
        if (level != null && !getLevel().isClientSide()) contractNeighbours();
    }

    @Override
    public void setRemoved() {
        super.setRemoved();
        if (clientMonitor != null) clientMonitor.destroy();
    }

    @Override
    public void saveAdditional(ValueOutput tag) {
        tag.putInt(NBT_X, xIndex);
        tag.putInt(NBT_Y, yIndex);
        tag.putInt(NBT_WIDTH, width);
        tag.putInt(NBT_HEIGHT, height);
        super.saveAdditional(tag);
    }

    @Override
    public void loadAdditional(ValueInput nbt) {
        super.loadAdditional(nbt);

        var oldXIndex = xIndex;
        var oldYIndex = yIndex;

        xIndex = nbt.getIntOr(NBT_X, 0);
        yIndex = nbt.getIntOr(NBT_Y, 0);
        width = nbt.getIntOr(NBT_WIDTH, 1);
        height = nbt.getIntOr(NBT_HEIGHT, 1);

        if (level != null && level.isClientSide()) onClientLoad(oldXIndex, oldYIndex);
    }

    void blockTick() {
        if (needsValidating) {
            needsValidating = false;
            validate();
        }

        if (needsUpdate) {
            needsUpdate = false;
            expand();
        }

        if (xIndex != 0 || yIndex != 0 || serverMonitor == null) return;

        if (serverMonitor.pollResized()) eachComputer(c -> c.queueEvent("monitor_resize", c.getAttachmentName()));
        if (serverMonitor.pollTerminalChanged()) MonitorWatcher.enqueue(this);
    }

    @Nullable
    @VisibleForTesting
    public ServerMonitor getCachedServerMonitor() {
        return serverMonitor;
    }

    @Nullable
    private ServerMonitor getServerMonitor() {
        if (serverMonitor != null) return serverMonitor;

        var origin = getOrigin();
        if (origin == null) return null;

        return serverMonitor = origin.serverMonitor;
    }

    @Nullable
    private ServerMonitor createServerMonitor() {
        if (serverMonitor != null) return serverMonitor;

        if (xIndex == 0 && yIndex == 0) {
            // If we're the origin, set up the new monitor
            serverMonitor = new ServerMonitor(advanced, this);

            // And propagate it to child monitors
            for (var x = 0; x < width; x++) {
                for (var y = 0; y < height; y++) {
                    var monitor = getLoadedMonitor(x, y).getMonitor();
                    if (monitor != null) monitor.serverMonitor = serverMonitor;
                }
            }

            return serverMonitor;
        } else {
            // Otherwise fetch the origin and attempt to get its monitor
            // Note this may load chunks, but we don't really have a choice here.
            var te = getLevel().getBlockEntity(toWorldPos(0, 0));
            if (!(te instanceof MonitorBlockEntity monitor)) return null;

            return serverMonitor = monitor.createServerMonitor();
        }
    }

    private void createServerTerminal() {
        var monitor = createServerMonitor();
        if (monitor != null && monitor.getTerminal() == null) monitor.rebuild();
    }

    @Nullable
    public ClientMonitor getOriginClientMonitor() {
        if (clientMonitor != null) return clientMonitor;

        var origin = getOrigin();
        return origin == null ? null : origin.clientMonitor;
    }

    // Networking stuff

    @Override
    public final ClientboundBlockEntityDataPacket getUpdatePacket() {
        return ClientboundBlockEntityDataPacket.create(this);
    }

    @Override
    public final CompoundTag getUpdateTag(HolderLookup.Provider registries) {
        var nbt = super.getUpdateTag(registries);
        nbt.putInt(NBT_X, xIndex);
        nbt.putInt(NBT_Y, yIndex);
        nbt.putInt(NBT_WIDTH, width);
        nbt.putInt(NBT_HEIGHT, height);
        return nbt;
    }

    private void onClientLoad(int oldXIndex, int oldYIndex) {
        if ((oldXIndex != xIndex || oldYIndex != yIndex) && clientMonitor != null) {
            // If our index has changed, and we were the origin, then destroy the current monitor.
            clientMonitor.destroy();
            clientMonitor = null;
        }

        // If we're the origin terminal then create it.
        if (xIndex == 0 && yIndex == 0 && clientMonitor == null) clientMonitor = new ClientMonitor(this);
    }

    public final void read(@Nullable TerminalState state) {
        if (xIndex != 0 || yIndex != 0) {
            LOG.warn("Receiving monitor state for non-origin terminal at {}", getBlockPos());
            return;
        }

        if (clientMonitor == null) clientMonitor = new ClientMonitor(this);
        clientMonitor.read(state);
    }

    // Sizing and placement stuff

    private void updateBlockState() {
        getLevel().setBlock(getBlockPos(), getBlockState()
            .setValue(MonitorBlock.STATE, MonitorEdgeState.fromConnections(
                yIndex < height - 1, yIndex > 0,
                xIndex > 0, xIndex < width - 1)), Block.UPDATE_CLIENTS);
    }

    // region Sizing and placement stuff
    public Direction getDirection() {
        // Ensure we're actually a monitor block. This _should_ always be the case, but sometimes there's
        // fun problems with the block being missing on the client.
        return getBlockState().getValueOrElse(MonitorBlock.FACING, Direction.NORTH);
    }

    public Direction getOrientation() {
        return getBlockState().getValueOrElse(MonitorBlock.ORIENTATION, Direction.NORTH);
    }

    public Direction getFront() {
        var orientation = getOrientation();
        return orientation == Direction.NORTH ? getDirection() : orientation;
    }

    public Direction getRight() {
        return getDirection().getCounterClockWise();
    }

    public Direction getDown() {
        var orientation = getOrientation();
        if (orientation == Direction.NORTH) return Direction.UP;
        return orientation == Direction.DOWN ? getDirection() : getDirection().getOpposite();
    }

    public int getWidth() {
        return width;
    }

    public int getHeight() {
        return height;
    }

    public int getXIndex() {
        return xIndex;
    }

    public int getYIndex() {
        return yIndex;
    }

    boolean isCompatible(MonitorBlockEntity other) {
        return !other.isRemoved() && !other.isRemoving
            && advanced == other.advanced
            && getOrientation() == other.getOrientation()
            && getDirection() == other.getDirection();
    }

    /**
     * Get a tile within the current monitor only if it is loaded and compatible.
     *
     * @param x Absolute X position in monitor coordinates
     * @param y Absolute Y position in monitor coordinates
     * @return The located monitor
     */
    private MonitorState getLoadedMonitor(int x, int y) {
        if (x == xIndex && y == yIndex) return MonitorState.present(this);
        var pos = toWorldPos(x, y);

        var world = getLevel();
        if (world == null || !world.isLoaded(pos)) return MonitorState.UNLOADED;

        if (!(world.getBlockEntity(pos) instanceof MonitorBlockEntity monitor)) return MonitorState.MISSING;

        return isCompatible(monitor) ? MonitorState.present(monitor) : MonitorState.MISSING;
    }

    private @Nullable MonitorBlockEntity getOrigin() {
        return getLoadedMonitor(0, 0).getMonitor();
    }

    /**
     * Convert monitor coordinates to world coordinates.
     *
     * @param x Absolute X position in monitor coordinates
     * @param y Absolute Y position in monitor coordinates
     * @return The monitor's position.
     */
    BlockPos toWorldPos(int x, int y) {
        if (xIndex == x && yIndex == y) return getBlockPos();
        return getBlockPos().relative(getRight(), -xIndex + x).relative(getDown(), -yIndex + y);
    }

    void resize(int width, int height) {
        // If we're not already the origin then we'll need to generate a new terminal.
        if (xIndex != 0 || yIndex != 0) serverMonitor = null;

        xIndex = 0;
        yIndex = 0;
        this.width = width;
        this.height = height;

        // Determine if we actually need a monitor. In order to do this, simply check if
        // any component monitor been wrapped as a peripheral. Whilst this flag may be
        // out of date,
        var needsTerminal = false;
        terminalCheck:
        for (var x = 0; x < width; x++) {
            for (var y = 0; y < height; y++) {
                var monitor = getLoadedMonitor(x, y).getMonitor();
                if (monitor != null && monitor.peripheral != null) {
                    needsTerminal = true;
                    break terminalCheck;
                }
            }
        }

        // Either delete the current monitor or sync a new one.
        if (needsTerminal) {
            if (serverMonitor == null) serverMonitor = new ServerMonitor(advanced, this);

            // Update the terminal's width and height and rebuild it. This ensures the monitor
            // is consistent when syncing it to other monitors.
            serverMonitor.rebuild();
        } else {
            // Remove the terminal from the serverMonitor, but keep it around - this ensures that we sync
            // the (now blank) monitor to the client.
            if (serverMonitor != null) serverMonitor.reset();
        }

        // Update the other monitors, setting coordinates, dimensions and the server terminal
        var pos = getBlockPos();
        Direction down = getDown(), right = getRight();
        for (var x = 0; x < width; x++) {
            for (var y = 0; y < height; y++) {
                var other = getLevel().getBlockEntity(pos.relative(right, x).relative(down, y));
                if (!(other instanceof MonitorBlockEntity monitor) || !isCompatible(monitor)) continue;

                monitor.xIndex = x;
                monitor.yIndex = y;
                monitor.width = width;
                monitor.height = height;
                monitor.serverMonitor = serverMonitor;
                monitor.needsUpdate = monitor.needsValidating = false;
                monitor.updateBlockState();
                BlockEntityHelpers.updateBlock(monitor);
            }
        }

        assertInvariant();
    }

    void updateNeighborsDeferred() {
        needsUpdate = true;
    }

    void expand() {
        var monitor = getOrigin();
        if (monitor != null && monitor.xIndex == 0 && monitor.yIndex == 0) new Expander(monitor).expand();
    }

    private void contractNeighbours() {
        if (width == 1 && height == 1) return;

        var pos = getBlockPos();
        Direction down = getDown(), right = getRight();
        var origin = toWorldPos(0, 0);

        MonitorBlockEntity toLeft = null, toAbove = null, toRight = null, toBelow = null;
        if (xIndex > 0) toLeft = tryResizeAt(pos.relative(right, -xIndex), xIndex, 1);
        if (yIndex > 0) toAbove = tryResizeAt(origin, width, yIndex);
        if (xIndex < width - 1) toRight = tryResizeAt(pos.relative(right, 1), width - xIndex - 1, 1);
        if (yIndex < height - 1) {
            toBelow = tryResizeAt(origin.relative(down, yIndex + 1), width, height - yIndex - 1);
        }

        if (toLeft != null) toLeft.expand();
        if (toAbove != null) toAbove.expand();
        if (toRight != null) toRight.expand();
        if (toBelow != null) toBelow.expand();
    }

    @Nullable
    private MonitorBlockEntity tryResizeAt(BlockPos pos, int width, int height) {
        var tile = getLevel().getBlockEntity(pos);
        if (tile instanceof MonitorBlockEntity monitor && isCompatible(monitor)) {
            monitor.resize(width, height);
            return monitor;
        }

        return null;
    }


    private boolean checkMonitorAt(int xIndex, int yIndex) {
        var state = getLoadedMonitor(xIndex, yIndex);
        if (state.isMissing()) return false;

        var monitor = state.getMonitor();
        if (monitor == null) return true;

        return monitor.xIndex == xIndex && monitor.yIndex == yIndex && monitor.width == width && monitor.height == height;
    }

    private void validate() {
        if (xIndex == 0 && yIndex == 0 && width == 1 && height == 1) return;

        if (xIndex >= 0 && xIndex <= width && width > 0 && width <= Config.monitorWidth &&
            yIndex >= 0 && yIndex <= height && height > 0 && height <= Config.monitorHeight &&
            checkMonitorAt(0, 0) && checkMonitorAt(0, height - 1) &&
            checkMonitorAt(width - 1, 0) && checkMonitorAt(width - 1, height - 1)) {
            return;
        }

        // Something in our monitor is invalid. For now, let's just reset ourselves and then try to integrate ourselves
        // later.
        LOG.warn("Monitor is malformed, resetting to 1x1.");
        resize(1, 1);
        needsUpdate = true;
    }
    // endregion

    void monitorTouched(float xPos, float yPos, float zPos) {
        if (!advanced) return;

        var pair = XYPair
            .of(xPos, yPos, zPos, getDirection(), getOrientation())
            .add(xIndex, height - yIndex - 1);

        if (pair.x() > width - RENDER_BORDER || pair.y() > height - RENDER_BORDER || pair.x() < RENDER_BORDER || pair.y() < RENDER_BORDER) {
            return;
        }

        var serverTerminal = getServerMonitor();
        if (serverTerminal == null) return;

        Terminal originTerminal = serverTerminal.getTerminal();
        if (originTerminal == null) return;

        var xCharWidth = (width - (RENDER_BORDER + RENDER_MARGIN) * 2.0) / originTerminal.getWidth();
        var yCharHeight = (height - (RENDER_BORDER + RENDER_MARGIN) * 2.0) / originTerminal.getHeight();

        var xCharPos = (int) Math.min(originTerminal.getWidth(), Math.max((pair.x() - RENDER_BORDER - RENDER_MARGIN) / xCharWidth + 1.0, 1.0));
        var yCharPos = (int) Math.min(originTerminal.getHeight(), Math.max((pair.y() - RENDER_BORDER - RENDER_MARGIN) / yCharHeight + 1.0, 1.0));

        eachComputer(c -> c.queueEvent("monitor_touch", c.getAttachmentName(), xCharPos, yCharPos));
    }

    private void eachComputer(Consumer<IComputerAccess> fun) {
        for (var x = 0; x < width; x++) {
            for (var y = 0; y < height; y++) {
                var monitor = getLoadedMonitor(x, y).getMonitor();
                if (monitor == null) continue;

                monitor.computers.forEach(fun);
            }
        }
    }

    public IPeripheral peripheral() {
        createServerTerminal();
        var peripheral = this.peripheral != null ? this.peripheral : (this.peripheral = new MonitorPeripheral(this));
        assertInvariant();
        return peripheral;
    }

    void addComputer(IComputerAccess computer) {
        computers.add(computer);
    }

    void removeComputer(IComputerAccess computer) {
        computers.remove(computer);
    }

    public AABB getRenderBoundingBox() {
        // We attempt to cache the bounding box to save having to do property lookups (and allocations!) on every frame.
        // Unfortunately the AABB does depend on quite a lot of state, so we need to add a bunch of extra fields -
        // ideally these'd be a single object, but I don't think worth doing until Java has value types.
        if (boundingBox != null && Objects.equals(getBlockState(), bbState) && Objects.equals(getBlockPos(), bbPos) &&
            xIndex == bbX && yIndex == bbY && width == bbWidth && height == bbHeight) {
            return boundingBox;
        }

        bbState = getBlockState();
        bbPos = getBlockPos();
        bbX = xIndex;
        bbY = yIndex;
        bbWidth = width;
        bbHeight = height;

        var startPos = toWorldPos(0, 0);
        var endPos = toWorldPos(width - 1, height - 1);
        return boundingBox = new AABB(
            Math.min(startPos.getX(), endPos.getX()),
            Math.min(startPos.getY(), endPos.getY()),
            Math.min(startPos.getZ(), endPos.getZ()),
            Math.max(startPos.getX(), endPos.getX()) + 1,
            Math.max(startPos.getY(), endPos.getY()) + 1,
            Math.max(startPos.getZ(), endPos.getZ()) + 1
        );
    }

    /**
     * Assert all {@linkplain #checkInvariants() monitor invariants} hold.
     */
    private void assertInvariant() {
        assert checkInvariants() : "Monitor invariants failed. See logs.";
    }

    /**
     * Check various invariants about this monitor multiblock. This is only called when assertions are enabled, so
     * will be skipped outside of tests.
     *
     * @return Whether all invariants passed.
     */
    private boolean checkInvariants() {
        LOG.debug("Checking monitor invariants at {}", getBlockPos());

        var okay = true;

        if (width <= 0 || height <= 0) {
            okay = false;
            LOG.error("Monitor {} has non-positive of {}x{}", getBlockPos(), width, height);
        }

        var hasPeripheral = false;
        var origin = getOrigin();
        var serverMonitor = origin != null ? origin.serverMonitor : this.serverMonitor;
        for (var x = 0; x < width; x++) {
            for (var y = 0; y < height; y++) {
                var monitor = getLoadedMonitor(x, y).getMonitor();
                if (monitor == null) continue;

                hasPeripheral |= monitor.peripheral != null;

                if (monitor.serverMonitor != null && monitor.serverMonitor != serverMonitor) {
                    okay = false;
                    LOG.error(
                        "Monitor {} expected to be have serverMonitor={}, but was {}",
                        monitor.getBlockPos(), serverMonitor, monitor.serverMonitor
                    );
                }

                if (monitor.xIndex != x || monitor.yIndex != y) {
                    okay = false;
                    LOG.error(
                        "Monitor {} expected to be at {},{}, but believes it is {},{}",
                        monitor.getBlockPos(), x, y, monitor.xIndex, monitor.yIndex
                    );
                }

                if (monitor.width != width || monitor.height != height) {
                    okay = false;
                    LOG.error(
                        "Monitor {} expected to be size {},{}, but believes it is {},{}",
                        monitor.getBlockPos(), width, height, monitor.width, monitor.height
                    );
                }

                var expectedState = getBlockState().setValue(MonitorBlock.STATE, MonitorEdgeState.fromConnections(
                    y < height - 1, y > 0, x > 0, x < width - 1
                ));
                if (monitor.getBlockState() != expectedState) {
                    okay = false;
                    LOG.error(
                        "Monitor {} expected to have state {}, but has state {}",
                        monitor.getBlockState(), expectedState, monitor.getBlockState()
                    );
                }
            }
        }

        if (hasPeripheral != (serverMonitor != null && serverMonitor.getTerminal() != null)) {
            okay = false;
            LOG.error(
                "Peripheral is {}, but serverMonitor={} and serverMonitor.terminal={}",
                hasPeripheral, serverMonitor, serverMonitor == null ? null : serverMonitor.getTerminal()
            );
        }

        return okay;
    }
}
