import { randomInt } from "node:crypto";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import type { AdbConfig } from "./adb.js";
import { launchAdbTarget } from "./adb.js";
import { bluetoothActionSchema, readBluetoothStatus, triggerBluetoothAction } from "./bluetooth.js";
import type { CarTelemetryRecorder } from "./car-telemetry.js";
import type { CompanionDatabase } from "./database.js";
import { hashToken, randomToken } from "./security.js";
import { collectSystemStatus } from "./status.js";
import { TripStore } from "./trips.js";

const pairingSchema = z.object({
  code: z.string().regex(/^\d{6}$/),
  deviceId: z.string().uuid(),
  deviceName: z.string().trim().min(1).max(80)
});

const locationSchema = z.object({
  capturedAt: z.string().datetime(),
  latitude: z.number().finite().min(-90).max(90),
  longitude: z.number().finite().min(-180).max(180),
  accuracyMeters: z.number().finite().min(0).max(10_000),
  altitudeMeters: z.number().finite().min(-1_000).max(20_000).nullable().optional(),
  speedKph: z.number().finite().min(0).max(1_000).nullable().optional(),
  bearingDegrees: z.number().finite().min(0).max(360).nullable().optional(),
  batteryPercent: z.number().finite().min(0).max(100).nullable().optional(),
  networkType: z.enum(["offline", "wifi", "cellular", "ethernet", "other"]).nullable().optional()
});

type MobileDevice = { id: string; name: string; createdAt: string; lastSeenAt: string; revokedAt: string | null };
type AuthenticatedMobileDevice = { id: string; name: string };

export function isLoopbackAddress(address: string): boolean {
  return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
}

function requireLoopback(request: FastifyRequest, reply: FastifyReply): boolean {
  if (isLoopbackAddress(request.ip)) return true;
  void reply.code(404).send({ error: { code: "NOT_FOUND", message: "Not found" } });
  return false;
}

function authenticateMobile(database: CompanionDatabase, request: FastifyRequest, reply: FastifyReply): AuthenticatedMobileDevice | null {
  if (!requireLoopback(request, reply)) return null;
  const token = request.headers["x-mobile-token"];
  if (typeof token !== "string" || token.length < 32) {
    void reply.code(401).send({ error: { code: "MOBILE_AUTH_REQUIRED", message: "Pair the phone again" } });
    return null;
  }
  const device = database.prepare(
    "SELECT id, name FROM mobile_devices WHERE token_hash = ? AND revoked_at IS NULL"
  ).get(hashToken(token)) as AuthenticatedMobileDevice | undefined;
  if (!device) {
    void reply.code(401).send({ error: { code: "MOBILE_AUTH_INVALID", message: "This phone is no longer paired" } });
    return null;
  }
  database.prepare("UPDATE mobile_devices SET last_seen_at = ? WHERE id = ?").run(new Date().toISOString(), device.id);
  return device;
}

function auditMobile(database: CompanionDatabase, device: AuthenticatedMobileDevice, action: string, result: "success" | "failure", details: Record<string, unknown> = {}) {
  database.prepare(
    "INSERT INTO audit_events (action, result, details_json, created_at) VALUES (?, ?, ?, ?)"
  ).run(action, result, JSON.stringify({ mobileDeviceId: device.id, mobileDeviceName: device.name, ...details }), new Date().toISOString());
}

export function registerMobileRoutes(
  app: FastifyInstance,
  database: CompanionDatabase,
  adbConfig: AdbConfig,
  carTelemetry: CarTelemetryRecorder,
  requireWebUser: (request: FastifyRequest, reply: FastifyReply) => boolean
): void {
  const trips = new TripStore(database);

  app.post("/api/mobile/pairing", async (request, reply) => {
    if (!requireWebUser(request, reply)) return;
    const code = String(randomInt(100_000, 1_000_000));
    const now = new Date();
    const expiresAt = new Date(now.getTime() + 10 * 60_000).toISOString();
    database.prepare(
      `INSERT INTO mobile_pairing_codes (id, code_hash, expires_at, created_by, created_at)
       VALUES (1, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET
       code_hash = excluded.code_hash, expires_at = excluded.expires_at,
       created_by = excluded.created_by, created_at = excluded.created_at`
    ).run(hashToken(code), expiresAt, request.authUser!.id, now.toISOString());
    return { code, expiresAt };
  });

  app.get("/api/mobile/devices", async (request, reply) => {
    if (!requireWebUser(request, reply)) return;
    const devices = database.prepare(
      `SELECT id, name, created_at AS createdAt, last_seen_at AS lastSeenAt, revoked_at AS revokedAt
       FROM mobile_devices ORDER BY created_at DESC`
    ).all() as MobileDevice[];
    return { devices };
  });

  app.get("/api/trips", async (request, reply) => {
    if (!requireWebUser(request, reply)) return;
    return trips.list();
  });

  app.get<{ Params: { id: string } }>("/api/trips/:id", async (request, reply) => {
    if (!requireWebUser(request, reply)) return;
    const id = Number(request.params.id);
    if (!Number.isInteger(id) || id < 1) {
      return reply.code(400).send({ error: { code: "INVALID_TRIP", message: "That trip is invalid" } });
    }
    const trip = trips.detail(id);
    if (!trip) return reply.code(404).send({ error: { code: "TRIP_NOT_FOUND", message: "Trip not found" } });
    return trip;
  });

  app.get("/api/mobile/bluetooth", async (request, reply) => {
    if (!requireWebUser(request, reply)) return;
    return readBluetoothStatus();
  });

  app.post("/api/mobile/bluetooth", { config: { rateLimit: { max: 12, timeWindow: "1 minute" } } }, async (request, reply) => {
    if (!requireWebUser(request, reply)) return;
    const parsed = z.object({ action: bluetoothActionSchema }).safeParse(request.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: { code: "INVALID_BLUETOOTH_ACTION", message: "That Bluetooth action is not available" } });
    }
    try {
      return await triggerBluetoothAction(parsed.data.action);
    } catch {
      return reply.code(503).send({
        error: { code: "BLUETOOTH_ACTION_FAILED", message: "The Pi could not change its Bluetooth state" }
      });
    }
  });

  app.delete<{ Params: { id: string } }>("/api/mobile/devices/:id", async (request, reply) => {
    if (!requireWebUser(request, reply)) return;
    const result = database.prepare("UPDATE mobile_devices SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL")
      .run(new Date().toISOString(), request.params.id);
    if (result.changes === 0) return reply.code(404).send({ error: { code: "MOBILE_DEVICE_NOT_FOUND", message: "Phone not found" } });
    return reply.code(204).send();
  });

  app.post("/internal/mobile/pair", { config: { rateLimit: { max: 8, timeWindow: "1 minute" } } }, async (request, reply) => {
    if (!requireLoopback(request, reply)) return;
    const parsed = pairingSchema.safeParse(request.body);
    if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_PAIRING_REQUEST", message: "Enter the six-digit pairing code" } });
    const row = database.prepare("SELECT code_hash, expires_at FROM mobile_pairing_codes WHERE id = 1")
      .get() as { code_hash: string; expires_at: string } | undefined;
    if (!row || row.code_hash !== hashToken(parsed.data.code) || row.expires_at <= new Date().toISOString()) {
      return reply.code(401).send({ error: { code: "PAIRING_CODE_INVALID", message: "The pairing code is invalid or expired" } });
    }
    const token = randomToken();
    const now = new Date().toISOString();
    database.transaction(() => {
      database.prepare(
        `INSERT INTO mobile_devices (id, name, token_hash, created_at, last_seen_at, revoked_at)
         VALUES (?, ?, ?, ?, ?, NULL) ON CONFLICT(id) DO UPDATE SET
         name = excluded.name, token_hash = excluded.token_hash,
         last_seen_at = excluded.last_seen_at, revoked_at = NULL`
      ).run(parsed.data.deviceId, parsed.data.deviceName, hashToken(token), now, now);
      database.prepare("DELETE FROM mobile_pairing_codes WHERE id = 1").run();
    })();
    return { token, deviceId: parsed.data.deviceId, pairedAt: now };
  });

  app.get("/internal/mobile/snapshot", async (request, reply) => {
    const device = authenticateMobile(database, request, reply);
    if (!device) return;
    const [system, car] = await Promise.all([
      collectSystemStatus(adbConfig),
      carTelemetry.readLive().catch(() => null)
    ]);
    const shortcuts = database.prepare(
      "SELECT id, name, target_type AS type, target_value AS value FROM adb_shortcuts ORDER BY created_at, id"
    ).all();
    const phone = database.prepare(
      `SELECT received_at AS receivedAt, captured_at AS capturedAt, latitude, longitude,
       accuracy_meters AS accuracyMeters, altitude_meters AS altitudeMeters, speed_kph AS speedKph,
       bearing_degrees AS bearingDegrees, battery_percent AS batteryPercent, network_type AS networkType
       FROM mobile_phone_state WHERE device_id = ?`
    ).get(device.id) ?? null;
    return { protocolVersion: 1, collectedAt: new Date().toISOString(), system, car, statistics: carTelemetry.store.overview(), shortcuts, phone };
  });

  app.post("/internal/mobile/location", async (request, reply) => {
    const device = authenticateMobile(database, request, reply);
    if (!device) return;
    const parsed = locationSchema.safeParse(request.body);
    if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_PHONE_LOCATION", message: "The phone location was invalid" } });
    const value = parsed.data;
    const trip = database.transaction(() => {
      database.prepare(
        `INSERT INTO mobile_phone_state
         (device_id, received_at, captured_at, latitude, longitude, accuracy_meters, altitude_meters,
          speed_kph, bearing_degrees, battery_percent, network_type)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(device_id) DO UPDATE SET
         received_at = excluded.received_at, captured_at = excluded.captured_at,
         latitude = excluded.latitude, longitude = excluded.longitude, accuracy_meters = excluded.accuracy_meters,
         altitude_meters = excluded.altitude_meters, speed_kph = excluded.speed_kph,
         bearing_degrees = excluded.bearing_degrees, battery_percent = excluded.battery_percent,
         network_type = excluded.network_type`
      ).run(device.id, new Date().toISOString(), value.capturedAt, value.latitude, value.longitude, value.accuracyMeters,
        value.altitudeMeters ?? null, value.speedKph ?? null, value.bearingDegrees ?? null,
        value.batteryPercent ?? null, value.networkType ?? null);
      return trips.record({
        deviceId: device.id,
        capturedAt: value.capturedAt,
        latitude: value.latitude,
        longitude: value.longitude,
        accuracyMeters: value.accuracyMeters,
        altitudeMeters: value.altitudeMeters ?? null,
        speedKph: value.speedKph ?? null,
        bearingDegrees: value.bearingDegrees ?? null,
        phoneBatteryPercent: value.batteryPercent ?? null
      });
    })();
    return { accepted: true, trip };
  });

  app.post<{ Params: { id: string } }>("/internal/mobile/shortcuts/:id/launch", async (request, reply) => {
    const device = authenticateMobile(database, request, reply);
    if (!device) return;
    const id = Number(request.params.id);
    const shortcut = Number.isInteger(id) ? database.prepare(
      "SELECT target_type AS type, target_value AS value FROM adb_shortcuts WHERE id = ?"
    ).get(id) as { type: "component" | "package"; value: string } | undefined : undefined;
    if (!shortcut) return reply.code(404).send({ error: { code: "ADB_SHORTCUT_NOT_FOUND", message: "Shortcut not found" } });
    try {
      await launchAdbTarget(adbConfig, shortcut);
      auditMobile(database, device, "mobile.adb-launch", "success", { shortcutId: id });
      return { launched: true };
    } catch {
      auditMobile(database, device, "mobile.adb-launch", "failure", { shortcutId: id });
      return reply.code(503).send({ error: { code: "ADB_LAUNCH_FAILED", message: "The shortcut could not be opened" } });
    }
  });
}
