import crypto from "crypto";
import jwt from "jsonwebtoken";
import bcrypt from "bcryptjs";
import { FastifyRequest, FastifyReply } from "fastify";
import prisma from "../config/database";
import { config } from "../config/env";
import { AuthData, JwtPayload } from "../types";
import { getSystemSettings } from "./system-settings";

// Pre-computed bcrypt hash for timing-safe comparison when user not found
const DUMMY_HASH =
  "$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO";

// --- Token helpers ---

function hashToken(token: string): string {
  return crypto.createHash("sha256").update(token).digest("hex");
}

function generateAccessToken(user: {
  id: number;
  username: string;
  roleName: string | null;
}): string {
  return jwt.sign(
    { sub: user.id, username: user.username, role: user.roleName },
    config.jwt.secret,
    { expiresIn: config.jwt.accessTokenExpiry },
  );
}

function generateRefreshToken(): string {
  return crypto.randomBytes(32).toString("hex");
}

// --- Auth data loading ---

async function loadAuthData(userId: number): Promise<AuthData | null> {
  const user = await prisma.users.findUnique({
    where: { id: userId },
    include: {
      roles: {
        include: {
          role_permissions: {
            include: { permissions: true },
          },
        },
      },
    },
  });

  if (!user || !user.is_active) return null;

  if (user.locked_until && new Date(user.locked_until) > new Date())
    return null;

  const isAdmin = user.roles?.name === "admin";
  const permissions = isAdmin
    ? (await prisma.permissions.findMany({ select: { name: true } })).map(
        (p) => p.name,
      )
    : (user.roles?.role_permissions ?? []).map(
        (rp: { permissions: { name: string } }) => rp.permissions.name,
      );

  return {
    userId: user.id,
    username: user.username,
    email: user.email,
    firstName: user.first_name,
    lastName: user.last_name,
    roleId: user.role_id,
    roleName: user.roles?.name ?? null,
    permissions,
    totp_enabled: !!user.totp_enabled,
    require_2fa: !!(
      await prisma.company_settings.findFirst({ select: { require_2fa: true } })
    )?.require_2fa,
  };
}

// --- Public API ---

export async function login(
  username: string,
  password: string,
  rememberMe: boolean,
  request: FastifyRequest,
): Promise<
  | {
      type: "success";
      accessToken: string;
      refreshToken: string;
      user: AuthData;
    }
  | { type: "totp_required"; loginToken: string }
  | { type: "error"; message: string; status: number }
> {
  const user = await prisma.users.findFirst({
    where: {
      OR: [{ username }, { email: username }],
    },
    include: { roles: true },
  });

  if (!user) {
    // Timing-safe: run bcrypt even when user not found
    await bcrypt.compare(password, DUMMY_HASH);
    return {
      type: "error",
      message: "Neplatné přihlašovací údaje",
      status: 401,
    };
  }

  if (!user.is_active) {
    await bcrypt.compare(password, DUMMY_HASH); // timing-safe
    request.log.warn(`Login failed for deactivated user: ${username}`);
    return {
      type: "error",
      message: "Neplatné přihlašovací údaje",
      status: 401,
    };
  }

  if (user.locked_until && new Date(user.locked_until) > new Date()) {
    await bcrypt.compare(password, DUMMY_HASH); // timing-safe
    request.log.warn(`Login failed for locked user: ${username}`);
    return {
      type: "error",
      message: "Neplatné přihlašovací údaje",
      status: 401,
    };
  }

  const passwordValid = await bcrypt.compare(password, user.password_hash);
  if (!passwordValid) {
    const settings = await getSystemSettings();
    await prisma.$transaction(async (tx) => {
      const updated = await tx.users.update({
        where: { id: user.id },
        data: { failed_login_attempts: { increment: 1 } },
        select: { failed_login_attempts: true },
      });

      if ((updated.failed_login_attempts ?? 0) >= settings.max_login_attempts) {
        await tx.users.update({
          where: { id: user.id },
          data: {
            locked_until: new Date(
              Date.now() + settings.lockout_minutes * 60_000,
            ),
          },
        });
      }
    });

    return {
      type: "error",
      message: "Neplatné přihlašovací údaje",
      status: 401,
    };
  }

  await prisma.users.update({
    where: { id: user.id },
    data: {
      failed_login_attempts: 0,
      locked_until: null,
      last_login: new Date(),
    },
  });

  const companySettings = await prisma.company_settings.findFirst({
    select: { require_2fa: true },
  });
  if (companySettings?.require_2fa && !user.totp_enabled) {
    return {
      type: "error",
      message: "Dvoufázové ověření je povinné",
      status: 403,
    };
  }

  if (user.totp_enabled) {
    const loginToken = crypto.randomBytes(32).toString("hex");
    const tokenHash = hashToken(loginToken);

    await prisma.totp_login_tokens.create({
      data: {
        user_id: user.id,
        token_hash: tokenHash,
        expires_at: new Date(
          Date.now() + config.totp.loginTokenExpiryMinutes * 60_000,
        ),
      },
    });

    return { type: "totp_required", loginToken };
  }

  const authData = await loadAuthData(user.id);
  if (!authData) {
    return {
      type: "error",
      message: "Chyba načítání uživatelských dat",
      status: 500,
    };
  }

  const accessToken = generateAccessToken({
    id: user.id,
    username: user.username,
    roleName: user.roles?.name ?? null,
  });

  const refreshTokenRaw = generateRefreshToken();
  const refreshTokenHash = hashToken(refreshTokenRaw);

  const expiresIn = rememberMe
    ? config.jwt.refreshTokenRememberExpiry
    : config.jwt.refreshTokenSessionExpiry;

  await prisma.refresh_tokens.create({
    data: {
      user_id: user.id,
      token_hash: refreshTokenHash,
      expires_at: new Date(Date.now() + expiresIn * 1000),
      remember_me: rememberMe,
      ip_address: request.ip,
      user_agent: request.headers["user-agent"] ?? null,
    },
  });

  return {
    type: "success",
    accessToken,
    refreshToken: refreshTokenRaw,
    user: authData,
  };
}

export async function refreshAccessToken(
  refreshTokenRaw: string,
  request: FastifyRequest,
): Promise<
  | {
      type: "success";
      accessToken: string;
      refreshToken: string;
      user: AuthData;
      rememberMe: boolean;
    }
  | { type: "error"; message: string; status: number }
> {
  const tokenHash = hashToken(refreshTokenRaw);

  return prisma.$transaction(async (tx) => {
    const tokens = await tx.$queryRaw<
      Array<{
        id: number;
        user_id: number;
        expires_at: Date;
        replaced_at: Date | null;
        replaced_by_hash: string | null;
        remember_me: boolean | null;
      }>
    >`
      SELECT id, user_id, expires_at, replaced_at, replaced_by_hash, remember_me FROM refresh_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
    `;
    const storedToken = tokens[0] ?? null;

    // Detected reuse: a token that exists but has ALREADY been rotated
    // (replaced_at / replaced_by_hash set) is being presented again. This is
    // the classic refresh-token-theft signature — either the legitimate
    // client or an attacker is replaying a consumed token. Breach
    // containment: revoke the WHOLE token family for that user so both the
    // stolen and the still-valid descendant tokens are invalidated, forcing
    // a fresh login.
    if (
      storedToken &&
      (storedToken.replaced_at || storedToken.replaced_by_hash)
    ) {
      const reuseUserId = Number(storedToken.user_id);
      await tx.refresh_tokens.deleteMany({ where: { user_id: reuseUserId } });
      request.log.warn(
        `Refresh-token reuse detected for user ${reuseUserId}; revoked all sessions`,
      );
      return { type: "error", message: "Neplatný refresh token", status: 401 };
    }

    if (!storedToken || new Date(storedToken.expires_at) < new Date()) {
      return { type: "error", message: "Neplatný refresh token", status: 401 };
    }

    // $queryRaw on MySQL may return BigInt for integer columns
    const storedTokenId = Number(storedToken.id);
    const storedUserId = Number(storedToken.user_id);

    const authData = await loadAuthData(storedUserId);
    if (!authData) {
      return { type: "error", message: "Uživatel nenalezen", status: 401 };
    }

    const newRefreshTokenRaw = generateRefreshToken();
    const newRefreshTokenHash = hashToken(newRefreshTokenRaw);

    const expiresIn = storedToken.remember_me
      ? config.jwt.refreshTokenRememberExpiry
      : config.jwt.refreshTokenSessionExpiry;

    // $queryRaw on MySQL returns 0/1 for booleans; Prisma expects true/false
    const rememberMe = Boolean(storedToken.remember_me);

    await tx.refresh_tokens.update({
      where: { id: storedTokenId },
      data: { replaced_at: new Date(), replaced_by_hash: newRefreshTokenHash },
    });
    await tx.refresh_tokens.create({
      data: {
        user_id: storedUserId,
        token_hash: newRefreshTokenHash,
        expires_at: new Date(Date.now() + expiresIn * 1000),
        remember_me: rememberMe,
        ip_address: request.ip,
        user_agent: request.headers["user-agent"] ?? null,
      },
    });

    const accessToken = generateAccessToken({
      id: authData.userId,
      username: authData.username,
      roleName: authData.roleName,
    });

    return {
      type: "success",
      accessToken,
      refreshToken: newRefreshTokenRaw,
      user: authData,
      rememberMe: rememberMe,
    };
  });
}

export async function logout(refreshTokenRaw: string): Promise<void> {
  const tokenHash = hashToken(refreshTokenRaw);

  // Delete only the specific token presented, not all sessions
  await prisma.refresh_tokens.deleteMany({
    where: { token_hash: tokenHash },
  });

  await prisma.refresh_tokens.deleteMany({
    where: { expires_at: { lt: new Date() } },
  });
}

export async function verifyAccessToken(
  token: string,
): Promise<AuthData | null> {
  try {
    const payload = jwt.verify(token, config.jwt.secret, {
      algorithms: ["HS256"],
    }) as unknown as JwtPayload;
    return loadAuthData(payload.sub);
  } catch (err) {
    // An expired or otherwise invalid access token is an EXPECTED condition:
    // access tokens live ~15 min, and the client silently refreshes + retries
    // on the resulting 401 (see src/admin/utils/api.ts). Logging every such
    // case as an error — with a stack trace — floods the logs (roughly every
    // 15 min per active user) and buries genuine errors. `JsonWebTokenError`
    // is the base class of `TokenExpiredError`/`NotBeforeError`, so this skips
    // all normal token-validation failures while still surfacing anything
    // unexpected (e.g. a DB failure in loadAuthData).
    if (!(err instanceof jwt.JsonWebTokenError)) {
      console.error("Unexpected error verifying access token:", err);
    }
    return null;
  }
}

export { hashToken, loadAuthData };
