import Combine
import Foundation

/// A device worth remembering after a completed transfer.
///
/// Only a suggestion: nothing is issued until the user agrees, because being
/// reachable is a standing permission and a transfer is a one-off.
struct PairingSuggestion: Equatable, Identifiable {
	let endpointId: String
	let displayName: String?
	let transferName: String?

	var id: String { endpointId }

	var resolvedName: String {
		guard let displayName, !displayName.isEmpty else {
			return String(localized: L10n.Approval.nearbyDevice)
		}
		return displayName
	}
}

struct ContactsState: Equatable {
	var contacts: [DeviceContact] = []
	var blocked: [String] = []
	var pendingPairings: [PendingPairingModel] = []
	var pendingOffers: [IncomingOfferModel] = []
	var grantLifetime: GrantLifetimeOption = .days90
	var isLoading = false
	/// Endpoints with an in-flight decision, so a row can disable itself without
	/// blocking the rest of the list.
	var busyEndpoints: Set<String> = []
	var busyOfferIds: Set<String> = []
	var suggestions: [PairingSuggestion] = []
	/// Transfers this device is holding for contacts that were not running.
	var heldOffers: [HeldOfferModel] = []
	var checkForOffersOnOpen = false
	var isCheckingForOffers = false
	var selectedEndpointId: String?

	var selected: DeviceContact? {
		guard let selectedEndpointId else { return nil }
		return contacts.first { $0.endpointId == selectedEndpointId }
	}

	/// One prompt at a time: pairing consent is a modal decision and stacking
	/// sheets on top of each other reads as a loop of dialogs.
	var currentPairing: PendingPairingModel? { pendingPairings.first }
	var currentOffer: IncomingOfferModel? { pendingOffers.first }
	var currentSuggestion: PairingSuggestion? { suggestions.first }
}

/// Drives the device-history surfaces: the list, its detail, and the two
/// consent prompts. Ported in the MVVM shape used by the other feature models.
@MainActor
final class ContactsModel: ObservableObject {
	@Published private(set) var state = ContactsState()

	/// Set when the detail screen asks for a file picker; the platform picker
	/// modifier observes it, mirroring `SendModel`.
	@Published var pendingFilePick = false
	/// Device the picked files are destined for.
	@Published private(set) var sendTarget: String?

	private let repository: CoreGateway
	private let messages: UiMessageController
	private let preferences: AppPreferencesRepository
	private let fileSystemService: FileSystemService
	private var cancellables = Set<AnyCancellable>()

	init(
		repository: CoreGateway,
		messages: UiMessageController,
		preferences: AppPreferencesRepository,
		fileSystemService: FileSystemService
	) {
		self.repository = repository
		self.messages = messages
		self.preferences = preferences
		self.fileSystemService = fileSystemService
		state.grantLifetime = preferences.preferences.grantLifetime
		state.checkForOffersOnOpen = preferences.preferences.checkForOffersOnOpen

		repository.signals
			.sink { [weak self] signal in
				guard let self else { return }
				switch signal {
				case .contactsChanged:
					Task { await self.refresh() }
				case .offersChanged:
					Task { await self.refreshOffers() }
				case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
					// A completed delivery names the device that received from us.
					Task { await self.considerSendPeers(transferId: transferId) }
				case .approvalChanged:
					break
				}
			}
			.store(in: &cancellables)

		repository.statePublisher
			.sink { [weak self] core in
				guard let self, core.isInitialized else { return }
				self.considerReceivePeers(core.transfers)
			}
			.store(in: &cancellables)

		repository.statePublisher
			.map(\.isInitialized)
			.removeDuplicates()
			.sink { [weak self] isInitialized in
				guard let self, isInitialized else { return }
				// The core owns the lifetime; push the stored preference on start
				// so a restart does not silently fall back to the default.
				Task {
					await self.repository.setGrantLifetime(self.state.grantLifetime)
					await self.refresh()
				}
			}
			.store(in: &cancellables)
	}

	// MARK: - Loading

	func refresh() async {
		state.isLoading = true
		defer { state.isLoading = false }

		switch await repository.contacts() {
		case .success(let contacts):
			state.contacts = contacts
		case .failure(let error):
			messages.error(error)
		}
		if case .success(let blocked) = await repository.blockedContacts() {
			state.blocked = blocked
		}
		if case .success(let held) = await repository.heldOffers() {
			state.heldOffers = held
		}
		state.pendingPairings = await repository.pendingPairings()
		await refreshOffers()
	}

	func refreshOffers() async {
		state.pendingOffers = await repository.pendingOffers()
	}

	// MARK: - Post-transfer suggestions

	/// A completed receive names its sender, so that device becomes a candidate.
	private func considerReceivePeers(_ transfers: [Transfer]) {
		let candidates = transfers
			.filter { $0.direction == .receive && $0.status == .done }
			.compactMap { transfer -> PairingSuggestion? in
				guard let peerId = transfer.peerId else { return nil }
				return PairingSuggestion(
					endpointId: peerId,
					displayName: nil,
					transferName: transfer.transferName
				)
			}
		add(suggestions: candidates)
	}

	/// A completed delivery names the device we sent to.
	private func considerSendPeers(transferId: UInt64) async {
		guard case .success(let requests) = await repository.receiverRequests(transferId: transferId) else {
			return
		}
		let candidates = requests
			.filter { $0.status == .completed }
			.map { request in
				PairingSuggestion(
					endpointId: request.remoteEndpointId,
					displayName: request.receiverName ?? request.receiverDeviceName,
					transferName: request.transferName
				)
			}
		add(suggestions: candidates)
	}

	/// Filters candidates down to devices actually worth asking about.
	private func add(suggestions candidates: [PairingSuggestion]) {
		let known = Set(state.contacts.map(\.endpointId))
		let blocked = Set(state.blocked)
		let declined = preferences.preferences.declinedPairingSuggestions
		let pending = Set(state.suggestions.map(\.endpointId))

		let fresh = candidates.filter { candidate in
			!known.contains(candidate.endpointId)
				&& !blocked.contains(candidate.endpointId)
				&& !declined.contains(candidate.endpointId)
				&& !pending.contains(candidate.endpointId)
		}
		guard !fresh.isEmpty else { return }
		state.suggestions.append(contentsOf: fresh)
	}

	/// Agree to be reachable by a suggested device.
	func acceptSuggestion(_ suggestion: PairingSuggestion) async {
		state.suggestions.removeAll { $0.endpointId == suggestion.endpointId }
		preferences.clearDeclinedPairingSuggestion(suggestion.endpointId)
		await allowDeviceToReachMe(
			endpointId: suggestion.endpointId,
			displayName: preferences.preferences.username
		)
	}

	/// Decline, and remember the decline so the next transfer does not re-ask.
	func declineSuggestion(_ suggestion: PairingSuggestion) {
		state.suggestions.removeAll { $0.endpointId == suggestion.endpointId }
		preferences.declinePairingSuggestion(suggestion.endpointId)
	}

	// MARK: - Collecting waiting transfers

	func setCheckForOffersOnOpen(_ enabled: Bool) {
		state.checkForOffersOnOpen = enabled
		preferences.setCheckForOffersOnOpen(enabled)
	}

	/// Called when the app comes to the foreground.
	///
	/// Opt-in, because asking every contact whether they have something waiting
	/// also tells them the app was opened. Never runs in the background.
	func checkForOffersOnForeground() async {
		guard state.checkForOffersOnOpen else { return }
		_ = await collectWaitingOffers()
	}

	/// Explicit "check now". Returns how many transfers were collected so the
	/// caller can report an empty result, which a silent refresh cannot.
	@discardableResult
	func collectWaitingOffers() async -> UInt64 {
		guard !state.isCheckingForOffers else { return 0 }
		state.isCheckingForOffers = true
		defer { state.isCheckingForOffers = false }

		switch await repository.pollContactsForOffers() {
		case .success(let collected):
			await refreshOffers()
			return collected
		case .failure(let error):
			messages.error(error)
			return 0
		}
	}

	// MARK: - Selection

	func select(_ endpointId: String?) { state.selectedEndpointId = endpointId }

	// MARK: - Pairing consent

	/// Agree to be reachable by a device, typically right after a transfer.
	func allowDeviceToReachMe(endpointId: String, displayName: String?) async {
		state.busyEndpoints.insert(endpointId)
		defer { state.busyEndpoints.remove(endpointId) }

		if case .failure(let error) = await repository.allowDeviceToReachMe(
			endpointId: endpointId,
			displayName: displayName
		) {
			messages.error(error)
			return
		}
		await refresh()
	}

	/// Answer a device's offer to be remembered.
	func respondToPairing(endpointId: String, accepted: Bool) async {
		state.busyEndpoints.insert(endpointId)
		defer { state.busyEndpoints.remove(endpointId) }

		switch await repository.respondToPairing(endpointId: endpointId, accepted: accepted) {
		case .success:
			// Drop the prompt immediately: the core has already consumed it, and
			// leaving it on screen invites a second answer that does nothing.
			state.pendingPairings.removeAll { $0.endpointId == endpointId }
			if accepted { await refresh() }
		case .failure(let error):
			messages.error(error)
		}
	}

	// MARK: - Incoming offers

	/// Answer an incoming offer. Returns the ticket when accepted so the caller
	/// can run the receive with a platform-appropriate destination; the core
	/// releases it only on acceptance.
	func respondToOffer(offerId: String, accepted: Bool) async -> String? {
		state.busyOfferIds.insert(offerId)
		defer { state.busyOfferIds.remove(offerId) }

		let ticket = await repository.respondToOffer(offerId: offerId, accepted: accepted)
		state.pendingOffers.removeAll { $0.offerId == offerId }
		return ticket
	}

	// MARK: - Sending to a device

	/// Start choosing files to send to a remembered device.
	func chooseFilesToSend(to endpointId: String) {
		sendTarget = endpointId
		pendingFilePick = true
	}

	func onFilePickFailed(_ reason: String) {
		sendTarget = nil
		messages.error(InvitationError.raw(reason))
	}

	/// Send the picked selection straight to the chosen device.
	///
	/// Only the receiving user is prompted; this call returns once they have
	/// answered, so the button stays busy until then.
	func onFilesPicked(_ files: [PickedShareFile]) async {
		guard let endpointId = sendTarget else { return }
		sendTarget = nil
		guard !files.isEmpty else { return }

		state.busyEndpoints.insert(endpointId)
		defer { state.busyEndpoints.remove(endpointId) }

		let result = await fileSystemService.sharePickedFiles(
			repository: repository,
			files: files,
			transferName: files.count == 1 ? files[0].displayName : "",
			senderName: preferences.preferences.username,
			destination: .contact(endpointId: endpointId)
		)
		await fileSystemService.discardPickedFiles(files)
		switch result {
		case .success(let outcome):
			// A closed app is a delay, not a failure: say so rather than
			// reporting success for something nobody has received.
			let text: UiText = outcome.delivered
				? .resource(L10n.Send.transferCreated)
				: .resource(L10n.Contacts.offerHeld)
			messages.tryShow(UiMessage(text: text, tone: outcome.delivered ? .success : .info))
			await refresh()
		case .failure(let error):
			messages.error(error)
		}
	}

	/// Fire-and-report variant of ``offerTransfer(transferId:to:)``.
	///
	/// Owned by the model rather than a view so the request survives the picker
	/// being dismissed: the answer depends on a person at the other device.
	func offerTransferInBackground(transferId: UInt64, to contact: DeviceContact) {
		Task { await offerTransfer(transferId: transferId, to: contact) }
	}

	/// Push an existing transfer to a remembered device.
	///
	/// Returns whether it landed, so the caller can distinguish "accepted" from
	/// "waiting for that device to open the app".
	@discardableResult
	func offerTransfer(transferId: UInt64, to contact: DeviceContact) async -> Bool {
		state.busyEndpoints.insert(contact.endpointId)
		defer { state.busyEndpoints.remove(contact.endpointId) }

		switch await repository.offerTransferToContact(
			transferId: transferId,
			endpointId: contact.endpointId
		) {
		case .success(let outcome):
			let text: UiText = outcome.delivered
				? .dynamic(L10n.Contacts.sentToDevice(device: contact.displayName))
				: .resource(L10n.Contacts.offerHeld)
			messages.tryShow(UiMessage(text: text, tone: outcome.delivered ? .success : .info))
			await refresh()
			return outcome.delivered
		case .failure(let error) where error.offerRefusal != nil:
			// The offer was delivered and a person said no, or nobody answered.
			// Neither is a failure of this device, so neither is shown as one.
			let text = error.offerRefusal == .declined
				? L10n.Contacts.declinedByDevice(device: contact.displayName)
				: L10n.Contacts.noAnswer(device: contact.displayName)
			messages.tryShow(UiMessage(text: .dynamic(text), tone: .info))
			await refresh()
			return false
		case .failure(let error):
			messages.error(error)
			return false
		}
	}

	// MARK: - Management

	func setLabel(endpointId: String, label: String) async {
		let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines)
		if case .failure(let error) = await repository.setContactLabel(
			endpointId: endpointId,
			label: trimmed.isEmpty ? nil : trimmed
		) {
			messages.error(error)
			return
		}
		await refresh()
	}

	func forget(endpointId: String) async {
		state.busyEndpoints.insert(endpointId)
		defer { state.busyEndpoints.remove(endpointId) }

		if case .failure(let error) = await repository.forgetContact(endpointId: endpointId) {
			messages.error(error)
			return
		}
		if state.selectedEndpointId == endpointId { state.selectedEndpointId = nil }
		await refresh()
	}

	func forgetAll() async {
		if case .failure(let error) = await repository.forgetAllContacts() {
			messages.error(error)
			return
		}
		state.selectedEndpointId = nil
		await refresh()
	}

	func block(endpointId: String) async {
		state.busyEndpoints.insert(endpointId)
		defer { state.busyEndpoints.remove(endpointId) }

		if case .failure(let error) = await repository.blockContact(endpointId: endpointId) {
			messages.error(error)
			return
		}
		if state.selectedEndpointId == endpointId { state.selectedEndpointId = nil }
		await refresh()
	}

	func unblock(endpointId: String) async {
		if case .failure(let error) = await repository.unblockContact(endpointId: endpointId) {
			messages.error(error)
			return
		}
		await refresh()
	}

	func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
		state.grantLifetime = lifetime
		preferences.setGrantLifetime(lifetime)
		Task { await repository.setGrantLifetime(lifetime) }
	}
}
