import Foundation
import Combine

/// Settings sections, ported from `feature/settings/SettingsViewModel.kt`.
enum SettingsSection: Hashable {
	case overview
	case preferences
	case appearance
	case notifications
	case network
	case storage
	case about
	case bugReport

	var titleKey: String.LocalizationValue {
		switch self {
		case .overview: return L10n.Settings.title
		case .preferences: return L10n.Preferences.title
		case .appearance: return L10n.Appearance.title
		case .notifications: return L10n.Notifications.title
		case .network: return L10n.Settings.networkTitle
		case .storage: return L10n.Storage.title
		case .about: return L10n.About.title
		case .bugReport: return L10n.About.bugReport
		}
	}
}

/// On-disk usage breakdown for the Storage screen.
struct StorageBreakdown: Equatable {
	var receivedFiles: UInt64 = 0
	var transferCache: UInt64 = 0
	var appData: UInt64 = 0
	var temporary: UInt64 = 0
	var total: UInt64 { receivedFiles + transferCache + appData + temporary }
}

struct SettingsState: Equatable {
	var selectedSection: SettingsSection = .overview
	var username = ""
	var receiveFolder: ReceiveFolder?
	var folderAccessStatus: FolderAccessStatus = .unavailable
	var isValidatingFolder = false
	var supportsCustomReceiveFolders = true
	var themeMode: ThemeMode = .system
	var notificationPermission: NotificationPermission = .notDetermined
	var relayMode: RelayPreferenceMode = .automatic
	var relayURLs: [String] = []
	var relayValidationError: RelayConfigurationValidationError?
	var relayConfigurationIsDirty = false
	var isApplyingRelayConfiguration = false
	var hasActiveNetworkWork = false
	var endpointId: String?
	var relayApplyErrorKey: String.LocalizationValue?
	var deviceInfo: DeviceInfo?
	var appVersion = ""
	var isLoadingDeviceInfo = false
	var bugWhatHappened = ""
	var bugExpected = ""
	var bugSteps = ""
	var bugContact = ""
	var bugIncludeLogs = true
	var isSubmittingBugReport = false
	var bugLogPreviewBytes = 0
	var storage: StorageBreakdown?
	var isCalculatingStorage = false
	var storageLoadFailed = false
	var isDeletingTransfers = false
	var isCleaningStorage = false

	static func == (lhs: SettingsState, rhs: SettingsState) -> Bool {
		lhs.selectedSection == rhs.selectedSection && lhs.username == rhs.username
			&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
			&& lhs.isValidatingFolder == rhs.isValidatingFolder
			&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
			&& lhs.themeMode == rhs.themeMode
			&& lhs.notificationPermission == rhs.notificationPermission
			&& lhs.appVersion == rhs.appVersion
			&& lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs
			&& lhs.relayValidationError == rhs.relayValidationError
			&& lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty
			&& lhs.isApplyingRelayConfiguration == rhs.isApplyingRelayConfiguration
			&& lhs.hasActiveNetworkWork == rhs.hasActiveNetworkWork
			&& lhs.endpointId == rhs.endpointId
			&& lhs.relayApplyErrorKey == rhs.relayApplyErrorKey
			&& lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
			&& lhs.bugWhatHappened == rhs.bugWhatHappened && lhs.bugExpected == rhs.bugExpected
			&& lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact
			&& lhs.bugIncludeLogs == rhs.bugIncludeLogs && lhs.isSubmittingBugReport == rhs.isSubmittingBugReport
			&& lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes
			&& lhs.storage == rhs.storage && lhs.isCalculatingStorage == rhs.isCalculatingStorage
			&& lhs.storageLoadFailed == rhs.storageLoadFailed
			&& lhs.isDeletingTransfers == rhs.isDeletingTransfers
			&& lhs.isCleaningStorage == rhs.isCleaningStorage
			&& lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem
	}
}

@MainActor
final class SettingsModel: ObservableObject {
	@Published private(set) var state: SettingsState
	/// Set by the view to request the receive-folder picker (macOS).
	@Published var pendingReceiveFolderPick = false

	private let environment: PlatformEnvironment
	private let deviceInfoProvider: DeviceInfoProvider
	private let fileSystemService: FileSystemService
	private let repository: CoreGateway
	private let preferences: AppPreferencesRepository
	private let notifications: LocalNotificationService
	private let messages: UiMessageController
	private let bugReports: BugReportService

	private var usernamePersistTask: Task<Void, Never>?
	private var hasLocalUsernameDraft = false
	private var hasRelayConfigurationDraft = false
	private var cancellables = Set<AnyCancellable>()

	init(
		environment: PlatformEnvironment,
		deviceInfoProvider: DeviceInfoProvider,
		fileSystemService: FileSystemService,
		repository: CoreGateway,
		preferences: AppPreferencesRepository,
		notifications: LocalNotificationService,
		messages: UiMessageController,
		bugReports: BugReportService
	) {
		self.environment = environment
		self.deviceInfoProvider = deviceInfoProvider
		self.fileSystemService = fileSystemService
		self.repository = repository
		self.preferences = preferences
		self.notifications = notifications
		self.messages = messages
		self.bugReports = bugReports
		self.state = SettingsState(
			supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders,
			appVersion: environment.appVersion
		)

		preferences.$preferences
			.sink { [weak self] prefs in
				guard let self else { return }
				let previousFolder = self.state.receiveFolder
				let folder = self.fileSystemService.effectiveReceiveFolder(prefs.receiveFolder)
				self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
				self.state.receiveFolder = folder
				self.state.themeMode = prefs.themeMode
				if !self.hasRelayConfigurationDraft {
					self.state.relayMode = prefs.relayConfiguration.mode
					self.state.relayURLs = prefs.relayConfiguration.relayURLs
					self.state.relayConfigurationIsDirty = false
				}
				if folder != previousFolder { Task { await self.validateFolder(folder) } }
			}
			.store(in: &cancellables)

		repository.statePublisher
			.sink { [weak self] coreState in
				guard let self else { return }
				let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0
					|| (coreState.status?.activeShares ?? 0) > 0
					|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
				self.state.hasActiveNetworkWork = hasActiveWork
				self.state.endpointId = coreState.status?.endpointId
				if !hasActiveWork && self.state.relayApplyErrorKey == L10n.Relay.applyActiveTransfers {
					self.state.relayApplyErrorKey = nil
				}
			}
			.store(in: &cancellables)

		refreshNotificationPermission()
		loadDeviceInfo()
	}


	func selectSection(_ section: SettingsSection) {
		state.selectedSection = section
		if section == .about || section == .bugReport {
			loadDeviceInfo()
			if section == .bugReport { refreshBugLogPreview() }
		}
	}

	func setUsername(_ value: String) {
		hasLocalUsernameDraft = true
		state.username = value
		usernamePersistTask?.cancel()
		usernamePersistTask = Task {
			try? await Task.sleep(nanoseconds: 350_000_000)
			if Task.isCancelled { return }
			preferences.setUsername(value)
		}
	}

	func setThemeMode(_ mode: ThemeMode) { preferences.setThemeMode(mode) }

	func chooseReceiveFolder() {
		if !fileSystemService.supportsCustomReceiveFolders { return }
		pendingReceiveFolderPick = true
	}

	func onReceiveFolderPicked(_ folder: ReceiveFolder) { preferences.setReceiveFolder(folder) }
	func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.raw(reason)) }
	func resetReceiveFolder() { preferences.resetReceiveFolder() }

	/// Whether the current receive folder is the platform default (so the reset
	/// action can be hidden when it would be a no-op). Compared by location, not
	/// display name, which can differ once resolved.
	var isUsingDefaultReceiveFolder: Bool {
		guard let folder = state.receiveFolder else { return true }
		let fallback = fileSystemService.defaultReceiveFolder()
		return folder.kind == fallback.kind && folder.value == fallback.value
	}

	/// Ask the OS for notification permission. This is the only time the app can
	/// grant it; disabling or fine-tuning afterwards happens in the Settings app.
	func requestNotifications() {
		Task {
			let permission = await notifications.requestPermission()
			state.notificationPermission = permission
			if permission == .unsupported {
				messages.show(UiMessage(text: .resource(L10n.Notifications.unsupported), tone: .warning))
			}
		}
	}

	// MARK: - Network

	func setRelayMode(_ mode: RelayPreferenceMode) {
		hasRelayConfigurationDraft = true
		state.relayMode = mode
		if mode.usesCustomRelayURLs && state.relayURLs.isEmpty { state.relayURLs = [""] }
		updateRelayConfigurationDraft()
	}

	func setRelayURL(_ value: String, at index: Int) {
		guard state.relayURLs.indices.contains(index) else { return }
		hasRelayConfigurationDraft = true
		state.relayURLs[index] = value
		updateRelayConfigurationDraft()
	}

	func addRelayURL() {
		guard state.relayURLs.count < RelayConfigurationValidator.maximumRelayCount else { return }
		hasRelayConfigurationDraft = true
		state.relayURLs.append("")
		updateRelayConfigurationDraft()
	}

	func removeRelayURL(at index: Int) {
		guard state.relayURLs.indices.contains(index) else { return }
		hasRelayConfigurationDraft = true
		state.relayURLs.remove(at: index)
		if state.relayURLs.isEmpty { state.relayURLs = [""] }
		updateRelayConfigurationDraft()
	}

	func applyRelayConfiguration() {
		guard !state.isApplyingRelayConfiguration, state.relayConfigurationIsDirty else { return }

		let configuration: RelayConfiguration
		do {
			configuration = try RelayConfigurationValidator.validate(
				mode: state.relayMode,
				relayURLs: state.relayURLs,
				retainedRelayURLs: preferences.preferences.relayConfiguration.relayURLs
			)
		} catch let error as RelayConfigurationValidationError {
			state.relayValidationError = error
			state.relayApplyErrorKey = nil
			return
		} catch {
			return
		}

		let coreState = repository.state
		let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0
			|| (coreState.status?.activeShares ?? 0) > 0
			|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
		guard !hasActiveWork else {
			state.hasActiveNetworkWork = true
			state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
			messages.show(UiMessage(text: .resource(L10n.Relay.applyActiveTransfers), tone: .warning))
			return
		}

		let previousConfiguration = preferences.preferences.relayConfiguration
		state.relayValidationError = nil
		state.relayApplyErrorKey = nil
		state.isApplyingRelayConfiguration = true
		Task {
			let applyResult = await repository.initialize(
				appDataDir: environment.defaultCoreDataDir,
				networkConfiguration: configuration
			)
			switch applyResult {
			case .success:
				hasRelayConfigurationDraft = false
				preferences.setRelayConfiguration(configuration)
				state.isApplyingRelayConfiguration = false
				state.relayConfigurationIsDirty = false
				messages.show(UiMessage(text: .resource(L10n.Relay.settingsApplied), tone: .success))
			case .failure(let error):
				if let lifecycleError = error as? CoreNetworkLifecycleError {
					state.isApplyingRelayConfiguration = false
					switch lifecycleError {
					case .activeNetworkWork:
						state.hasActiveNetworkWork = true
						state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
					case .transitionInProgress:
						state.relayApplyErrorKey = L10n.Relay.applyFailed
					}
					return
				}
				let rollbackResult = await repository.initialize(
					appDataDir: environment.defaultCoreDataDir,
					networkConfiguration: previousConfiguration
				)
				state.isApplyingRelayConfiguration = false
				if case .success = rollbackResult {
					state.relayApplyErrorKey = L10n.Relay.applyFailed
					messages.show(UiMessage(text: .resource(L10n.Relay.applyFailed), tone: .error))
				} else {
					state.relayApplyErrorKey = L10n.Relay.restoreFailed
					messages.show(UiMessage(text: .resource(L10n.Relay.restoreFailed), tone: .error))
				}
			}
		}
	}

	private func updateRelayConfigurationDraft() {
		state.relayValidationError = nil
		state.relayApplyErrorKey = nil
		let saved = preferences.preferences.relayConfiguration
		let draftURLs = state.relayMode.usesCustomRelayURLs ? state.relayURLs : saved.relayURLs
		state.relayConfigurationIsDirty = saved != RelayConfiguration(mode: state.relayMode, relayURLs: draftURLs)
		hasRelayConfigurationDraft = state.relayConfigurationIsDirty
	}

	func setBugWhatHappened(_ value: String) { state.bugWhatHappened = value }
	func setBugExpected(_ value: String) { state.bugExpected = value }
	func setBugSteps(_ value: String) { state.bugSteps = value }
	func setBugContact(_ value: String) { state.bugContact = value }
	func setBugIncludeLogs(_ value: Bool) { state.bugIncludeLogs = value }

	func submitBugReport(onSuccess: @escaping () -> Void = {}) {
		if state.isSubmittingBugReport { return }
		Task {
			let snapshot = state
			let what = snapshot.bugWhatHappened.trimmingCharacters(in: .whitespacesAndNewlines)
			let expected = snapshot.bugExpected.trimmingCharacters(in: .whitespacesAndNewlines)
			if what.isEmpty {
				messages.show(UiMessage(text: .resource(L10n.Bug.reportMissingWhat), tone: .warning))
				return
			}
			if expected.isEmpty {
				messages.show(UiMessage(text: .resource(L10n.Bug.reportMissingExpected), tone: .warning))
				return
			}
			state.isSubmittingBugReport = true
			let result = await bugReports.submit(
				BugReportDraft(
					whatHappened: what, expected: expected, steps: snapshot.bugSteps,
					contact: snapshot.bugContact, includeLogs: snapshot.bugIncludeLogs
				),
				deviceInfo: snapshot.deviceInfo
			)
			switch result {
			case .success:
				state.isSubmittingBugReport = false
				state.bugWhatHappened = ""
				state.bugExpected = ""
				state.bugSteps = ""
				state.bugContact = ""
				state.bugIncludeLogs = true
				messages.show(UiMessage(text: .resource(L10n.Bug.reportSubmitted), tone: .success))
				onSuccess()
			case .failure:
				state.isSubmittingBugReport = false
				messages.show(UiMessage(text: .resource(L10n.Bug.reportSubmitFailed), tone: .error))
			}
		}
	}

	func openNotificationSettings() {
		Task {
			if case .failure = await notifications.openSettings() {
				messages.show(UiMessage(text: .resource(L10n.Notifications.settingsOpenFailed), tone: .error))
			}
		}
	}

	/// Re-read the OS permission (called on appear and when returning to the
	/// foreground, e.g. after a trip to Settings) so the toggle stays in sync.
	func refreshNotificationPermission() {
		Task {
			state.notificationPermission = await notifications.refreshPermission()
		}
	}

	// MARK: - Storage

	/// Recomputes the on-disk usage breakdown off the main actor. Safe to call
	/// before the core is ready: it keeps the spinner up and waits for the core to
	/// finish initializing (it starts asynchronously at launch) rather than bailing.
	func loadStorageUsage() {
		if state.isCalculatingStorage { return }
		state.isCalculatingStorage = true
		state.storageLoadFailed = false
		let tempDir = NSTemporaryDirectory()
		Task {
			// The core initializes asynchronously at launch; poll briefly so opening
			// Storage early doesn't leave the summary stuck.
			var attempts = 0
			while !repository.state.isInitialized && attempts < 100 {
				try? await Task.sleep(nanoseconds: 100_000_000)
				attempts += 1
			}
			let coreResult = await repository.storageUsage()
			let artifactsResult = await repository.receivedArtifacts()
			guard case .success(let core) = coreResult,
				case .success(let artifacts) = artifactsResult else {
				state.isCalculatingStorage = false
				state.storageLoadFailed = true
				return
			}
			let diskSizes = await Task.detached {
				let received = artifacts.reduce(UInt64(0)) { total, artifact in
					total + SettingsModel.fileSize(artifact.locator)
				}
				return (received, SettingsModel.directorySize(tempDir))
			}.value
			let breakdown = StorageBreakdown(
				receivedFiles: diskSizes.0,
				transferCache: core.blobStoreBytes,
				appData: core.appDataBytes,
				temporary: diskSizes.1
			)
			state.storage = breakdown
			state.isCalculatingStorage = false
		}
	}

	/// Deletes every send/receive transfer via the core, freeing the imported
	/// shared-file content and clearing history. Node identity and received files
	/// are left untouched.
	func deleteAllTransfers() {
		if state.isDeletingTransfers { return }
		state.isDeletingTransfers = true
		Task {
			var failures = 0
			for id in repository.state.transfers.map(\.transferId) {
				if case .failure = await repository.delete(transferId: id) { failures += 1 }
			}
			_ = await repository.refresh()
			state.isDeletingTransfers = false
			if failures == 0 {
				loadStorageUsage()
				messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success))
			} else {
				messages.error(InvitationError.deleteRecordsFailed)
			}
		}
	}

	/// Reclaims disk space the core's transfer deletion doesn't touch: the app's
	/// temporary directory (leftover picker/staging copies) and any stray `.Trash`
	/// folders that accumulate inside app-owned directories. Never touches received
	/// files, the core database, or user-chosen receive folders.
	func freeUpSpace() {
		if state.isCleaningStorage { return }
		// Purging staging while a transfer is mid-flight could break it.
		let hasActive = repository.state.transfers.contains {
			$0.status == .sharing || $0.status == .importing || $0.status == .receiving
		}
		if hasActive {
			messages.tryShow(UiMessage(text: .resource(L10n.Storage.cleanupBusy), tone: .warning))
			return
		}
		state.isCleaningStorage = true
		let tempDir = NSTemporaryDirectory()
		let dataDir = environment.defaultCoreDataDir
		// Only clean the receive folder's trash when it is app-owned (iOS fixed
		// Documents), never a user-chosen macOS folder like ~/Downloads.
		let receiveTrashRoot = fileSystemService.supportsCustomReceiveFolders ? nil : state.receiveFolder?.value
		Task {
			let freed = await Task.detached {
				SettingsModel.reclaimJunk(tempDir: tempDir, dataDir: dataDir, receiveTrashRoot: receiveTrashRoot)
			}.value
			state.isCleaningStorage = false
			loadStorageUsage()
			messages.show(UiMessage(
				text: .dynamic(L10n.Storage.cleanupFreed(size: formatBytes(freed))),
				tone: .success
			))
		}
	}

	/// Deletes temp-directory contents and `.Trash` folders under the given roots,
	/// returning the number of bytes reclaimed. Runs off the main actor.
	nonisolated static func reclaimJunk(tempDir: String, dataDir: String, receiveTrashRoot: String?) -> UInt64 {
		let fm = FileManager.default
		var freed: UInt64 = 0
		// Empty the temporary directory.
		if let entries = try? fm.contentsOfDirectory(atPath: tempDir) {
			for name in entries {
				let path = (tempDir as NSString).appendingPathComponent(name)
				freed += itemSize(path)
				try? fm.removeItem(atPath: path)
			}
		}
		// Remove stray `.Trash` folders inside app-owned directories.
		for root in [dataDir, receiveTrashRoot].compactMap({ $0 }) {
			for trash in trashDirectories(under: root) {
				freed += directorySize(trash)
				try? fm.removeItem(atPath: trash)
			}
		}
		return freed
	}

	/// Paths of every directory named `.Trash` under `root` (not descending into them).
	private nonisolated static func trashDirectories(under root: String) -> [String] {
		let url = URL(fileURLWithPath: root, isDirectory: true)
		guard let enumerator = FileManager.default.enumerator(
			at: url, includingPropertiesForKeys: [.isDirectoryKey]
		) else { return [] }
		var result: [String] = []
		for case let fileURL as URL in enumerator where fileURL.lastPathComponent == ".Trash" {
			result.append(fileURL.path)
			enumerator.skipDescendants()
		}
		return result
	}

	/// Allocated size of a file or directory (0 if missing).
	private nonisolated static func itemSize(_ path: String) -> UInt64 {
		var isDirectory: ObjCBool = false
		guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { return 0 }
		return isDirectory.boolValue ? directorySize(path) : fileSize(path)
	}

	nonisolated static func fileSize(_ path: String) -> UInt64 {
		let values = try? URL(fileURLWithPath: path).resourceValues(
			forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey]
		)
		guard values?.isRegularFile == true else { return 0 }
		return UInt64(values?.totalFileAllocatedSize ?? values?.fileSize ?? 0)
	}

	/// Recursive size of every regular file under `path` (0 if missing).
	nonisolated static func directorySize(_ path: String) -> UInt64 {
		let url = URL(fileURLWithPath: path)
		guard let enumerator = FileManager.default.enumerator(
			at: url, includingPropertiesForKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey]
		) else { return 0 }
		var total: UInt64 = 0
		for case let fileURL as URL in enumerator {
			let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey])
			guard values?.isRegularFile == true else { continue }
			total += UInt64(values?.totalFileAllocatedSize ?? values?.fileSize ?? 0)
		}
		return total
	}

	private func loadDeviceInfo() {
		if state.isLoadingDeviceInfo { return }
		state.isLoadingDeviceInfo = true
		Task {
			let info = await deviceInfoProvider.load()
			state.deviceInfo = info
			state.isLoadingDeviceInfo = false
		}
	}

	private func refreshBugLogPreview() {
		Task {
			let bytes = await bugReports.previewLogBytes()
			state.bugLogPreviewBytes = bytes
		}
	}

	private func validateFolder(_ folder: ReceiveFolder) async {
		state.isValidatingFolder = true
		let status = await fileSystemService.validateReceiveFolder(folder)
		state.folderAccessStatus = status
		state.isValidatingFolder = false
	}
}
