import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import { securityHeaders } from "../middleware/security";
import tripsRoutes from "../routes/admin/trips";

// ---------------------------------------------------------------------------
// Route-level tests for the trips domain:
//
// 1. PUT /trips/:id with vehicle_id actually moves the trip to the new vehicle
//    (UpdateTripSchema previously had no vehicle_id, so the edit modal's
//    vehicle change was silently dropped while the UI reported success) and
//    recomputes actual_km on BOTH vehicles.
// 2. GET /trips/stats aggregates count/total/business/private km over the
//    WHOLE filtered set (not one 25-row page), honors the month filter in
//    both wire formats ("month=5&year=2098" and "month=2098-05"), and scopes
//    non-managers to their own trips exactly like the list does.
// ---------------------------------------------------------------------------

/** Note-prefix for test-created rows so cleanup never touches real data. */
const N = "trips_test_";

let app: Awaited<ReturnType<typeof buildApp>>;
let adminUserId: number;
let adminToken: string;
let scopeUserId: number;
let scopeRoleId: number;
let scopeToken: string;
let vehicleAId: number;
let vehicleBId: number;

async function buildApp() {
  const a = Fastify({ logger: false });
  await a.register(cookie);
  await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
  a.addHook("onRequest", securityHeaders);
  await a.register(tripsRoutes, { prefix: "/api/admin/trips" });
  return a;
}

/**
 * Generate a JWT access token. `requireAuth` re-loads auth data from the DB
 * via `loadAuthData(payload.sub)`, so only the `sub` (user id) matters here.
 */
function generateToken(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: "15m" },
  );
}

async function authGet(path: string, token: string) {
  return app.inject({
    method: "GET",
    url: path,
    headers: { Authorization: `Bearer ${token}` },
  });
}

async function authPut(path: string, token: string, body: unknown) {
  return app.inject({
    method: "PUT",
    url: path,
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    payload: body as object,
  });
}

function tripRow(params: {
  userId: number;
  vehicleId: number;
  date: string;
  startKm: number;
  dist: number;
  isBusiness: boolean;
}) {
  return {
    user_id: params.userId,
    vehicle_id: params.vehicleId,
    trip_date: new Date(params.date),
    start_km: params.startKm,
    end_km: params.startKm + params.dist,
    route_from: "Praha",
    route_to: "Brno",
    is_business: params.isBusiness,
    notes: `${N}fixture`,
  };
}

async function cleanupFixtureTrips() {
  await prisma.trips.deleteMany({ where: { notes: { contains: N } } });
}

beforeAll(async () => {
  app = await buildApp();

  const admin = await prisma.users.findFirst({
    where: { roles: { name: "admin" } },
    include: { roles: true },
  });
  if (!admin) throw new Error("Test setup: admin user not found");
  adminUserId = admin.id;
  adminToken = generateToken({
    id: admin.id,
    username: admin.username,
    roleName: admin.roles?.name ?? null,
  });

  // Dedicated non-admin user with trips.record + trips.history (but NOT
  // trips.manage) for the own-trips scoping tests.
  const stamp = Date.now().toString(36);
  const role = await prisma.roles.create({
    data: {
      name: `${N}role_${stamp}`,
      display_name: "Trips Scope Test",
    },
  });
  scopeRoleId = role.id;
  const perms = await prisma.permissions.findMany({
    where: { name: { in: ["trips.record", "trips.history"] } },
    select: { id: true },
  });
  if (perms.length !== 2)
    throw new Error(
      "Test setup: trips.record/trips.history permissions missing",
    );
  await prisma.role_permissions.createMany({
    data: perms.map((p) => ({ role_id: role.id, permission_id: p.id })),
  });
  const scopeUser = await prisma.users.create({
    data: {
      username: `${N}user_${stamp}`,
      first_name: "Trips",
      last_name: "Scope",
      email: `${N}${stamp}@test.local`,
      password_hash:
        "$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
      role_id: role.id,
      is_active: true,
    },
  });
  scopeUserId = scopeUser.id;
  if (scopeUserId === adminUserId)
    throw new Error("scope user must be distinct from admin");
  scopeToken = generateToken({
    id: scopeUser.id,
    username: scopeUser.username,
    roleName: role.name,
  });

  // Two dedicated vehicles (spz is unique, VarChar(20)).
  const vehicleA = await prisma.vehicles.create({
    data: { spz: `TTA-${stamp}`, name: "Trips Test A", initial_km: 500 },
  });
  const vehicleB = await prisma.vehicles.create({
    data: { spz: `TTB-${stamp}`, name: "Trips Test B", initial_km: 200 },
  });
  vehicleAId = vehicleA.id;
  vehicleBId = vehicleB.id;

  await cleanupFixtureTrips();
});

beforeEach(async () => {
  await cleanupFixtureTrips();
});

afterAll(async () => {
  await cleanupFixtureTrips();
  await prisma.vehicles
    .deleteMany({ where: { id: { in: [vehicleAId, vehicleBId] } } })
    .catch(() => {});
  if (scopeUserId)
    await prisma.users
      .deleteMany({ where: { id: scopeUserId } })
      .catch(() => {});
  if (scopeRoleId)
    await prisma.roles
      .deleteMany({ where: { id: scopeRoleId } })
      .catch(() => {});
  if (app) await app.close();
});

describe("PUT /api/admin/trips/:id — vehicle_id", () => {
  it("changes the trip's vehicle and recomputes actual_km on both vehicles", async () => {
    const trip = await prisma.trips.create({
      data: tripRow({
        userId: adminUserId,
        vehicleId: vehicleAId,
        date: "2098-04-10",
        startKm: 1000,
        dist: 500,
        isBusiness: true,
      }),
    });

    // The edit form sends the id as a string (Select value) — intIdFromForm
    // must coerce it.
    const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, {
      vehicle_id: String(vehicleBId),
    });

    expect(res.statusCode).toBe(200);
    expect(res.json().success).toBe(true);

    const updated = await prisma.trips.findUnique({ where: { id: trip.id } });
    expect(updated!.vehicle_id).toBe(vehicleBId);

    // New vehicle picks up the trip's odometer reading…
    const vehB = await prisma.vehicles.findUnique({
      where: { id: vehicleBId },
    });
    expect(vehB!.actual_km).toBe(1500);
    // …and the old vehicle falls back to initial_km (no trips left on it).
    const vehA = await prisma.vehicles.findUnique({
      where: { id: vehicleAId },
    });
    expect(vehA!.actual_km).toBe(500);
  });

  it("recomputes actual_km when end_km changes and the vehicle stays the same", async () => {
    // Two trips on vehicle A — the recompute must take MAX(end_km) over ALL
    // of the vehicle's trips, not just the edited one.
    const trip1 = await prisma.trips.create({
      data: tripRow({
        userId: adminUserId,
        vehicleId: vehicleAId,
        date: "2098-09-10",
        startKm: 1000,
        dist: 500, // end_km 1500
        isBusiness: true,
      }),
    });
    const trip2 = await prisma.trips.create({
      data: tripRow({
        userId: adminUserId,
        vehicleId: vehicleAId,
        date: "2098-09-12",
        startKm: 1800,
        dist: 200, // end_km 2000
        isBusiness: true,
      }),
    });

    // Raising the MAX trip's end_km (vehicle UNCHANGED) moves the odometer up…
    const raise = await authPut(`/api/admin/trips/${trip2.id}`, adminToken, {
      end_km: 2200,
    });
    expect(raise.statusCode).toBe(200);
    let vehA = await prisma.vehicles.findUnique({ where: { id: vehicleAId } });
    expect(vehA!.actual_km).toBe(2200);

    // …while editing the non-MAX trip recomputes but keeps MAX(end_km).
    const lower = await authPut(`/api/admin/trips/${trip1.id}`, adminToken, {
      end_km: 1600,
    });
    expect(lower.statusCode).toBe(200);
    const updated = await prisma.trips.findUnique({ where: { id: trip1.id } });
    expect(updated!.end_km).toBe(1600);
    expect(updated!.vehicle_id).toBe(vehicleAId);
    vehA = await prisma.vehicles.findUnique({ where: { id: vehicleAId } });
    expect(vehA!.actual_km).toBe(2200);
  });

  it("writes an audit row with oldValues/newValues on update", async () => {
    const trip = await prisma.trips.create({
      data: tripRow({
        userId: adminUserId,
        vehicleId: vehicleAId,
        date: "2098-10-10",
        startKm: 100,
        dist: 50, // end_km 150
        isBusiness: true,
      }),
    });

    const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, {
      end_km: 180,
      route_to: "Ostrava",
    });
    expect(res.statusCode).toBe(200);

    const audit = await prisma.audit_logs.findFirst({
      where: { action: "update", entity_type: "trip", entity_id: trip.id },
      orderBy: { id: "desc" },
    });
    expect(audit).not.toBeNull();
    expect(audit!.user_id).toBe(adminUserId);

    // oldValues = the full pre-update row, newValues = the changed fields.
    expect(audit!.old_values).toBeTruthy();
    expect(audit!.new_values).toBeTruthy();
    const oldValues = JSON.parse(audit!.old_values!);
    const newValues = JSON.parse(audit!.new_values!);
    expect(oldValues.end_km).toBe(150);
    expect(oldValues.route_to).toBe("Brno");
    expect(oldValues.vehicle_id).toBe(vehicleAId);
    expect(newValues).toEqual({ end_km: 180, route_to: "Ostrava" });
  });

  it("rejects an invalid vehicle_id with 400", async () => {
    const trip = await prisma.trips.create({
      data: tripRow({
        userId: adminUserId,
        vehicleId: vehicleAId,
        date: "2098-04-11",
        startKm: 100,
        dist: 50,
        isBusiness: true,
      }),
    });

    const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, {
      vehicle_id: "abc",
    });
    expect(res.statusCode).toBe(400);

    const unchanged = await prisma.trips.findUnique({ where: { id: trip.id } });
    expect(unchanged!.vehicle_id).toBe(vehicleAId);
  });
});

describe("GET /api/admin/trips/stats", () => {
  it("aggregates over the WHOLE filtered set, beyond one 25-row page", async () => {
    // 20 business trips of 10 km + 10 private trips of 7 km in 2098-05.
    const rows = [];
    for (let i = 0; i < 20; i++) {
      rows.push(
        tripRow({
          userId: adminUserId,
          vehicleId: vehicleAId,
          date: "2098-05-10",
          startKm: 1000 + i * 10,
          dist: 10,
          isBusiness: true,
        }),
      );
    }
    for (let i = 0; i < 10; i++) {
      rows.push(
        tripRow({
          userId: adminUserId,
          vehicleId: vehicleBId,
          date: "2098-05-15",
          startKm: 2000 + i * 7,
          dist: 7,
          isBusiness: false,
        }),
      );
    }
    await prisma.trips.createMany({ data: rows });

    // The list truncates to the default 25-row page but reports the real total…
    const listRes = await authGet(
      "/api/admin/trips?month=5&year=2098",
      adminToken,
    );
    expect(listRes.statusCode).toBe(200);
    const listBody = listRes.json();
    expect(listBody.data).toHaveLength(25);
    expect(listBody.pagination.total).toBe(30);
    expect(listBody.pagination.total_pages).toBe(2);

    // …while the stats endpoint covers all 30 rows.
    const res = await authGet(
      "/api/admin/trips/stats?month=5&year=2098",
      adminToken,
    );
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.success).toBe(true);
    expect(body.data).toEqual({
      count: 30,
      total_km: 20 * 10 + 10 * 7,
      business_km: 200,
      private_km: 70,
    });
  });

  it("honors the month filter in both wire formats", async () => {
    await prisma.trips.createMany({
      data: [
        tripRow({
          userId: adminUserId,
          vehicleId: vehicleAId,
          date: "2098-06-10",
          startKm: 100,
          dist: 10,
          isBusiness: true,
        }),
        tripRow({
          userId: adminUserId,
          vehicleId: vehicleAId,
          date: "2098-06-12",
          startKm: 110,
          dist: 20,
          isBusiness: false,
        }),
        tripRow({
          userId: adminUserId,
          vehicleId: vehicleAId,
          date: "2098-07-05",
          startKm: 130,
          dist: 40,
          isBusiness: true,
        }),
      ],
    });

    // Combined "YYYY-MM" format (TripsHistory)
    const june = await authGet(
      "/api/admin/trips/stats?month=2098-06",
      adminToken,
    );
    expect(june.statusCode).toBe(200);
    expect(june.json().data).toEqual({
      count: 2,
      total_km: 30,
      business_km: 10,
      private_km: 20,
    });

    // Split month+year format (TripsAdmin)
    const july = await authGet(
      "/api/admin/trips/stats?month=7&year=2098",
      adminToken,
    );
    expect(july.statusCode).toBe(200);
    expect(july.json().data).toEqual({
      count: 1,
      total_km: 40,
      business_km: 40,
      private_km: 0,
    });
  });

  it("scopes non-managers to their own trips (user_id param ignored)", async () => {
    await prisma.trips.createMany({
      data: [
        // 2 trips for the scope user, 3 for the admin — same month.
        tripRow({
          userId: scopeUserId,
          vehicleId: vehicleAId,
          date: "2098-08-05",
          startKm: 100,
          dist: 10,
          isBusiness: true,
        }),
        tripRow({
          userId: scopeUserId,
          vehicleId: vehicleAId,
          date: "2098-08-06",
          startKm: 110,
          dist: 15,
          isBusiness: false,
        }),
        tripRow({
          userId: adminUserId,
          vehicleId: vehicleBId,
          date: "2098-08-07",
          startKm: 200,
          dist: 100,
          isBusiness: true,
        }),
        tripRow({
          userId: adminUserId,
          vehicleId: vehicleBId,
          date: "2098-08-08",
          startKm: 300,
          dist: 100,
          isBusiness: true,
        }),
        tripRow({
          userId: adminUserId,
          vehicleId: vehicleBId,
          date: "2098-08-09",
          startKm: 400,
          dist: 100,
          isBusiness: true,
        }),
      ],
    });

    // Non-manager: only own trips, even when explicitly requesting another user.
    const own = await authGet(
      "/api/admin/trips/stats?month=8&year=2098",
      scopeToken,
    );
    expect(own.statusCode).toBe(200);
    expect(own.json().data).toEqual({
      count: 2,
      total_km: 25,
      business_km: 10,
      private_km: 15,
    });

    const spoofed = await authGet(
      `/api/admin/trips/stats?month=8&year=2098&user_id=${adminUserId}`,
      scopeToken,
    );
    expect(spoofed.statusCode).toBe(200);
    expect(spoofed.json().data.count).toBe(2);

    // The list applies the exact same scoping.
    const list = await authGet(
      "/api/admin/trips?month=8&year=2098",
      scopeToken,
    );
    expect(list.statusCode).toBe(200);
    expect(list.json().data).toHaveLength(2);

    // Manager (admin) sees everything in the month.
    const all = await authGet(
      "/api/admin/trips/stats?month=8&year=2098",
      adminToken,
    );
    expect(all.statusCode).toBe(200);
    expect(all.json().data.count).toBe(5);
    expect(all.json().data.total_km).toBe(325);

    // Manager can filter to a single user.
    const filtered = await authGet(
      `/api/admin/trips/stats?month=8&year=2098&user_id=${scopeUserId}`,
      adminToken,
    );
    expect(filtered.statusCode).toBe(200);
    expect(filtered.json().data.count).toBe(2);
  });
});
