import { prisma } from '../prisma.js';
import { config } from '../config.js';
import type { Prisma } from '../generated/prisma/client.js';
import { classifyPlaceIntent } from '../ai/place-intent.js';
import { transcribeAudioContent } from '../audio/deepgram.js';
import {
  fetchGooglePlaceDetails,
  normalizeGooglePlaceDetails,
  type GooglePlacePhoto,
} from '../google/places.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 VoicePlaceRecommendationsInput = {
  audioContentBase64: string;
  audioMimeType: string;
};

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: {
    favoriteUsers: true;
    experiences: {
      include: {
        user: true;
      };
    };
  };
}>;

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

function serializePlace(place: PlaceWithRecentExperiences, userId?: string) {
  return {
    ...place,
    googleRegularOpeningHours: place.googleRegularOpeningHours,
    googleCurrentOpeningHours: place.googleCurrentOpeningHours,
    googlePayload: place.googlePayload,
    googlePhotos: place.googlePhotos,
    photoUrls: googlePhotoUrls(place.googlePhotos),
    photoAttributions: googlePhotoAttributions(place.googlePhotos),
    traits: placeTraits(place),
    isFavorite:
      userId === undefined
        ? false
        : place.favoriteUsers.some((favorite) => favorite.userId === userId),
    experiences: place.experiences.map(serializeVoiceExperience),
  };
}

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

  await enrichPlacesMissingGooglePayload(places);
  const refreshedPlaces = await loadPlacesByIds(places.map((place) => place.id));
  return refreshedPlaces.map((place) => serializePlace(place, userId));
}

export async function listFavoritePlaces(userId: string) {
  const favorites = await prisma.userFavoritePlace.findMany({
    where: { userId },
    include: {
      place: {
        include: {
          favoriteUsers: true,
          experiences: {
            orderBy: { createdAt: 'desc' },
            take: 10,
            include: { user: true },
          },
        },
      },
    },
    orderBy: { createdAt: 'desc' },
  });

  return favorites.map((favorite) => serializePlace(favorite.place, userId));
}

export async function listNearbyPlaces(
  input: NearbyPlacesInput,
  userId: string,
) {
  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 nearbyPlaces = parseGoogleNearbyPlaces(input, payload);
  const storedPlaces = await prisma.place.findMany({
    where: {
      googlePlaceId: {
        in: nearbyPlaces.map((place) => place.googlePlaceId),
      },
    },
    include: {
      favoriteUsers: true,
      experiences: {
        orderBy: { createdAt: 'desc' },
        take: 10,
        include: { user: true },
      },
    },
  });
  const storedByGoogleId = new Map(
    storedPlaces.map((place) => [place.googlePlaceId, place]),
  );

  return nearbyPlaces.map((place) => {
    const stored = storedByGoogleId.get(place.googlePlaceId);
    return stored
      ? serializePlace(stored, userId)
      : serializeGoogleNearbyPlace(place);
  });
}

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

  return experiences.map(serializeVoiceExperience);
}

export async function recommendPlacesByVoice(
  input: VoicePlaceRecommendationsInput,
  userId: string,
) {
  const transcript = await transcribeAudioContent(input);
  const intent = await classifyPlaceIntent({ transcript });
  const places = await prisma.place.findMany({
    where: {
      experiences: { some: {} },
    },
    include: {
      favoriteUsers: true,
      experiences: {
        orderBy: { createdAt: 'desc' },
        take: 10,
        include: { user: true },
      },
    },
    orderBy: { updatedAt: 'desc' },
    take: 50,
  });

  return {
    transcript,
    tags: intent.tags,
    summary: intent.summary,
    places: rankPlacesByVoiceTags(places, intent.tags, userId).map((place) =>
      serializePlace(place, userId),
    ),
  };
}

export async function addFavoritePlace(placeId: string, userId: string) {
  const place = await prisma.place.findUnique({ where: { id: placeId } });
  if (!place) {
    throw new Error(`Place ${placeId} was not found.`);
  }

  await prisma.userFavoritePlace.upsert({
    where: { userId_placeId: { userId, placeId } },
    create: { userId, placeId },
    update: {},
  });

  return loadSerializedPlace(placeId, userId);
}

export async function removeFavoritePlace(placeId: string, userId: string) {
  await prisma.userFavoritePlace.deleteMany({
    where: { userId, placeId },
  });

  return loadSerializedPlace(placeId, userId);
}

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()];
}

function serializeGoogleNearbyPlace(place: PersistableGooglePlace) {
  return {
    id: place.googlePlaceId,
    googlePlaceId: place.googlePlaceId,
    name: place.name,
    latitude: place.latitude,
    longitude: place.longitude,
    googlePrimaryType: place.googlePrimaryType,
    googleTypes: place.googleTypes,
    googleBusinessStatus: null,
    googleRating: null,
    googleUserRatingCount: null,
    googleRegularOpeningHours: null,
    googleCurrentOpeningHours: null,
    googlePayload: null,
    googlePhotos: null,
    photoUrls: [],
    photoAttributions: [],
    traits: [],
    isFavorite: false,
    experiences: [],
  };
}

async function loadSerializedPlace(placeId: string, userId: string) {
  const place = await prisma.place.findUnique({
    where: { id: placeId },
    include: {
      favoriteUsers: true,
      experiences: {
        orderBy: { createdAt: 'desc' },
        take: 10,
        include: { user: true },
      },
    },
  });
  if (!place) {
    throw new Error(`Place ${placeId} was not found.`);
  }
  return serializePlace(place, userId);
}

async function loadPlacesByIds(placeIds: string[]) {
  if (placeIds.length === 0) {
    return [];
  }

  const places = await prisma.place.findMany({
    where: { id: { in: placeIds } },
    include: {
      favoriteUsers: true,
      experiences: {
        orderBy: { createdAt: 'desc' },
        take: 10,
        include: { user: true },
      },
    },
    orderBy: { updatedAt: 'desc' },
  });
  const order = new Map(placeIds.map((id, index) => [id, index]));
  return places.sort((left, right) => {
    return (order.get(left.id) ?? 0) - (order.get(right.id) ?? 0);
  });
}

async function enrichPlacesMissingGooglePayload(
  places: Array<Pick<PlaceWithRecentExperiences, 'id' | 'googlePlaceId' | 'name' | 'latitude' | 'longitude' | 'googlePayloadFetchedAt'>>,
) {
  for (const place of places) {
    if (place.googlePayloadFetchedAt) {
      continue;
    }

    await enrichPlaceFromGoogle({
      id: place.id,
      googlePlaceId: place.googlePlaceId,
      name: place.name,
      latitude: place.latitude,
      longitude: place.longitude,
    });
  }
}

export async function enrichPlaceFromGoogle(input: {
  id: string;
  googlePlaceId: string;
  name: string;
  latitude: number;
  longitude: number;
}) {
  const payload = await fetchGooglePlaceDetails(input.googlePlaceId);
  const enrichment = normalizeGooglePlaceDetails(
    {
      name: input.name,
      latitude: input.latitude,
      longitude: input.longitude,
    },
    payload,
  );

  return prisma.place.update({
    where: { id: input.id },
    data: {
      name: enrichment.name,
      latitude: enrichment.latitude,
      longitude: enrichment.longitude,
      googlePrimaryType: enrichment.googlePrimaryType,
      googleTypes: enrichment.googleTypes,
      googleBusinessStatus: enrichment.googleBusinessStatus,
      googleRating: enrichment.googleRating,
      googleUserRatingCount: enrichment.googleUserRatingCount,
      googleRegularOpeningHours:
        enrichment.googleRegularOpeningHours as Prisma.InputJsonValue,
      googleCurrentOpeningHours:
        enrichment.googleCurrentOpeningHours as Prisma.InputJsonValue,
      googlePhotos: enrichment.googlePhotos as Prisma.InputJsonValue,
      googlePayload: enrichment.googlePayload as Prisma.InputJsonValue,
      googlePayloadFetchedAt: enrichment.googlePayloadFetchedAt,
    },
  });
}

function googlePhotoUrls(value: Prisma.JsonValue | null) {
  return googlePhotos(value).map((photo) => {
    return `${config.publicApiUrl}/google/place-photo?name=${encodeURIComponent(photo.name ?? '')}&maxWidthPx=900`;
  });
}

function googlePhotoAttributions(value: Prisma.JsonValue | null) {
  return googlePhotos(value).flatMap((photo) => photo.authorAttributions ?? []);
}

function googlePhotos(value: Prisma.JsonValue | null): GooglePlacePhoto[] {
  if (!Array.isArray(value)) {
    return [];
  }

  return value.filter((item): item is GooglePlacePhoto => {
    return jsonObject(item)?.name !== undefined;
  });
}

function placeTraits(place: Pick<PlaceWithRecentExperiences, 'experiences'>) {
  const tags = new Set<string>();
  for (const experience of place.experiences) {
    const analysis = jsonObject(experience.analysis);
    const analysisTags = analysis?.tags;
    if (!Array.isArray(analysisTags)) {
      continue;
    }
    for (const tag of analysisTags) {
      if (typeof tag === 'string') {
        tags.add(tag);
      }
    }
  }
  return [...tags];
}

function rankPlacesByVoiceTags(
  places: PlaceWithRecentExperiences[],
  tags: string[],
  userId: string,
) {
  if (tags.length === 0) {
    return [];
  }

  const requestedTags = new Set(tags);
  return places
    .map((place) => {
      const tagsForPlace = placeTraits(place);
      const matchedTags = tagsForPlace.filter((tag) => requestedTags.has(tag));
      const favoriteBoost = place.favoriteUsers.some(
        (favorite) => favorite.userId === userId,
      )
        ? 0.25
        : 0;
      const ratingBoost = place.googleRating ? place.googleRating / 10 : 0;
      return {
        place,
        matchedCount: matchedTags.length,
        score: matchedTags.length * 10 + favoriteBoost + ratingBoost,
      };
    })
    .filter((item) => item.matchedCount > 0)
    .sort((left, right) => right.score - left.score)
    .slice(0, 12)
    .map((item) => item.place);
}

function jsonObject(value: unknown): Record<string, unknown> | null {
  if (!value || Array.isArray(value) || typeof value !== 'object') {
    return null;
  }
  return value as Record<string, unknown>;
}
