import {
	ApprovedOpenReservationFilter,
	CustomerData,
	OpenReservationFilter,
	OrderState,
	Reservation,
	ReservationSource,
} from '@backend/domain'
import { OrderRepository, ReservationRepository, SettingsRepository, TableRepository } from '@backend/repository'
import { ReservationState } from '@core/types'
import { DateUtils, removeNulls } from '@core/utils'
import { BadRequestException, Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common'
import { addMinutes, isAfter, isBefore, subMinutes } from 'date-fns'
import { flatMap, intersection, map, some } from 'es-toolkit/compat'
import { Between, FindManyOptions, ILike, In, MoreThan } from 'typeorm'
import { Transactional } from 'typeorm-transactional'
import { SessionStore } from '@backend/auth'
import { generateManagementToken } from '../utils/management-token.util'
import { normalizeCompanionEmails } from '../utils/companion-emails.util'
import { FreeTableCountResponse } from '../../order'
import { ReservationMapper } from '../mappers/reservation.mapper'
import { ReservationEmailService, ReservationUtilsService, TableAssignmentService } from '../shared'
import {
	CreateReservationRequest,
	ReservationEmailEvent,
	ReservationResponse,
	UpdateReservationRequest,
	VerifyReservationResponse,
} from '../types'

/**
 * Service for managing reservations
 */
@Injectable()
export class ReservationService {
	constructor(
		private readonly _orderRepository: OrderRepository,
		private readonly _tableRepository: TableRepository,
		private readonly _emailService: ReservationEmailService,
		private readonly _settingsRepository: SettingsRepository,
		private readonly _reservationRepository: ReservationRepository,
		private readonly _tableAssignmentService: TableAssignmentService,
		private readonly _reservationUtilsService: ReservationUtilsService,
	) {}

	/**
	 * Finds all reservations
	 * @param tableIds The ids of the tables to find reservations for
	 * @param date The date to find reservations for
	 */
	async findAll(tableIds?: string[], date?: Date): Promise<ReservationResponse[]> {
		const reservations = await this._reservationRepository.findByDayAndTables(tableIds, date, {
			relations: { tables: true, tableCombination: true },
		})

		return reservations.map((reservation) => ReservationMapper.entityToResponse(reservation))
	}

	/**
	 * Finds all open reservations
	 * @param tableIds The ids of the tables to find reservations for
	 * @param date The date to find reservations for
	 */
	async findAllOpen(tableIds?: string[], date?: Date): Promise<ReservationResponse[]> {
		const reservations = await this._reservationRepository.findByDayAndTables(tableIds, date, {
			where: { state: In(OpenReservationFilter) },
			relations: { tables: true, tableCombination: true },
		})

		return reservations.map((reservation) => ReservationMapper.entityToResponse(reservation))
	}

	/**
	 * Finds all open reservations
	 * @param tableIds The ids of the tables to find reservations for
	 * @param date The date to find reservations for
	 */
	async findByOrderIds(orderIds: string[]): Promise<ReservationResponse[]> {
		const reservations = await this._reservationRepository.find({
			where: {
				order: {
					id: In(orderIds),
				},
			},
			relations: { tables: true, tableCombination: true },
		})

		return reservations.map((reservation) => ReservationMapper.entityToResponse(reservation))
	}

	/**
	 * Finds a reservation by given id
	 * @param id The id of the reservation to find
	 */
	async findById(id: string): Promise<ReservationResponse> {
		const reservation = await this._reservationRepository.findOne({
			where: { id },
			relations: { tables: true, tableCombination: true },
		})
		if (!reservation) {
			throw new NotFoundException(`Reservation not found`)
		}

		return ReservationMapper.entityToResponse(reservation)
	}

	/**
	 * Creates a new reservation
	 * @param request The request to create the reservation with
	 * @param source The source of the reservation
	 */
	@Transactional()
	async create(request: CreateReservationRequest, source: ReservationSource): Promise<Reservation> {
		request.startDate = new Date(request.startDate)
		request.endDate = new Date(request.endDate)

		if (isBefore(request.startDate, new Date())) {
			throw new BadRequestException('Cannot create reservation in past date')
		}

		if (isAfter(request.startDate, request.endDate)) {
			throw new BadRequestException('End time must be greater than Start time')
		}

		// Serialize concurrent bookings per tenant so two guests cannot win the
		// same last table (the Google flow already does this, see BookingServerService)
		const tenantId = SessionStore.tenantId
		if (tenantId) await this._reservationRepository.acquireBookingLock(tenantId)

		const { tables, combination: tableCombination } = await this._tableAssignmentService.assignTable(request)

		const reservation: Partial<Reservation> = {
			noOfPersons: request.noOfPersons,
			startDate: request.startDate,
			endDate: request.endDate,
			comment: request.comment,
			customerData: this.normalizeCustomerData(request.customerData),
			companionEmails: normalizeCompanionEmails(request.companionEmails, request.customerData.email),
			source,
			tables,
			tableCombination,
			managementToken: generateManagementToken(),
		}

		if (await this._reservationUtilsService.canAutoApproveReservation(reservation as Reservation)) {
			reservation.state = ReservationState.approved
		}

		const entity = await this._reservationRepository.save(reservation)

		this._reservationUtilsService.notifyNewReservation(entity)

		return entity
	}

	/**
	 * Keeps `name` and the optional split fields consistent: when firstName/lastName
	 * are provided, `name` is derived from them so existing consumers (dashboard,
	 * search, emails) keep working unchanged.
	 */
	private normalizeCustomerData(customerData: CustomerData): CustomerData {
		const firstName = customerData.firstName?.trim() || undefined
		const lastName = customerData.lastName?.trim() || undefined

		const name = [firstName, lastName].filter(Boolean).join(' ') || customerData.name

		return { ...customerData, firstName, lastName, name }
	}

	/**
	 * Updates the reservation with given id
	 * @param id The id of the reservation to update
	 * @param request The request to update the reservation with
	 */
	async update(id: string, request: UpdateReservationRequest): Promise<ReservationResponse> {
		if (id !== request.id) throw new BadRequestException()

		request.startDate = new Date(request.startDate)
		request.endDate = new Date(request.endDate)

		if (isBefore(request.startDate, new Date())) {
			throw new BadRequestException('Cannot create reservation in past date')
		}

		if (isAfter(request.startDate, request.endDate)) {
			throw new BadRequestException('End time must be after Start time')
		}

		const existing = await this._reservationRepository.findOne({ where: { id } })
		if (!existing) {
			throw new NotFoundException('Reservation not found')
		}

		const { tables, combination: tableCombination } = await this._tableAssignmentService.assignTable(request)

		const reservation: Partial<Reservation> = {
			id,
			noOfPersons: request.noOfPersons,
			startDate: request.startDate,
			endDate: request.endDate,
			comment: request.comment,
			customerData: request.customerData,
			// undefined = leave unchanged; an explicit array (even empty) replaces the list
			...(request.companionEmails !== undefined && {
				companionEmails: normalizeCompanionEmails(request.companionEmails, request.customerData?.email),
			}),
			tables,
			tableCombination,
		}

		await this._reservationRepository.save(reservation)

		const entity = await this._reservationRepository.findOne({
			where: { id },
			relations: { tables: true, tableCombination: true },
		})

		if (!entity) throw new BadRequestException('Reservation not found')

		await this._reservationUtilsService.notifyReservationUpdate({
			updatedReservation: entity,
			oldReservation: existing,
			event: ReservationEmailEvent.updated,
		})

		return ReservationMapper.entityToResponse(entity)
	}

	/**
	 * Approves the reservation with given id
	 * @param reservationId The id of the reservation to approve
	 */
	@Transactional()
	async confirmReservation(reservationId: string): Promise<void> {
		const reservation = await this._reservationRepository.findOne({
			where: { id: reservationId },
			relations: { tables: true },
		})
		if (!reservation) {
			throw new NotFoundException('Reservation not found')
		}

		const oldState = reservation.state

		// Find all reservations that overlap current reservation's time
		const overlappingReservations = await this._reservationRepository
			.findByInterval(reservation.startDate, reservation.endDate, {
				where: { state: In(OpenReservationFilter) },
				relations: { tables: true },
			})
			.then((reservations) => reservations.filter((_reservation) => _reservation.id !== reservationId))

		const approvedReservations = overlappingReservations.filter((_reservation) =>
			ApprovedOpenReservationFilter.includes(_reservation.state),
		)

		const hasOverlappingReservationOnTable = some(
			overlappingReservations,
			(_reservation) => intersection(map(reservation.tables, 'id'), map(_reservation.tables, 'id')).length > 0,
		)

		const shouldFindAvailableTables =
			!reservation.tables || reservation.tables.length === 0 || hasOverlappingReservationOnTable

		if (shouldFindAvailableTables) {
			const { tables: availableTables, combination: tableCombination } =
				await this._tableAssignmentService.getAvailableTables(reservation.noOfPersons, approvedReservations)

			if (availableTables.length === 0) {
				throw new UnprocessableEntityException('Selected Time Slot is not available')
			}

			reservation.tables = availableTables
			reservation.tableCombination = tableCombination
		}

		reservation.state = ReservationState.approved
		await this._reservationRepository.save(reservation)

		/**
		// TODO: If tables assigned to reservation now were assigned to other unconfirmed reservations,
		//  try to find other tables for those reservations
		// TODO: Move to a queue
		const unconfirmedReservations = overlappingReservations.filter(
			(_reservation) => _reservation.state === ReservationState.Unconfirmed && _reservation.table?.id === '',
		)

		if (unconfirmedReservations.length > 0) {
			const takenTables = overlappingReservations
				.map((r) => r.table)
				.filter(removeNulls)
				.map((t) => t.id)

			// TODO: Also include current reservation's table
			//  takenTables.push(table.id)

			const availableTables = sortTablesByCapacityAsc(
				sortTablesByRoomPlan(tables.filter((_table) => !takenTables.includes(_table.id))),
			)

			const assignedTableIds: string[] = []

			for (const _reservation of unconfirmedReservations) {
				// Find most suitable free table
				const table =
					availableTables
						.filter((_table) => _table.capacity >= _reservation.noOfPersons && !assignedTableIds.includes(_table.id))
						.at(0) ?? null
				if (table) assignedTableIds.push(table.id)
				await this._reservationRepository.update({ id: _reservation.id }, { table } as never)
			}
		}
		**/

		await this._reservationUtilsService.notifyReservationUpdate({
			updatedReservation: reservation,
			updates: [{ field: 'state', oldValue: oldState, newValue: ReservationState.approved }],
			event: ReservationEmailEvent.confirmed,
		})
	}

	/**
	 * Moves the reservation with given id to {@link ReservationState.unconfirmed} state
	 * @param reservationId The id of the reservation to unconfirm
	 */
	async unConfirmReservation(reservationId: string): Promise<void> {
		const reservation = await this._reservationRepository.findOne({ where: { id: reservationId } })
		if (!reservation) {
			throw new NotFoundException('Reservation not found')
		}

		await this._reservationRepository.update({ id: reservationId }, { state: ReservationState.unconfirmed })

		await this._reservationUtilsService.notifyReservationUpdate({
			updatedReservation: reservation,
			updates: [{ field: 'state', oldValue: reservation.state, newValue: ReservationState.unconfirmed }],
			event: ReservationEmailEvent.pending,
		})

		// TODO: For the tables that were assigned to the reservation, try to find other reservations
		// that can be assigned to those tables
	}

	/**
	 * Rejects the reservation with given id
	 * @param reservationId The id of the reservation to reject
	 */
	async rejectReservation(reservationId: string): Promise<void> {
		const reservation = await this._reservationRepository.findOne({ where: { id: reservationId } })
		if (!reservation) {
			throw new NotFoundException('Reservation not found')
		}

		await this._reservationRepository.update({ id: reservationId }, { state: ReservationState.rejected })

		const oldState = reservation.state
		reservation.state = ReservationState.rejected

		await this._reservationUtilsService.notifyReservationUpdate({
			updatedReservation: reservation,
			updates: [{ field: 'state', oldValue: oldState, newValue: ReservationState.rejected }],
			event: ReservationEmailEvent.cancelledByRestaurant,
		})

		// TODO: For the tables that were assigned to the reservation, try to find other reservations
		// that can be assigned to those tables
	}

	/**
	 * Searches reservations by given query
	 * @param query The query to search by
	 * @param onlyOpenReservations If true, only open reservations will be returned
	 * @returns A list of {@link ReservationResponse}
	 */
	async searchByQuery(query: string, onlyOpenReservations = false): Promise<ReservationResponse[]> {
		const states = onlyOpenReservations
			? [ReservationState.unconfirmed, ReservationState.approved, ReservationState.seated]
			: []

		const filter: FindManyOptions<Reservation> = {
			where: [
				{
					customerData: { name: ILike(`%${query}%`) },
					state: In(states),
					startDate: MoreThan(DateUtils.normalizeDay(new Date())),
				},
				{
					customerData: { email: ILike(`%${query}%`) },
					state: In(states),
					startDate: MoreThan(DateUtils.normalizeDay(new Date())),
				},
				{
					customerData: { phoneNumber: ILike(`${query}%`) },
					state: In(states),
					startDate: MoreThan(DateUtils.normalizeDay(new Date())),
				},
			],
			relations: { tables: true, tableCombination: true },
		}
		const reservations = await this._reservationRepository.find(filter)
		return reservations.map((reservation) => ReservationMapper.entityToResponse(reservation))
	}

	/**
	 * Gets the number of free tables
	 */
	async freeTableCount(): Promise<FreeTableCountResponse> {
		const dashboardReservationTimeLimit = await this._settingsRepository
			.findOne({ select: { reservation: { dashboardReservationTimeLimit: true } } })
			.then((settings) => settings?.reservation?.dashboardReservationTimeLimit)

		const start = DateUtils.normalizeDay(new Date())
		const end = addMinutes(start, dashboardReservationTimeLimit ?? 120)

		const reservations = await this._reservationRepository.findByInterval(start, end, {
			where: { state: In(ApprovedOpenReservationFilter) },
			relations: { tables: true },
			select: { tables: { id: true } },
		})

		const orders = await this._orderRepository.find({
			where: { state: OrderState.open },
			relations: { tables: true },
			select: { tables: { id: true } },
		})

		const usedTablesCount = new Set([
			...flatMap(reservations, (reservation) => map(reservation.tables, 'id')).filter(removeNulls),
			...flatMap(orders, (order) => map(order.tables, 'id')),
		]).size

		const totalTablesCount = await this._tableRepository.count()
		return { count: totalTablesCount - usedTablesCount }
	}

	// TODO: Verify proper working
	/**
	 * Verifies if the given query is a valid customer name
	 * @param tableId The id of the table to verify the reservation for
	 * @param query The query to verify
	 */
	async verifyReservation(tableId: string, query: string): Promise<VerifyReservationResponse> {
		const reservation = await this._reservationRepository.findOne({
			where: {
				tables: { id: tableId },
				startDate: Between(subMinutes(new Date(), 30), addMinutes(new Date(), 30)),
				state: ReservationState.approved,
			},
			order: { startDate: { direction: 'ASC' } },
		})

		if (!reservation) throw new BadRequestException()

		return { success: reservation.customerData.name === query, reservationId: reservation.id }
	}

	/**
	 * Searches customers by given field and query
	 * @param field The field to search by
	 * @param query The query to search by
	 * @returns A list of {@link CustomerData}
	 */
	async searchCustomers(field: string, query: string): Promise<CustomerData[]> {
		return this._reservationRepository
			.find({
				where: { customerData: { [field]: ILike(`%${query.toLowerCase()}%`) } },
				select: { customerData: {} },
			})
			.then((reservations) => map(reservations, 'customerData'))
	}

	async sendReviewEmail(reservationId: string): Promise<void> {
		const reservation = await this._reservationRepository.findOneBy({ id: reservationId })
		if (!reservation) throw new NotFoundException('Reservation not found')

		await this._emailService.sendReviewEmail(reservation)
	}
}
