import type { Server as SocketIOServer } from "socket.io";

import type { DeviceStateSnapshot, NormalizedTelemetry, VehicleAlertSummary } from "@tracker/shared";

import { getVehicleStatus } from "@tracker/shared";

import { env } from "../../config/env.js";
import { query, queryOne } from "../../lib/db.js";
import { getRedis } from "../../lib/redis.js";
import { sendInspectionDueSoonEmailIfNeeded, sendServiceDueSoonEmailIfNeeded } from "../settings/mail.service.js";
import { syncVehicleTripFromTelemetry } from "../vehicles/trips.service.js";

type PersistedTelemetry = {
  deviceId: string;
  vehicleId: string | null;
  status: "online" | "delayed" | "offline";
};

type UpsertedDevice = {
  id: string;
  vehicle_id: string | null;
  ignition: boolean | null;
};

const AUDIENCE_CACHE_TTL_MS = 60_000;
const INSPECTION_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;

export class TelemetryService {
  private readonly audienceCache = new Map<string, { userIds: string[]; expiresAt: number }>();
  private readonly lastInspectionCheckAt = new Map<string, number>();

  constructor(private readonly io: SocketIOServer) {}

  async ingestTelemetry(telemetry: NormalizedTelemetry): Promise<PersistedTelemetry> {
    const serverTime = telemetry.serverTime || new Date().toISOString();
    const device = await this.upsertDevice({
      imei: telemetry.imei,
      protocolName: telemetry.protocolName,
      rawPayload: telemetry.rawPayload,
      serverTime,
      deviceTime: telemetry.deviceTime ?? null,
      battery: telemetry.battery ?? null,
      gsmSignal: telemetry.gsmSignal ?? null,
      ignition: telemetry.ignition ?? null,
      charging: telemetry.charging ?? null,
      defense: telemetry.defense ?? null,
      gpsValid: telemetry.gpsValid ?? null,
      satelliteCount: telemetry.satelliteCount ?? null,
      positionType: telemetry.positionType ?? null,
    });

    const resolvedTelemetry: NormalizedTelemetry = {
      ...telemetry,
      ignition: telemetry.ignition ?? device.ignition ?? undefined,
    };

    await this.persistPosition(device, resolvedTelemetry, serverTime);
    await syncVehicleTripFromTelemetry({
      vehicleId: device.vehicle_id,
      deviceId: device.id,
      ignition: resolvedTelemetry.ignition,
      lat: resolvedTelemetry.lat,
      lon: resolvedTelemetry.lon,
      eventTime: resolvedTelemetry.deviceTime ?? serverTime,
    });
    await this.handleInspectionDueSoonNotification(device.vehicle_id);
    const alert = await this.createOverspeedAlert(device, resolvedTelemetry, serverTime);

    const status = getVehicleStatus(
      serverTime,
      env.DEVICE_ONLINE_SECONDS,
      env.DEVICE_DELAYED_SECONDS,
    );

    const redis = await getRedis();
    if (redis) {
      await redis.set(`last_position:${telemetry.imei}`, JSON.stringify(resolvedTelemetry));
      await redis.set(`device_seen:${telemetry.imei}`, serverTime);
    }

    if (alert) {
      await this.broadcastVehicleAlert(alert);
    }

    await this.broadcastTelemetry(resolvedTelemetry, device.vehicle_id, status);

    return {
      deviceId: device.id,
      vehicleId: device.vehicle_id,
      status,
    };
  }

  private async handleInspectionDueSoonNotification(vehicleId: string | null) {
    if (!vehicleId) {
      return;
    }

    const now = Date.now();
    const lastCheckedAt = this.lastInspectionCheckAt.get(vehicleId) ?? 0;
    if (now - lastCheckedAt < INSPECTION_CHECK_INTERVAL_MS) {
      return;
    }
    this.lastInspectionCheckAt.set(vehicleId, now);

    const vehicle = await queryOne<{
      id: string;
      name: string;
      plate_number: string | null;
      inspection_due_date: string | null;
      oil_change_due_date: string | null;
      oil_change_odometer: number | null;
      oil_change_interval_km: number | null;
      tire_changed_at: string | null;
      next_tire_change_due_date: string | null;
    }>(
      `
        select
          id,
          name,
          plate_number,
          inspection_due_date,
          oil_change_due_date,
          oil_change_odometer,
          oil_change_interval_km,
          tire_changed_at,
          next_tire_change_due_date
        from vehicles
        where id = $1
      `,
      [vehicleId],
    );

    if (!vehicle) {
      return;
    }

    try {
      await sendInspectionDueSoonEmailIfNeeded({
        vehicleId: vehicle.id,
        vehicleName: vehicle.name,
        plateNumber: vehicle.plate_number,
        inspectionDueDate: vehicle.inspection_due_date,
      });
    } catch (error) {
      console.error("inspection due soon email failed", {
        vehicleId: vehicle.id,
        error,
      });
    }

    const fixedRecipientAlerts: Array<{
      serviceType: "inspection" | "oil" | "tires";
      dueDate: string | null;
      enabled: boolean;
    }> = [
      {
        serviceType: "inspection",
        dueDate: vehicle.inspection_due_date,
        enabled: Boolean(vehicle.inspection_due_date),
      },
      {
        serviceType: "oil",
        dueDate: vehicle.oil_change_due_date,
        enabled: Boolean(
          vehicle.oil_change_due_date
          && vehicle.oil_change_odometer != null
          && vehicle.oil_change_interval_km != null
        ),
      },
      {
        serviceType: "tires",
        dueDate: vehicle.next_tire_change_due_date,
        enabled: Boolean(vehicle.tire_changed_at && vehicle.next_tire_change_due_date),
      },
    ];

    for (const alert of fixedRecipientAlerts) {
      if (!alert.enabled) {
        continue;
      }

      try {
        await sendServiceDueSoonEmailIfNeeded({
          vehicleId: vehicle.id,
          vehicleName: vehicle.name,
          plateNumber: vehicle.plate_number,
          dueDate: alert.dueDate,
          serviceType: alert.serviceType,
        });
      } catch (error) {
        console.error("service due soon email failed", {
          vehicleId: vehicle.id,
          serviceType: alert.serviceType,
          error,
        });
      }
    }
  }

  async ingestDeviceState(input: {
    imei: string;
    protocolName: string;
    rawPayload: string;
    serverTime?: string;
    deviceState?: DeviceStateSnapshot;
  }): Promise<PersistedTelemetry> {
    const serverTime = input.serverTime || new Date().toISOString();
    const device = await this.upsertDevice({
      imei: input.imei,
      protocolName: input.protocolName,
      rawPayload: input.rawPayload,
      serverTime,
      deviceTime: input.deviceState?.deviceTime ?? null,
      battery: input.deviceState?.battery ?? null,
      gsmSignal: input.deviceState?.gsmSignal ?? null,
      ignition: input.deviceState?.ignition ?? null,
      charging: input.deviceState?.charging ?? null,
      defense: input.deviceState?.defense ?? null,
      gpsValid: input.deviceState?.gpsValid ?? null,
      satelliteCount: input.deviceState?.satelliteCount ?? null,
      positionType: input.deviceState?.positionType ?? null,
    });

    const status = getVehicleStatus(
      serverTime,
      env.DEVICE_ONLINE_SECONDS,
      env.DEVICE_DELAYED_SECONDS,
    );

    return {
      deviceId: device.id,
      vehicleId: device.vehicle_id,
      status,
    };
  }

  private async upsertDevice(input: {
    imei: string;
    protocolName: string;
    rawPayload: string;
    serverTime: string;
    deviceTime?: string | null;
    battery?: number | null;
    gsmSignal?: number | null;
    ignition?: boolean | null;
    charging?: boolean | null;
    defense?: boolean | null;
    gpsValid?: boolean | null;
    satelliteCount?: number | null;
    positionType?: string | null;
  }) {
    const device = await queryOne<UpsertedDevice>(
      `
        insert into devices (
          imei,
          protocol_name,
          status,
          last_seen,
          last_device_time,
          battery,
          gsm_signal,
          ignition,
          charging,
          defense,
          gps_valid,
          satellite_count,
          position_type
        )
        values ($1, $2, 'online', $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
        on conflict (imei)
        do update set
          protocol_name = excluded.protocol_name,
          status = 'online',
          last_seen = excluded.last_seen,
          last_device_time = coalesce(excluded.last_device_time, devices.last_device_time),
          battery = coalesce(excluded.battery, devices.battery),
          gsm_signal = coalesce(excluded.gsm_signal, devices.gsm_signal),
          ignition = coalesce(excluded.ignition, devices.ignition),
          charging = coalesce(excluded.charging, devices.charging),
          defense = coalesce(excluded.defense, devices.defense),
          gps_valid = coalesce(excluded.gps_valid, devices.gps_valid),
          satellite_count = coalesce(excluded.satellite_count, devices.satellite_count),
          position_type = coalesce(excluded.position_type, devices.position_type),
          updated_at = now()
        returning id, vehicle_id, ignition
      `,
      [
        input.imei,
        input.protocolName,
        input.serverTime,
        input.deviceTime ?? null,
        input.battery ?? null,
        input.gsmSignal ?? null,
        input.ignition ?? null,
        input.charging ?? null,
        input.defense ?? null,
        input.gpsValid ?? null,
        input.satelliteCount ?? null,
        input.positionType ?? null,
      ],
    );

    if (!device) {
      throw new Error("Unable to upsert device");
    }

    if (!device.vehicle_id) {
      await query(
        `
          insert into unassigned_devices (imei, protocol_name, raw_payload, first_seen, last_seen)
          values ($1, $2, $3, $4, $4)
          on conflict (imei)
          do update set protocol_name = excluded.protocol_name, raw_payload = excluded.raw_payload, last_seen = excluded.last_seen
        `,
        [input.imei, input.protocolName, input.rawPayload, input.serverTime],
      );
    }

    return device;
  }

  private async persistPosition(
    device: UpsertedDevice,
    telemetry: NormalizedTelemetry,
    serverTime: string,
  ) {
    await query(
      `
        with latest_saved_position as materialized (
          select
            vehicle_id,
            speed,
            server_time,
            ignition,
            gps_valid,
            charging,
            defense,
            position_type,
            geom
          from positions
          where device_id = $1
            and not (
              $12::boolean is true
              or coalesce($10::double precision, 0) > 0
              or coalesce($10::double precision, 0) > 140
            )
          order by server_time desc
          limit 1
        ),
        inserted_position as (
          insert into positions (
            device_id,
            vehicle_id,
            imei,
            protocol_name,
            raw_payload,
            device_time,
            server_time,
            lat,
            lon,
            speed,
            heading,
            ignition,
            battery,
            gsm_signal,
            satellite_count,
            gps_valid,
            charging,
            defense,
            position_type,
            geom
          )
          select
            $1,
            $2,
            $3,
            $4,
            $5,
            $6,
            $7,
            $8,
            $9,
            $10,
            $11,
            $12,
            $13,
            $14,
            $15,
            $16,
            $17,
            $18,
            $19,
            ST_SetSRID(ST_MakePoint($9, $8), 4326)::geography
          where
            $12::boolean is true
            or coalesce($10::double precision, 0) > 0
            or coalesce($10::double precision, 0) > 140
            or not exists (select 1 from latest_saved_position)
            or exists (
              select 1
              from latest_saved_position previous
              where
                previous.vehicle_id is distinct from $2::uuid
                or ($12::boolean is not null and previous.ignition is distinct from $12::boolean)
                or (
                  $10::double precision is not null
                  and (coalesce(previous.speed, 0) > 0) is distinct from ($10::double precision > 0)
                )
                or ($16::boolean is not null and previous.gps_valid is distinct from $16::boolean)
                or ($17::boolean is not null and previous.charging is distinct from $17::boolean)
                or ($18::boolean is not null and previous.defense is distinct from $18::boolean)
                or ($19::text is not null and previous.position_type is distinct from $19::text)
                or (
                  $12::boolean is false
                  and ST_Distance(
                    previous.geom,
                    ST_SetSRID(ST_MakePoint($9, $8), 4326)::geography
                  ) >= 50
                )
                or (
                  $12::boolean is not true
                  and coalesce($10::double precision, 0) <= 0
                  and $7::timestamptz - previous.server_time >= interval '15 minutes'
                )
            )
          returning id
        ),
        upserted_last_position as (
          insert into last_positions (
            device_id,
            vehicle_id,
            imei,
            lat,
            lon,
            speed,
            heading,
            device_time,
            server_time,
            ignition,
            battery,
            gsm_signal,
            satellite_count,
            gps_valid,
            charging,
            defense,
            position_type,
            geom
          )
          values (
            $1,
            $2,
            $3,
            $8,
            $9,
            $10,
            $11,
            $6,
            $7,
            $12,
            $13,
            $14,
            $15,
            $16,
            $17,
            $18,
            $19,
            ST_SetSRID(ST_MakePoint($9, $8), 4326)::geography
          )
          on conflict (device_id)
          do update set
            vehicle_id = excluded.vehicle_id,
            imei = excluded.imei,
            lat = excluded.lat,
            lon = excluded.lon,
            speed = excluded.speed,
            heading = excluded.heading,
            device_time = excluded.device_time,
            server_time = excluded.server_time,
            ignition = excluded.ignition,
            battery = excluded.battery,
            gsm_signal = excluded.gsm_signal,
            satellite_count = excluded.satellite_count,
            gps_valid = excluded.gps_valid,
            charging = excluded.charging,
            defense = excluded.defense,
            position_type = excluded.position_type,
            geom = excluded.geom,
            updated_at = now()
          returning device_id
        )
        select
          exists (select 1 from inserted_position) as position_persisted,
          exists (select 1 from upserted_last_position) as last_position_updated
      `,
      [
        device.id,
        device.vehicle_id,
        telemetry.imei,
        telemetry.protocolName,
        telemetry.rawPayload,
        telemetry.deviceTime ?? null,
        serverTime,
        telemetry.lat,
        telemetry.lon,
        telemetry.speed ?? null,
        telemetry.heading ?? null,
        telemetry.ignition ?? null,
        telemetry.battery ?? null,
        telemetry.gsmSignal ?? null,
        telemetry.satelliteCount ?? null,
        telemetry.gpsValid ?? null,
        telemetry.charging ?? null,
        telemetry.defense ?? null,
        telemetry.positionType ?? null,
      ],
    );
  }

  private async broadcastTelemetry(
    telemetry: NormalizedTelemetry,
    vehicleId: string | null,
    status: "online" | "delayed" | "offline",
  ) {
    const audience = await this.getAudienceUserIds(vehicleId);
    const { rawPayload: _rawPayload, ...clientTelemetry } = telemetry;

    const payload = {
      vehicleId,
      imei: telemetry.imei,
      status,
      telemetry: clientTelemetry,
    };

    for (const userId of audience) {
      this.io.to(`user:${userId}`).emit("telemetry:update", payload);
    }
  }

  private async getAudienceUserIds(vehicleId: string | null) {
    const cacheKey = vehicleId ?? "admins";
    const cached = this.audienceCache.get(cacheKey);
    const now = Date.now();
    if (cached && cached.expiresAt > now) {
      return cached.userIds;
    }

    const audience = vehicleId
      ? await query<{ id: string }>(
          `
            select id from users where role = 'admin'
            union
            select user_id as id from user_vehicle_permissions where vehicle_id = $1
          `,
          [vehicleId],
        )
      : await query<{ id: string }>("select id from users where role = 'admin'");
    const userIds = audience.map((user) => user.id);
    this.audienceCache.set(cacheKey, {
      userIds,
      expiresAt: now + AUDIENCE_CACHE_TTL_MS,
    });
    return userIds;
  }

  private async createOverspeedAlert(
    device: UpsertedDevice,
    telemetry: NormalizedTelemetry,
    serverTime: string,
  ): Promise<VehicleAlertSummary | null> {
    const speed = telemetry.speed ?? null;
    if (!device.vehicle_id || speed == null || speed <= 140 || telemetry.ignition !== true) {
      return null;
    }

    const updated = await queryOne<{
      id: string;
      vehicle_id: string;
      vehicle_name: string;
      vehicle_type: "passenger" | "delivery";
      imei: string | null;
      speed: number;
      triggered_at: string;
      acknowledged_at: string | null;
    }>(
      `
        with updated as (
          update vehicle_alerts alert
          set speed = $2
          where alert.id = (
            select existing.id
            from vehicle_alerts existing
            inner join vehicle_trips current_trip
              on current_trip.vehicle_id = existing.vehicle_id
              and current_trip.ended_at is null
            where existing.vehicle_id = $1
              and existing.alert_type = 'delivery-overspeed'
              and existing.acknowledged_at is null
              and existing.triggered_at >= current_trip.started_at
              and $2 > existing.speed
            order by existing.triggered_at desc
            limit 1
          )
          returning id, vehicle_id, imei, speed, triggered_at, acknowledged_at
        )
        select
          updated.id,
          updated.vehicle_id,
          vehicle.name as vehicle_name,
          vehicle.vehicle_type,
          updated.imei,
          updated.speed,
          updated.triggered_at,
          updated.acknowledged_at
        from updated
        inner join vehicles vehicle on vehicle.id = updated.vehicle_id
      `,
      [device.vehicle_id, speed],
    );

    if (updated) {
      return {
        id: updated.id,
        vehicleId: updated.vehicle_id,
        vehicleName: updated.vehicle_name,
        vehicleKind: updated.vehicle_type,
        imei: updated.imei,
        speed: updated.speed,
        speedRecordedAt: telemetry.deviceTime ?? serverTime,
        triggeredAt: updated.triggered_at,
        acknowledgedAt: updated.acknowledged_at,
      };
    }

    const recentSamples = await query<{
      speed: number | null;
      ignition: boolean | null;
      server_time: string;
    }>(
      `
        select speed, ignition, server_time
        from positions
        where device_id = $1
        order by server_time desc
        limit 3
      `,
      [device.id],
    );

    const [latestSample, previousSample] = recentSamples;
    const latestSampleTime = latestSample ? new Date(latestSample.server_time).getTime() : Number.NaN;
    const previousSampleTime = previousSample ? new Date(previousSample.server_time).getTime() : Number.NaN;
    const samplesAreCloseEnough = !Number.isNaN(latestSampleTime)
      && !Number.isNaN(previousSampleTime)
      && latestSampleTime - previousSampleTime <= 30_000;
    const hasSustainedOverspeed = Boolean(
      latestSample
      && previousSample
      && latestSample.ignition === true
      && previousSample.ignition === true
      && (latestSample.speed ?? 0) > 140
      && (previousSample.speed ?? 0) > 110
      && samplesAreCloseEnough,
    );

    if (!hasSustainedOverspeed) {
      return null;
    }

    const inserted = await queryOne<{
      id: string;
      vehicle_id: string;
      vehicle_name: string;
      vehicle_type: "passenger" | "delivery";
      imei: string | null;
      speed: number;
      triggered_at: string;
      acknowledged_at: string | null;
    }>(
      `
        with inserted as (
          insert into vehicle_alerts (
            vehicle_id,
            device_id,
            imei,
            alert_type,
            message,
            speed,
            triggered_at
          )
          select
            v.id,
            $2,
            $3,
            'delivery-overspeed',
            $4,
            $5,
            $6
          from vehicles v
          where v.id = $1
            and v.vehicle_type = 'delivery'
            and not exists (
              select 1
              from vehicle_alerts existing
              inner join vehicle_trips current_trip
                on current_trip.vehicle_id = existing.vehicle_id
                and current_trip.ended_at is null
              where existing.vehicle_id = v.id
                and existing.alert_type = 'delivery-overspeed'
                and existing.acknowledged_at is null
                and existing.triggered_at >= current_trip.started_at
            )
          returning id, vehicle_id, imei, speed, triggered_at, acknowledged_at
        )
        select
          inserted.id,
          inserted.vehicle_id,
          v.name as vehicle_name,
          v.vehicle_type,
          inserted.imei,
          inserted.speed,
          inserted.triggered_at,
          inserted.acknowledged_at
        from inserted
        inner join vehicles v on v.id = inserted.vehicle_id
      `,
      [
        device.vehicle_id,
        device.id,
        telemetry.imei,
        `Przekroczenie ${Math.round(speed)} km/h przez ${telemetry.imei}`,
        speed,
        serverTime,
      ],
    );

    if (!inserted) {
      return null;
    }

    return {
      id: inserted.id,
      vehicleId: inserted.vehicle_id,
      vehicleName: inserted.vehicle_name,
      vehicleKind: inserted.vehicle_type,
      imei: inserted.imei,
      speed: inserted.speed,
      speedRecordedAt: telemetry.deviceTime ?? serverTime,
      triggeredAt: inserted.triggered_at,
      acknowledgedAt: inserted.acknowledged_at,
    };
  }

  private async broadcastVehicleAlert(alert: VehicleAlertSummary) {
    const audience = await this.getAudienceUserIds(alert.vehicleId);

    for (const userId of audience) {
      this.io.to(`user:${userId}`).emit("vehicle-alert:update", alert);
    }
  }
}
