import { Reservation, Table, TableCombination } from '@backend/domain'
import { ReservationRepository, TableCombinationRepository, TableRepository } from '@backend/repository'
import { ReservationState } from '@core/types'
import {
	removeNulls,
	sortTableCombinationsByCapacityAsc,
	sortTablesByCapacityAsc,
	sortTablesByPriority,
	sortTablesByRoomPlan,
} from '@core/utils'
import { Injectable, UnprocessableEntityException } from '@nestjs/common'
import { differenceBy, map } from 'es-toolkit/compat'
import { TableGraphBuilder } from '../../room-plan'
import { CreateReservationRequest } from '../types'

/** Structural subset assignTable actually needs — lets both DTOs and Reservation entities be passed. */
type TableAssignmentRequest = Pick<
	CreateReservationRequest,
	'tableId' | 'tableCombinationId' | 'startDate' | 'endDate' | 'noOfPersons'
>

type FindBestTableCombinationOptions = { noOfPersons: number }
type TablesWithCombination = {
	tables: Table[]
	combination?: TableCombination
}

@Injectable()
export class TableAssignmentService {
	constructor(
		private readonly _tableRepository: TableRepository,
		private readonly _reservationRepository: ReservationRepository,
		private readonly _tableCombinationRepository: TableCombinationRepository,
	) {}

	async assignTable(
		request: TableAssignmentRequest,
		options?: { excludeReservationId?: string },
	): Promise<TablesWithCombination> {
		const { tableId, tableCombinationId, startDate, endDate, noOfPersons } = request

		const overlappingReservations = await this.findOverlappingReservations(
			startDate,
			endDate,
			options?.excludeReservationId,
		)

		if (tableId) {
			const table = await this._tableRepository.findOne({ where: { id: tableId } })
			if (!table) {
				throw new UnprocessableEntityException('Table not found')
			}

			// If selected table can not fit the number of persons, try to find other tables
			if (table.capacity < noOfPersons) {
				return this.getAvailableTables(noOfPersons, overlappingReservations)
			}

			// If selected table has an overlapping reservation, try to find other tables
			if (
				overlappingReservations.some((reservation) => reservation.tables?.map((table) => table.id).includes(table.id))
			) {
				return this.getAvailableTables(noOfPersons, overlappingReservations)
			}

			return { tables: [table] }
		} else if (tableCombinationId) {
			const tableCombination = await this._tableCombinationRepository.findOne({
				where: { id: tableCombinationId },
				relations: { tables: true },
			})
			if (!tableCombination) {
				throw new UnprocessableEntityException('Table combination not found')
			}

			// If selected table combination can not fit the number of persons, try to find other tables
			if (tableCombination.capacity < noOfPersons) {
				return this.getAvailableTables(noOfPersons, overlappingReservations)
			}

			const occupiedTables = overlappingReservations.flatMap((reservation) => reservation.tables).filter(removeNulls)
			const availableTables = differenceBy(tableCombination.tables, occupiedTables, 'id')
			const availableCapacity = availableTables.map((table) => table.capacity).reduce((acc, cur) => acc + cur, 0)

			// If selected table combination has less available capacity, try to find other tables
			if (availableCapacity < noOfPersons) {
				return this.getAvailableTables(noOfPersons, overlappingReservations)
			}

			const tables = this.findBestTableCombination(availableTables, { noOfPersons })
			if (tables) return { tables }
		}

		// Try to find other tables
		return this.getAvailableTables(noOfPersons, overlappingReservations)
	}

	private async findOverlappingReservations(
		start: Date,
		end: Date,
		excludeReservationId?: string,
	): Promise<Reservation[]> {
		return this._reservationRepository
			.findByInterval(start, end, {
				where: { state: ReservationState.approved },
				relations: { tables: true },
			})
			.then((reservations) =>
				excludeReservationId
					? reservations.filter((reservation) => reservation.id !== excludeReservationId)
					: reservations,
			)
	}

	async getAvailableTables(
		noOfPersons: number,
		overlappingReservations: Reservation[],
	): Promise<TablesWithCombination> {
		const maxTableCapacity = (await this._tableRepository.maximum('capacity')) ?? Infinity

		if (noOfPersons <= maxTableCapacity) {
			const tables = await this._tableRepository.find()
			const table = this.findAvailableTable(noOfPersons, tables, overlappingReservations)
			if (table) return { tables: [table] }
		}

		const tableCombinations = await this._tableCombinationRepository.find({ relations: { tables: true } })
		return this.findAvailableTableCombination(noOfPersons, tableCombinations, overlappingReservations)
	}

	private findAvailableTable(
		noOfPersons: number,
		tables: Table[],
		overlappingReservations: Reservation[],
	): Table | undefined {
		const takenTables = new Set(
			overlappingReservations
				.map((reservation) => reservation.tables)
				.filter(removeNulls)
				.flatMap((tables) => tables.map((table) => table.id)),
		)

		// Filter tables that do not have a reservation and can fit the no of people
		const availableTables = tables
			.filter((table) => table.capacity >= noOfPersons)
			.filter((table) => !takenTables.has(table.id))

		// TODO: Also take unconfirmed reservations as input and
		// give least priority to tables that have unconfirmed reservations

		// Select the highest priority table with most appropriate capacity
		return sortTablesByCapacityAsc(sortTablesByPriority(sortTablesByRoomPlan(availableTables)))[0]
	}

	private async findAvailableTableCombination(
		noOfPersons: number,
		combinations: TableCombination[],
		overlappingReservations: Reservation[],
	): Promise<TablesWithCombination> {
		combinations = combinations.filter((comb) => comb.capacity >= noOfPersons)
		combinations = sortTableCombinationsByCapacityAsc(combinations)

		for (const combination of combinations) {
			const takenTables = overlappingReservations.flatMap((reservation) => reservation.tables).filter(removeNulls)
			const availableTables = differenceBy(combination.tables, takenTables, 'id')
			const availableCapacity = availableTables.map((table) => table.capacity).reduce((acc, cur) => acc + cur, 0)

			if (availableCapacity < noOfPersons) continue

			const tables = this.findBestTableCombination(availableTables, { noOfPersons })
			if (tables) return { tables, combination }
		}

		// Try to find available tables by rebooking other reservations

		// Find tables with approved reservations and where only single table is assigned to reservation,
		// these tables are available for rebooking
		const tablesAvailableForRebooking = overlappingReservations
			.filter((reservation) => reservation.tables?.length === 1)
			.flatMap((reservation) => reservation.tables)
			.filter(removeNulls)

		// Find combinations with tables available for rebooking
		const rebookingCombinations = combinations.filter((combination) =>
			map(tablesAvailableForRebooking, 'id').some((table) => map(combination.tables, 'id').includes(table)),
		)

		// If no combinations available for rebooking
		if (rebookingCombinations.length === 0) return { tables: [] }

		const tables = await this._tableRepository.find()

		const takenTables = new Set(
			overlappingReservations
				.map((reservation) => reservation.tables)
				.filter(removeNulls)
				.flatMap((tables) => tables.map((table) => table.id)),
		)

		// Filter tables that do not have a reservation
		const availableTables = tables.filter((table) => !takenTables.has(table.id))

		for (const combination of rebookingCombinations) {
			const combinationTablesAvailableForRebooking = combination.tables.filter((table) =>
				map(tablesAvailableForRebooking, 'id').includes(table.id),
			)

			const rebookedTables: Table[] = []
			const rebookedReservations: Array<{ reservation: Reservation; table: Table }> = []

			for (const table of combinationTablesAvailableForRebooking) {
				// Find reservation that is assigned to this table
				const tableReservation = overlappingReservations.find((reservation) =>
					map(reservation.tables, 'id')?.includes(table.id),
				)!

				// Filter tables that can fit the no of people
				const freeTables = sortTablesByCapacityAsc(
					availableTables.filter((table) => table.capacity >= tableReservation.noOfPersons),
				)
				if (freeTables.length === 0) continue

				// Assign reservation to one of free tables
				rebookedReservations.push({ reservation: tableReservation, table: freeTables[0] })
				rebookedTables.push(table)
			}

			// If rebooked tables can fit no of people, break the loop and assign the tables
			if (rebookedTables.reduce((acc, cur) => acc + cur.capacity, 0) >= noOfPersons) {
				const bestTables = this.findBestTableCombination(rebookedTables, { noOfPersons })
				if (!bestTables) continue

				await this._reservationRepository.save(
					rebookedReservations.map(({ reservation, table }) => ({ ...reservation, tables: [table] })),
				)

				return { tables: bestTables, combination }
			}
		}

		return { tables: [] }
	}

	/**
	 * Find the best combination of tables by iterating through all possible combinations
	 * and selecting the one with the least remaining capacity
	 */
	private findBestTableCombination(tables: Table[], options: FindBestTableCombinationOptions): Table[] | null {
		const graph = new TableGraphBuilder().build(tables)

		const tableCombinations = graph.getTableCombinations(tables)

		const combinations = tableCombinations
			.map((tables) => new TableCombination({ tables }))
			.filter((combination) => combination.capacity >= options.noOfPersons)

		const sortedByCapacity = combinations.sort((a, b) => a.capacity - b.capacity)

		if (sortedByCapacity.length === 0) {
			return null
		}

		return sortedByCapacity[0].tables
	}
}
