import "dotenv/config";
import express, { NextFunction, Request, Response } from "express";
import cors from "cors";
import http from "node:http";
import path from "node:path";
import fs from "node:fs";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import Database from "better-sqlite3";
import { Server } from "socket.io";

type User = {
  id: number;
  username: string;
};

type AuthRequest = Request & {
  user?: User;
};

type TokenPayload = {
  id: number;
  username: string;
};

const PORT = Number(process.env.PORT ?? 4000);
const JWT_SECRET = process.env.JWT_SECRET ?? "development-secret";
const CLIENT_ORIGIN = process.env.CLIENT_ORIGIN ?? "http://localhost:5173";
const DATABASE_PATH = process.env.DATABASE_PATH ?? "./data/chat.sqlite";

fs.mkdirSync(path.dirname(DATABASE_PATH), { recursive: true });

const database = new Database(DATABASE_PATH);
database.pragma("foreign_keys = ON");

database.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT NOT NULL UNIQUE COLLATE NOCASE,
    password_hash TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
  );

  CREATE TABLE IF NOT EXISTS messages (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    sender_id INTEGER NOT NULL,
    receiver_id INTEGER NOT NULL,
    body TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (sender_id) REFERENCES users(id) ON DELETE CASCADE,
    FOREIGN KEY (receiver_id) REFERENCES users(id) ON DELETE CASCADE
  );

  CREATE INDEX IF NOT EXISTS idx_messages_participants
  ON messages(sender_id, receiver_id, created_at);
`);

const app = express();
const server = http.createServer(app);

const io = new Server(server, {
  cors: {
    origin: CLIENT_ORIGIN,
    credentials: true
  }
});

app.use(
  cors({
    origin: CLIENT_ORIGIN,
    credentials: true
  })
);
app.use(express.json({ limit: "1mb" }));

function createToken(user: User) {
  return jwt.sign(user, JWT_SECRET, { expiresIn: "7d" });
}

function authenticateToken(
  request: AuthRequest,
  response: Response,
  next: NextFunction
) {
  const header = request.headers.authorization;
  const token = header?.startsWith("Bearer ") ? header.slice(7) : null;

  if (!token) {
    response.status(401).json({ message: "Authentication required" });
    return;
  }

  try {
    const payload = jwt.verify(token, JWT_SECRET) as TokenPayload;
    request.user = {
      id: payload.id,
      username: payload.username
    };
    next();
  } catch {
    response.status(401).json({ message: "Invalid or expired token" });
  }
}

function getUserById(id: number): User | undefined {
  return database
    .prepare("SELECT id, username FROM users WHERE id = ?")
    .get(id) as User | undefined;
}

function getMessage(id: number) {
  return database
    .prepare(`
      SELECT
        messages.id,
        messages.body,
        messages.created_at AS createdAt,
        messages.sender_id AS senderId,
        messages.receiver_id AS receiverId,
        sender.username AS senderUsername,
        receiver.username AS receiverUsername
      FROM messages
      JOIN users sender ON sender.id = messages.sender_id
      JOIN users receiver ON receiver.id = messages.receiver_id
      WHERE messages.id = ?
    `)
    .get(id);
}

app.get("/api/health", (_request, response) => {
  response.json({ status: "ok" });
});

app.post("/api/auth/register", async (request, response) => {
  const username = String(request.body.username ?? "").trim();
  const password = String(request.body.password ?? "");

  if (!/^[a-zA-Z0-9_]{3,24}$/.test(username)) {
    response.status(400).json({
      message:
        "Username must contain 3-24 letters, numbers, or underscores"
    });
    return;
  }

  if (password.length < 6) {
    response.status(400).json({
      message: "Password must contain at least 6 characters"
    });
    return;
  }

  const existingUser = database
    .prepare("SELECT id FROM users WHERE username = ? COLLATE NOCASE")
    .get(username);

  if (existingUser) {
    response.status(409).json({ message: "Username is already taken" });
    return;
  }

  const passwordHash = await bcrypt.hash(password, 12);
  const result = database
    .prepare("INSERT INTO users (username, password_hash) VALUES (?, ?)")
    .run(username, passwordHash);

  const user = {
    id: Number(result.lastInsertRowid),
    username
  };

  response.status(201).json({
    user,
    token: createToken(user)
  });
});

app.post("/api/auth/login", async (request, response) => {
  const username = String(request.body.username ?? "").trim();
  const password = String(request.body.password ?? "");

  const record = database
    .prepare("SELECT * FROM users WHERE username = ? COLLATE NOCASE")
    .get(username) as
    | {
        id: number;
        username: string;
        password_hash: string;
      }
    | undefined;

  if (!record || !(await bcrypt.compare(password, record.password_hash))) {
    response.status(401).json({ message: "Invalid username or password" });
    return;
  }

  const user = {
    id: record.id,
    username: record.username
  };

  response.json({
    user,
    token: createToken(user)
  });
});

app.get(
  "/api/users",
  authenticateToken,
  (request: AuthRequest, response: Response) => {
    const search = String(request.query.search ?? "").trim();
    const currentUserId = request.user!.id;

    const users = database
      .prepare(`
        SELECT id, username
        FROM users
        WHERE id != ?
          AND username LIKE ?
        ORDER BY username ASC
        LIMIT 50
      `)
      .all(currentUserId, `%${search}%`);

    response.json(users);
  }
);

app.get(
  "/api/messages/:userId",
  authenticateToken,
  (request: AuthRequest, response: Response) => {
    const currentUserId = request.user!.id;
    const otherUserId = Number(request.params.userId);

    if (!Number.isInteger(otherUserId)) {
      response.status(400).json({ message: "Invalid user ID" });
      return;
    }

    const messages = database
      .prepare(`
        SELECT
          messages.id,
          messages.body,
          messages.created_at AS createdAt,
          messages.sender_id AS senderId,
          messages.receiver_id AS receiverId,
          sender.username AS senderUsername,
          receiver.username AS receiverUsername
        FROM messages
        JOIN users sender ON sender.id = messages.sender_id
        JOIN users receiver ON receiver.id = messages.receiver_id
        WHERE
          (messages.sender_id = ? AND messages.receiver_id = ?)
          OR
          (messages.sender_id = ? AND messages.receiver_id = ?)
        ORDER BY messages.created_at ASC, messages.id ASC
      `)
      .all(currentUserId, otherUserId, otherUserId, currentUserId);

    response.json(messages);
  }
);

app.get(
  "/api/conversations",
  authenticateToken,
  (request: AuthRequest, response: Response) => {
    const userId = request.user!.id;

    const conversations = database
      .prepare(`
        SELECT
          other.id,
          other.username,
          latest.body AS lastMessage,
          latest.created_at AS lastMessageAt
        FROM users other
        JOIN (
          SELECT
            CASE
              WHEN sender_id = @userId THEN receiver_id
              ELSE sender_id
            END AS other_id,
            body,
            created_at
          FROM messages
          WHERE sender_id = @userId OR receiver_id = @userId
          ORDER BY created_at DESC, id DESC
        ) latest ON latest.other_id = other.id
        WHERE other.id != @userId
        GROUP BY other.id
        ORDER BY latest.created_at DESC
      `)
      .all({ userId });

    response.json(conversations);
  }
);

io.use((socket, next) => {
  const token = socket.handshake.auth?.token as string | undefined;

  if (!token) {
    next(new Error("Authentication required"));
    return;
  }

  try {
    const payload = jwt.verify(token, JWT_SECRET) as TokenPayload;
    socket.data.user = {
      id: payload.id,
      username: payload.username
    };
    next();
  } catch {
    next(new Error("Invalid token"));
  }
});

io.on("connection", (socket) => {
  const user = socket.data.user as User;
  socket.join(`user:${user.id}`);

  socket.broadcast.emit("presence_changed", {
    userId: user.id,
    online: true
  });

  socket.on(
    "send_message",
    (
      data: { receiverId?: number; body?: string },
      callback?: (result: { ok: boolean; message?: unknown; error?: string }) => void
    ) => {
      const receiverId = Number(data.receiverId);
      const body = String(data.body ?? "").trim();

      if (!Number.isInteger(receiverId) || !body || body.length > 4000) {
        callback?.({
          ok: false,
          error: "A valid recipient and message are required"
        });
        return;
      }

      if (!getUserById(receiverId)) {
        callback?.({ ok: false, error: "Recipient does not exist" });
        return;
      }

      const result = database
        .prepare(`
          INSERT INTO messages (sender_id, receiver_id, body)
          VALUES (?, ?, ?)
        `)
        .run(user.id, receiverId, body);

      const message = getMessage(Number(result.lastInsertRowid));

      io.to(`user:${user.id}`).to(`user:${receiverId}`).emit(
        "message_received",
        message
      );

      callback?.({ ok: true, message });
    }
  );

  socket.on("disconnect", () => {
    socket.broadcast.emit("presence_changed", {
      userId: user.id,
      online: false
    });
  });
});

const clientDist = path.resolve(process.cwd(), "../client/dist");

if (fs.existsSync(clientDist)) {
  app.use(express.static(clientDist));

  app.get("*", (_request, response) => {
    response.sendFile(path.join(clientDist, "index.html"));
  });
}

server.listen(PORT, () => {
  console.log(`Chat server running on http://localhost:${PORT}`);
});
