import { prisma } from '../prisma.js';
import { config } from '../config.js';
import type { Prisma } from '../generated/prisma/client.js';

const googleNearbySearchUrl =
  'https://places.googleapis.com/v1/places:searchNearby';
const maxNearbyRadiusMeters = 500;
const nearbyPlaceTypes = [
  'restaurant',
  'cafe',
  'bar',
  'bakery',
  'meal_takeaway',
  'meal_delivery',
];

type NearbyPlacesInput = {
  latitude: number;
  longitude: number;
  radiusMeters: number;
};

type GoogleNearbyPlace = {
  id?: string;
  displayName?: {
    text?: string;
  };
  primaryType?: string;
  types?: string[];
  location?: {
    latitude?: number;
    longitude?: number;
  };
};

type GoogleNearbyResponse = {
  places?: GoogleNearbyPlace[];
};

type PersistableGooglePlace = {
  googlePlaceId: string;
  name: string;
  latitude: number;
  longitude: number;
  googlePrimaryType: string | null;
  googleTypes: string[];
};

type PlaceWithRecentExperiences = Prisma.PlaceGetPayload<{
  include: {
    experiences: {
      include: {
        user: true;
      };
    };
  };
}>;

function serializeVoiceExperience(
  experience: Awaited<ReturnType<typeof prisma.voiceExperience.findMany>>[number],
) {
  return {
    ...experience,
    createdAt: experience.createdAt.toISOString(),
  };
}

function serializePlace(place: PlaceWithRecentExperiences) {
  return {
    ...place,
    experiences: place.experiences.map(serializeVoiceExperience),
  };
}

export async function listPlaces() {
  const places = await prisma.place.findMany({
    include: {
      experiences: {
        orderBy: { createdAt: 'desc' },
        take: 10,
        include: { user: true },
      },
    },
    orderBy: { updatedAt: 'desc' },
  });

  return places.map(serializePlace);
}

export async function listNearbyPlaces(input: NearbyPlacesInput) {
  if (!Number.isFinite(input.latitude) || !Number.isFinite(input.longitude)) {
    throw new Error('Nearby place search requires a valid coordinate.');
  }
  if (
    !Number.isInteger(input.radiusMeters) ||
    input.radiusMeters <= 0 ||
    input.radiusMeters > maxNearbyRadiusMeters
  ) {
    throw new Error(
      `Nearby place radius must be from 1 to ${maxNearbyRadiusMeters} meters.`,
    );
  }
  if (config.googlePlacesApiKey === '') {
    throw new Error('GOOGLE_PLACES_API_KEY is required for nearby place search.');
  }

  const response = await fetch(googleNearbySearchUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Goog-Api-Key': config.googlePlacesApiKey,
      'X-Goog-FieldMask': [
        'places.id',
        'places.displayName',
        'places.location',
        'places.primaryType',
        'places.types',
      ].join(','),
    },
    body: JSON.stringify({
      includedTypes: nearbyPlaceTypes,
      maxResultCount: 20,
      rankPreference: 'DISTANCE',
      locationRestriction: {
        circle: {
          center: {
            latitude: input.latitude,
            longitude: input.longitude,
          },
          radius: input.radiusMeters,
        },
      },
    }),
  });

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`Google Nearby Search failed: ${response.status} ${body}`);
  }

  const payload = (await response.json()) as GoogleNearbyResponse;
  const googlePlaces = parseGoogleNearbyPlaces(input, payload);
  await upsertGooglePlaces(googlePlaces);

  return listPersistedNearbyPlaces(input);
}

export async function listVoiceExperiences() {
  const experiences = await prisma.voiceExperience.findMany({
    include: { place: true, user: true },
    orderBy: { createdAt: 'desc' },
    take: 100,
  });

  return experiences.map(serializeVoiceExperience);
}

function distanceMeters(
  fromLatitude: number,
  fromLongitude: number,
  toLatitude: number,
  toLongitude: number,
) {
  const earthRadiusMeters = 6371000;
  const fromLat = degreesToRadians(fromLatitude);
  const toLat = degreesToRadians(toLatitude);
  const deltaLat = degreesToRadians(toLatitude - fromLatitude);
  const deltaLon = degreesToRadians(toLongitude - fromLongitude);
  const a =
    Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
    Math.cos(fromLat) *
      Math.cos(toLat) *
      Math.sin(deltaLon / 2) *
      Math.sin(deltaLon / 2);
  return earthRadiusMeters * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

function degreesToRadians(value: number) {
  return (value * Math.PI) / 180;
}

function parseGoogleNearbyPlaces(
  input: NearbyPlacesInput,
  payload: GoogleNearbyResponse,
) {
  const placesByGoogleId = new Map<string, PersistableGooglePlace>();
  for (const place of payload.places ?? []) {
    const googlePlaceId = place.id;
    const name = place.displayName?.text;
    const latitude = place.location?.latitude;
    const longitude = place.location?.longitude;
    const googleTypes = place.types ?? [];
    if (
      !googlePlaceId ||
      !name ||
      latitude === undefined ||
      longitude === undefined
    ) {
      continue;
    }
    if (
      distanceMeters(input.latitude, input.longitude, latitude, longitude) >
      input.radiusMeters
    ) {
      continue;
    }

    placesByGoogleId.set(googlePlaceId, {
      googlePlaceId,
      name,
      latitude,
      longitude,
      googlePrimaryType: place.primaryType ?? null,
      googleTypes,
    });
  }

  return [...placesByGoogleId.values()];
}

async function upsertGooglePlaces(places: PersistableGooglePlace[]) {
  await Promise.all(
    places.map((place) =>
      prisma.place.upsert({
        where: { googlePlaceId: place.googlePlaceId },
        create: place,
        update: {
          name: place.name,
          latitude: place.latitude,
          longitude: place.longitude,
          googlePrimaryType: place.googlePrimaryType,
          googleTypes: place.googleTypes,
        },
      }),
    ),
  );
}

async function listPersistedNearbyPlaces(input: NearbyPlacesInput) {
  const bounds = coordinateBounds(
    input.latitude,
    input.longitude,
    input.radiusMeters,
  );
  const places = await prisma.place.findMany({
    where: {
      latitude: { gte: bounds.minLatitude, lte: bounds.maxLatitude },
      longitude: { gte: bounds.minLongitude, lte: bounds.maxLongitude },
    },
    include: {
      experiences: {
        orderBy: { createdAt: 'desc' },
        take: 10,
        include: { user: true },
      },
    },
  });

  return places
    .map((place) => ({
      place,
      distanceMeters: distanceMeters(
        input.latitude,
        input.longitude,
        place.latitude,
        place.longitude,
      ),
    }))
    .filter(({ distanceMeters }) => distanceMeters <= input.radiusMeters)
    .sort((a, b) => a.distanceMeters - b.distanceMeters)
    .map(({ place }) => serializePlace(place));
}

function coordinateBounds(
  latitude: number,
  longitude: number,
  radiusMeters: number,
) {
  const metersPerLatitudeDegree = 111320;
  const latitudeDelta = radiusMeters / metersPerLatitudeDegree;
  const longitudeScale = Math.max(
    Math.cos(degreesToRadians(latitude)),
    0.000001,
  );
  const longitudeDelta =
    radiusMeters / (metersPerLatitudeDegree * longitudeScale);

  return {
    minLatitude: latitude - latitudeDelta,
    maxLatitude: latitude + latitudeDelta,
    minLongitude: longitude - longitudeDelta,
    maxLongitude: longitude + longitudeDelta,
  };
}
