const express = require('express');
const app = express();
const http = require('http').createServer(app);
const io = require('socket.io')(http);
const path = require('path');
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const bcrypt = require('bcrypt');
const session = require('express-session');
const { v4: uuidv4 } = require('uuid');
const fs = require('fs');

// Configuration
const PORT = process.env.PORT || 3000;
const CHUNK_SIZE = 100;
const MAP_WIDTH = 10000;
const MAP_HEIGHT = 10000;
const SAVE_FILE = 'save.txt';
// Backups folder
const BACKUP_DIR = 'backups';
const logPackets = false;
const MAX_BATCH_PLACE = 1024;
const ip = "localhost";

const saveBackups = false;

if (!fs.existsSync(BACKUP_DIR)) {
    fs.mkdirSync(BACKUP_DIR);
}

// In-memory data stores
const grids = {};
const users = new Map();
const usernames = new Map();

// Utility Functions

/**
 * Formats the current time to 'YYYY-MM-DD HH:MM' with minute precision.
 * @returns {string} - Formatted time string.
 */
function getCurrentFormattedTime() {
    const now = new Date();
    const year = now.getFullYear();
    const month = (`0${now.getMonth() + 1}`).slice(-2);
    const day = (`0${now.getDate()}`).slice(-2);
    const hours = (`0${now.getHours()}`).slice(-2);
    const minutes = (`0${now.getMinutes()}`).slice(-2);
    return `${year}-${month}-${day} ${hours}:${minutes}`;
}

/**
 * Validates if the given x and y positions are within the map boundaries.
 * @param {number} x - X-coordinate.
 * @param {number} y - Y-coordinate.
 * @returns {boolean} - True if valid, else false.
 */
function isValidPosition(x, y) {
    return x >= 0 && x < MAP_WIDTH && y >= 0 && y < MAP_HEIGHT;
}

// Compression Functions

/**
 * Compresses time, color, and username data by associating each group with their pixel positions.
 * Format: time|color|username|x1,y1;x2,y2;...
 * @param {string} time - The time identifier (minute precision).
 * @param {string} color - The color identifier.
 * @param {string} username - The username of the user.
 * @param {Array} pixels - Array of pixel objects with x and y.
 * @returns {string} - Compressed time, color, and username data string.
 */
function compressTimeColorUserData(time, color, username, pixels) {
    // Join each pixel's position with ';' as a separator
    const positions = pixels.map(pixel => `${pixel.x},${pixel.y}`).join(';');
    return `${time}|${color}|${username}|${positions}`;
}

/**
 * Decompresses time, color, and username data into its constituent pixels.
 * @param {string} data - Compressed time, color, and username data string.
 * @returns {Object} - Object containing time, color, username, and an array of pixel positions.
 */
function decompressTimeColorUserData(data) {
    const [time, color, username, positionsData] = data.split('|');
    const pixels = positionsData.split(';').map(posStr => {
        const [x, y] = posStr.split(',').map(Number);
        return { x, y };
    });
    return { time, color, username, pixels };
}

// Save and Load functions

/**
 * Saves the current state of all grids to the SAVE_FILE in a compressed format.
 */
function saveGrids() {
    // Create a backup before saving
    if (saveBackups && fs.existsSync(SAVE_FILE)) {
        const backupFile = path.join(BACKUP_DIR, `save_backup_${Date.now()}.txt`);
        const data = fs.readFileSync(SAVE_FILE);
        fs.writeFileSync(backupFile, data);
        console.log(`Backup created: ${backupFile}`);
    }

    const compressedData = Object.entries(grids).map(([room, grid]) => {
        // Object to hold (time + color + username) grouped pixel data
        const timeColorUserMap = {};

        grid.forEach((chunk, chunkKey) => {
            const [chunkX, chunkY] = chunkKey.split(',').map(Number);
            chunk.forEach((pixelData, pixelKey) => {
                const [xOffset, yOffset] = pixelKey.split(',').map(Number);
                const x = chunkX * CHUNK_SIZE + xOffset;
                const y = chunkY * CHUNK_SIZE + yOffset;
                const { color, username, time } = pixelData;

                const key = `${time}|${color}|${username}`;
                if (!timeColorUserMap[key]) {
                    timeColorUserMap[key] = [];
                }

                timeColorUserMap[key].push({ x, y });
            });
        });

        // Compress time, color, and username data
        const timeColorUserData = Object.entries(timeColorUserMap).map(([key, pixels]) => {
            const [time, color, username] = key.split('|');
            return compressTimeColorUserData(time, color, username, pixels);
        }).join('|');

        return `${room}|${timeColorUserData}`;
    }).join('\n');

    fs.writeFileSync(SAVE_FILE, compressedData);
    console.log('Grids successfully saved.');
}

/**
 * Loads the grids from the SAVE_FILE, reconstructing the in-memory data structures.
 */
function loadGrids() {
    if (!fs.existsSync(SAVE_FILE)) return;

    try {
        const data = fs.readFileSync(SAVE_FILE, 'utf8');

        data.split('\n').forEach(roomData => {
            if (!roomData) return; // Skip empty lines
            const firstPipe = roomData.indexOf('|');
            if (firstPipe === -1) {
                console.warn(`Invalid room data format: ${roomData}`);
                return;
            }
            const room = roomData.substring(0, firstPipe);
            const timeColorUserDataStr = roomData.substring(firstPipe + 1);
            grids[room] = new Map();

            // Split into groups by '|', every 4 tokens represent time, color, username, positions
            const tokens = timeColorUserDataStr.split('|');
            for (let i = 0; i < tokens.length;) {
                if (i + 3 >= tokens.length) {
                    console.warn(`Incomplete pixel group in room '${room}'.`);
                    break;
                }

                const time = tokens[i++];
                const color = tokens[i++];
                const username = tokens[i++];
                const positionsStr = tokens[i++];

                try {
                    const { pixels } = decompressTimeColorUserData(`${time}|${color}|${username}|${positionsStr}`);

                    pixels.forEach(pixel => {
                        const { x, y } = pixel;
                        const chunkX = Math.floor(x / CHUNK_SIZE);
                        const chunkY = Math.floor(y / CHUNK_SIZE);
                        const chunkKey = `${chunkX},${chunkY}`;
                        const pixelKey = `${x % CHUNK_SIZE},${y % CHUNK_SIZE}`;

                        if (!grids[room].has(chunkKey)) {
                            grids[room].set(chunkKey, new Map());
                        }

                        grids[room].get(chunkKey).set(pixelKey, { color, username, time });
                    });
                } catch (error) {
                    console.error(`Error decompressing pixel group: ${time}|${color}|${username}|${positionsStr}`);
                }
            }
        });

        console.log('Grids successfully loaded.');
    } catch (error) {
        console.error('Error loading grids:', error);
    }
}

// Load grids at startup
loadGrids();

// Middleware setup
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.use(session({ 
    secret: process.env.SESSION_SECRET || 'gfdghvhgucjxigvjdfiohgdfihgfisdhgirhgirtfdgv', 
    resave: false, 
    saveUninitialized: false 
}));
app.use(passport.initialize());
app.use(passport.session());

// Passport configuration
passport.use(new LocalStrategy(
    function (username, password, done) {
        const user = Array.from(users.values()).find(u => u.username === username);
        if (!user) return done(null, false, { message: 'Incorrect username.' });
        bcrypt.compare(password, user.password, (err, res) => {
            if (err) return done(err);
            if (res) return done(null, user);
            else return done(null, false, { message: 'Incorrect password.' });
        });
    }
));

passport.serializeUser((user, done) => done(null, user.socketId));
passport.deserializeUser((socketId, done) => {
    const user = users.get(socketId);
    if (user) {
        done(null, user);
    } else {
        done(null, false);
    }
});

// Routes

/**
 * User Registration Route
 */
app.post('/register', async (req, res) => {
    const { username, password } = req.body;
    if (!username || !password) {
        return res.status(400).send('Username and password are required.');
    }
    if (Array.from(users.values()).some(user => user.username === username)) {
        return res.status(400).send('Username already exists.');
    }
    const hashedPassword = await bcrypt.hash(password, 10);
    const tempUser = { 
        id: uuidv4(), 
        username, 
        password: hashedPassword, 
        friends: new Set(), 
        avatar: null 
    };
    users.set(tempUser.id, tempUser); // Temporarily store the user
    res.status(201).send('User registered. Please login.');
});

/**
 * User Login Route
 */
app.post('/login', passport.authenticate('local'), (req, res) => {
    res.send('Logged in');
});

/**
 * Root Route - Serves the Client Interface
 */
app.get('/', (req, res) => {
    res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

io.on('connection', (socket) => {
    console.log('A user connected:', socket.id);

    // Init user data
    users.set(socket.id, { 
        username: 'Anonymous', 
        friends: new Set(), 
        avatar: null, 
        socketId: socket.id 
    });

    
    if (logPackets) {
        // Incoming packets
        socket.onAny((eventName, ...args) => {
            const ipAddress = socket.handshake.address;
            console.log(`Received from ${socket.id} (${ipAddress}): ${eventName}`, JSON.stringify(args).length + " bytes");
        });

        // Outgoing packets
        const originalEmit = socket.emit;
        socket.emit = function(eventName, ...args) {
            console.log(`Sent to ${socket.id}: ${eventName}`, JSON.stringify(args).length + " bytes");
            originalEmit.apply(this, [eventName, ...args]);
        };
    }

    // Notify about the number of online users
    io.emit('onlineUsers', users.size);

    /**
     * Set Username Event
     */
    socket.on('setUsername', (username) => {
        if (!username || typeof username !== 'string') {
            socket.emit('notification', { type: 'error', message: 'Invalid username.' });
            return;
        }
        const user = users.get(socket.id);
        if (!user) {
            socket.emit('notification', { type: 'error', message: 'User not found.' });
            return;
        }
        if (Array.from(users.values()).some(u => u.username === username)) {
            const num = Math.floor(Math.random() * 9000) + 1000;
            username += num;
        }
        if (user.username !== 'Anonymous') {
            usernames.delete(user.username);
            io.emit('userOffline', user.username);
        }
        // Filter out any non-alphanumeric characters
        username = username.replace(/[^a-zA-Z0-9]/g, '');
        // Clamp username
        const maxUsernameLength = 32;
        if (username.length > maxUsernameLength) {
            username = username.slice(0, maxUsernameLength);
        }
        if (username.length < 1) {
            socket.emit('notification', { type: 'error', message: 'Username must be at least 1 character.' });
            return;
        }
        user.username = username;
        users.set(socket.id, user);
        usernames.set(username, socket.id);
        io.emit('userOnline', username);
        socket.emit('notification', { type: 'success', message: `Username set to ${username}` });
        console.log(`User ${socket.id} set username to ${username}`);
    });

    /**
     * Join Room Event
     */
    socket.on('joinRoom', (roomName) => {
        if (!roomName || typeof roomName !== 'string') return;
        const previousRooms = Array.from(socket.rooms).filter(r => r !== socket.id);
        previousRooms.forEach(room => {
            socket.leave(room);
        });
        socket.join(roomName);
        if (!grids[roomName]) {
            grids[roomName] = new Map();
        }
        socket.emit('joinedRoom', roomName);
        console.log(`User ${socket.id} joined room ${roomName}`);
        const activeRooms = Array.from(io.sockets.adapter.rooms.keys()).filter(r => !users.has(r));
        io.emit('roomList', activeRooms);
    });

    /**
     * Request Room List Event
     */
    socket.on('requestRoomList', () => {
        const activeRooms = Array.from(io.sockets.adapter.rooms.keys()).filter(r => !users.has(r));
        socket.emit('roomList', activeRooms);
    });

    /**
     * Get Chunks Event
     */
    socket.on('getChunks', (data) => {
        const { room, chunks } = data;
        if (!room || !Array.isArray(chunks)) return;
        if (!grids[room]) {
            grids[room] = new Map();
        }
        const chunkData = {};
        chunks.forEach(chunk => {
            if (!grids[room].has(chunk)) {
                chunkData[chunk] = {};
                return;
            }
            const pixels = grids[room].get(chunk);
            const pixelsObj = {};
            pixels.forEach((pixelData, key) => {
                pixelsObj[key] = pixelData;
            });
            chunkData[chunk] = pixelsObj;
        });
        socket.emit('chunkData', { room, chunkData });
    });

    /**
     * Place Pixel Event
     */
    socket.on('placePixel', (data) => {
        const { room, x, y, color } = data;
        const user = users.get(socket.id);
        if (!user || !room) return;
        // // Cooldown check
        // const userCooldownObj = cooldownUsers.find(u => u.username === user.username);
        // if (userCooldownObj) {
        //     if (userCooldownObj.lastPlaced)
        //     socket.emit('notification', { type: 'error', message: 'Cooldown!' });
        //     return;
        // }
        if (!isValidPosition(x, y)) {
            socket.emit('notification', { type: 'error', message: 'Invalid pixel position.' });
            return;
        }
        if (!color || typeof color !== 'string') {
            socket.emit('notification', { type: 'error', message: 'Invalid color.' });
            return;
        }
        if (!grids[room]) return;

        const time = getCurrentFormattedTime();

        const key = `${Math.floor(x / CHUNK_SIZE)},${Math.floor(y / CHUNK_SIZE)}`;
        if (!grids[room].has(key)) {
            grids[room].set(key, new Map());
        }

        const chunkMap = grids[room].get(key);
        const pixelKey = `${x % CHUNK_SIZE},${y % CHUNK_SIZE}`;

        const existingPixelData = chunkMap.get(pixelKey);
        if (!existingPixelData || existingPixelData.color !== color) {
            if (color.toUpperCase() === '#FFFFFF') {
                // Remove pixel if color is white (assuming white is the default background)
                chunkMap.delete(pixelKey);
                io.to(room).emit('pixelUpdate', { 
                    room, 
                    chunkKey: key, 
                    x: x % CHUNK_SIZE, 
                    y: y % CHUNK_SIZE, 
                    color: '#FFFFFF', 
                    removed: true 
                });
            } else {
                const pixelData = { color, username: user.username, time };
                    chunkMap.set(pixelKey, pixelData);
                    io.to(room).emit('pixelUpdate', {
                        room,
                        chunkKey: key,
                        x: x % CHUNK_SIZE,
                        y: y % CHUNK_SIZE,
                        color
                    });
            }
        }
    });

    socket.on('placeBatchPixels', (data) => {
        const { room, pixels, color, username } = data;
        const user = users.get(socket.id);
        if (!user || !room || !grids[room]) return;
        if (user.username !== 'beefour') {
            socket.emit('notification', { type: 'error', message: 'You are not allowed to use this feature.' });
            return;
        }
        if (pixels.length > MAX_BATCH_PLACE) {
            socket.emit('notification', { type: 'error', message: 'Batch size exceeds the maximum limit' });
            return;
        }
    
        const batchUpdates = [];
        const whitePixels = [];
    
        pixels.forEach(pixel => {
            const { x, y } = pixel;
            if (!isValidPosition(x, y) || !color || typeof color !== 'string') return;
    
            const time = getCurrentFormattedTime();
            const key = `${Math.floor(x / CHUNK_SIZE)},${Math.floor(y / CHUNK_SIZE)}`;
            if (!grids[room].has(key)) {
                grids[room].set(key, new Map());
            }
    
            const chunkMap = grids[room].get(key);
            const pixelKey = `${x % CHUNK_SIZE},${y % CHUNK_SIZE}`;
    
            if (color.toUpperCase() === '#FFFFFF') {
                chunkMap.delete(pixelKey);
                whitePixels.push({
                    room,
                    chunkKey: key,
                    x: x % CHUNK_SIZE,
                    y: y % CHUNK_SIZE,
                    color: '#FFFFFF',
                    removed: true
                });
            } else {
                const pixelData = { color, username, time };
                chunkMap.set(pixelKey, pixelData);
                batchUpdates.push({
                    room,
                    chunkKey: key,
                    x: x % CHUNK_SIZE,
                    y: y % CHUNK_SIZE,
                    color
                });
            }
        });
    
        if (batchUpdates.length > 0) {
            io.to(room).emit('pixelBatchUpdate', { room, updates: batchUpdates });
        }
    
        if (whitePixels.length > 0) {
            io.to(room).emit('pixelBatchUpdate', { room, updates: whitePixels });
        }
    });

    /**
     * Get Pixel Info Event
     */
    socket.on('getPixelInfo', (data) => {
        const { room, x, y } = data;
        if (!room || typeof x !== 'number' || typeof y !== 'number') {
            socket.emit('pixelInfo', null);
            return;
        }
        if (!grids[room]) {
            socket.emit('pixelInfo', null);
            return;
        }
        const chunkKey = `${Math.floor(x / CHUNK_SIZE)},${Math.floor(y / CHUNK_SIZE)}`;
        if (!grids[room].has(chunkKey)) {
            socket.emit('pixelInfo', null);
            return;
        }
        const chunkMap = grids[room].get(chunkKey);
        const pixelKey = `${x % CHUNK_SIZE},${y % CHUNK_SIZE}`;
        if (!chunkMap.has(pixelKey)) {
            socket.emit('pixelInfo', null);
            return;
        }
        const pixelData = chunkMap.get(pixelKey);
        socket.emit('pixelInfo', { room, x, y, ...pixelData });
    });

    /**
     * Chat Message Event
     */
    socket.on('chatMessage', (data) => {
        let { room, message } = data;
        let user = users.get(socket.id);
        if (!user || !room || !message || typeof message !== 'string') return;
        //remove < and > to prevent XSS
        message = message.replace(/</g, '&lt;').replace(/>/g, '&gt;');
        io.to(room).emit('chatMessage', { room, username: user.username, message });
    });

    /**
     * Add Friend Event
     */
    socket.on('addFriend', (friendUsername) => {
        if (!friendUsername || typeof friendUsername !== 'string') {
            socket.emit('notification', { type: 'error', message: 'Invalid friend username.' });
            return;
        }
        const user = users.get(socket.id);
        if (!user) return;
        if (user.friends.has(friendUsername)) {
            socket.emit('notification', { type: 'error', message: `${friendUsername} is already your friend.` });
            return;
        }
        if (!usernames.has(friendUsername)) {
            socket.emit('notification', { type: 'error', message: `${friendUsername} does not exist or is not online.` });
            return;
        }
        user.friends.add(friendUsername);
        users.set(socket.id, user);
        const friendSocketId = usernames.get(friendUsername);
        const friend = users.get(friendSocketId);
        if (friend) {
            friend.friends.add(user.username);
            users.set(friendSocketId, friend);
            io.to(friendSocketId).emit('friendAdded', user.username);
            socket.emit('friendAdded', friend.username);
            socket.emit('notification', { type: 'success', message: `Added ${friendUsername} as a friend.` });
        }
    });

    /**
     * Direct Message (DM) Event
     */
    socket.on('dm', (data) => {
        const { toUsername, message } = data;
        if (!toUsername || typeof toUsername !== 'string' || !message || typeof message !== 'string') {
            socket.emit('notification', { type: 'error', message: 'Invalid DM data.' });
            return;
        }
        const recipientSocketId = usernames.get(toUsername);
        if (recipientSocketId && users.has(recipientSocketId)) {
            const sender = users.get(socket.id);
            io.to(recipientSocketId).emit('dm', { from: sender.username, message });
            io.to(recipientSocketId).emit('notification', { type: 'dm', message: `New DM from ${sender.username}` });
            socket.emit('notification', { type: 'success', message: `DM sent to ${toUsername}` });
        } else {
            socket.emit('notification', { type: 'error', message: `${toUsername} is not online or does not exist.` });
        }
    });

    /**
     * Update Avatar Event
     */
    socket.on('updateAvatar', (avatarData) => {
        if (!avatarData || typeof avatarData !== 'string') {
            socket.emit('notification', { type: 'error', message: 'Invalid avatar data.' });
            return;
        }
        const user = users.get(socket.id);
        if (!user) return;
        user.avatar = avatarData;
        users.set(socket.id, user);
        socket.broadcast.emit('userAvatar', { socketId: socket.id, avatar: avatarData });
    });

    /**
     * User Disconnect Event
     */
    socket.on('disconnect', () => {
        console.log('A user disconnected:', socket.id);
        const user = users.get(socket.id);
        if (user && user.username !== 'Anonymous') {
            io.emit('userOffline', user.username);
            usernames.delete(user.username);
        }
        users.delete(socket.id);
        io.emit('onlineUsers', users.size);
    });
});

// Start the server
http.listen(PORT, ip, () => {
    console.log(`Server running on http://${ip}:${PORT}`);
});

// Save grids on server shutdown
process.on('SIGINT', () => {
    console.log('Saving grids before shutting down...');
    saveGrids();
    console.log('Grids saved. Shutting down.');
    process.exit();
});

make a C# frontend in a console app for this where users can place pixels and move around with the camera and all that