import Foundation

/// Owns the ride lifecycle: `RideFlowState` in, one screen out.
///
/// Everything that can refuse a ride funnels through `block(_:)` so the rider always
/// sees localised, actionable copy rather than server text.
@MainActor
final class RideViewModel: ObservableObject {

    @Published private(set) var state: RideFlowState = .idle
    /// Kept alongside the flow so the confirm sheet can show the balance after the fare.
    @Published private(set) var balance: Double?
    /// Set when the last end attempt was refused for being outside an allowed zone.
    @Published private(set) var zoneRefusal = false
    /// Where to park, when the refusal was a parking rule. Stays nil if zone-service cannot be
    /// reached, so the screen falls back to the generic wording rather than claiming a reason we
    /// did not confirm.
    @Published private(set) var parkingGuidance: ParkingGuidance?

    private let rides: RideRepository
    private let wallet: WalletRepository
    private let photos: PhotoUploader
    private let zones: ZoneRepository
    private let location: LocationProvider
    private let legal: LegalRepository


    /// Ride states that are still the rider's problem, and so are worth resuming into.
    private let inFlightStatuses: Set<String> = ["PENDING_UNLOCK", "ACTIVE"]

    /// Every ride this rider currently has running, focused or not.
    ///
    /// A rider may hold several scooters at once (booking-service only makes the *scooter*
    /// unique, never the rider), so the flow tracks the one ride being interacted with while
    /// this tracks all of them. Without it, starting a second ride would hide the first and
    /// leave its meter running out of sight.
    @Published private(set) var running: [RideResponse] = []

    /// Ticks once a second while any ride is running, so the running-rides bar can recompute
    /// its timers. Separate from `ticker`, which belongs to the focused ride.
    @Published private(set) var tick: Int = 0

    private var runningTicker: Task<Void, Never>?

    /// How often we ask booking-service whether the lock confirmed.
    private let unlockPollSeconds: UInt64 = 2
    /// Deliberately shorter than booking-service's allocation sweeper: tell the rider
    /// and offer another scooter before the backend silently expires the ride.
    private let unlockTimeoutSeconds = 75

    private var ticker: Task<Void, Never>?

    init(
        rides: RideRepository,
        wallet: WalletRepository,
        photos: PhotoUploader,
        zones: ZoneRepository,
        location: LocationProvider,
        legal: LegalRepository
    ) {
        self.rides = rides
        self.wallet = wallet
        self.photos = photos
        self.zones = zones
        self.location = location
        self.legal = legal
    }

    // MARK: - Start

    /// Allocate a scooter by QR code. Lands in `.blocked` on refusal.
    func start(qrCode: String) {
        let code = qrCode.trimmingCharacters(in: .whitespaces)
        guard !code.isEmpty else { return }
        state = .validating
        Task {
            // Consent first, before a scooter is reserved or a lock is opened. Asked here so the
            // rider reads the policy as a step in starting a ride rather than meeting a refusal;
            // booking-service checks it again and is the one that can actually refuse.
            let pending = await legal.outstanding()
            if !pending.isEmpty {
                state = .consent(documents: pending, pendingQrCode: code,
                                 submitting: false, failed: false)
                return
            }
            do {
                let here = await location.current()
                let ride = try await rides.startRide(qrCode: code, lat: here.lat, lng: here.lng)
                await refreshRunning()
                // The 202 means "dispatched", never "opened"  wait for lock.status.
                if ride.status == "ACTIVE" { onActive(ride) } else { awaitUnlock(ride) }
            } catch {
                // 451 means the server knows agreement is missing even though our own check did
                // not (a stale token, a version published mid-session). Show the step rather than
                // a dead end: the rider can still act on it.
                if BlockedReason.classify(error) == .consentRequired {
                    let documents = await legal.documents()
                    if !documents.isEmpty {
                        state = .consent(documents: documents, pendingQrCode: code,
                                         submitting: false, failed: false)
                        return
                    }
                }
                block(error)
            }
        }
    }

    /// Poll until the lock confirms. booking-service flips the ride to ACTIVE when it
    /// consumes `lock.status`, so polling is how the client observes an out-of-band event.
    private func awaitUnlock(_ initial: RideResponse) {
        state = .unlocking(ride: initial, elapsedSeconds: 0)
        ticker?.cancel()
        ticker = Task { [weak self] in
            guard let self else { return }
            var waited = 0
            while waited < self.unlockTimeoutSeconds, !Task.isCancelled {
                try? await Task.sleep(nanoseconds: self.unlockPollSeconds * 1_000_000_000)
                waited += Int(self.unlockPollSeconds)
                guard let current = try? await self.rides.ride(id: initial.id) else {
                    // A transient poll failure isn't a ride failure  keep waiting.
                    self.state = .unlocking(ride: initial, elapsedSeconds: waited)
                    continue
                }
                switch current.status {
                case "ACTIVE":
                    self.onActive(current)
                    return
                case "EXPIRED", "CANCELLED":
                    self.state = .blocked(reason: .unlockTimeout)
                    return
                default:
                    self.state = .unlocking(ride: current, elapsedSeconds: waited)
                }
            }
            guard !Task.isCancelled else { return }
            // Never confirmed. The rider was not charged.
            self.state = .blocked(reason: .unlockTimeout)
        }
    }

    /// Accept every outstanding document, then continue into the ride the rider asked for.
    ///
    /// All or nothing: these are the terms of using the service, so there is no partial state to
    /// model. A failure leaves the rider on this step with the reason, not dropped back to the map.
    func acceptConsent() {
        guard case let .consent(documents, pendingQrCode, _, _) = state else { return }
        state = .consent(documents: documents, pendingQrCode: pendingQrCode,
                         submitting: true, failed: false)
        Task {
            do {
                for document in documents {
                    try await legal.accept(document)
                }
            } catch {
                state = .consent(documents: documents, pendingQrCode: pendingQrCode,
                                 submitting: false, failed: true)
                return
            }
            start(qrCode: pendingQrCode)
        }
    }

    /// Rider declined. Back to the map: no ride, and nothing recorded.
    func declineConsent() {
        if case .consent = state { state = .idle }
    }

    /// Rider gave up waiting for the lock.
    func cancelUnlock() {
        ticker?.cancel()
        state = .idle
    }

    // MARK: - Active

    private func onActive(_ ride: RideResponse) {
        ticker?.cancel()
        // Enter .active up front rather than on the first tick: the guard in the loop below only
        // protects a state we are already in, and waiting a tick to set it would leave the
        // previous screen up for a second.
        state = activeState(ride)
        ticker = Task { [weak self] in
            guard let self else { return }
            while !Task.isCancelled {
                try? await Task.sleep(nanoseconds: 1_000_000_000)
                // Only ever refresh a state that is still active. Without this guard the tick
                // overwrites whatever the rider just moved to: tapping End set .confirmEnd, and a
                // second later the ticker put .active back, so the confirm dialog was destroyed
                // before it could be seen and the ride could not be ended at all.
                guard case .active = self.state else { return }
                self.state = self.activeState(ride)
            }
        }
        refreshBalance()
    }

    private func activeState(_ ride: RideResponse) -> RideFlowState {
        let seconds = elapsedSeconds(ride)
        return .active(
            ride: ride,
            elapsedSeconds: seconds,
            runningFare: fare(for: ride, seconds: seconds),
            // TODO(fleet): live telemetry needs a ride-scoped socket.
            batteryPercent: nil
        )
    }

    /// Pick up a ride that is still running server-side.
    ///
    /// The ride lives in booking-service, not in this process, so force-closing the app must not
    /// strand it. Without this the rider comes back to the idle map with no way to end a ride
    /// whose meter is still running. Failure is deliberately silent: an unreachable booking
    /// service still leaves a working map, and the next launch tries again.
    func restoreInFlight() {
        Task {
            await refreshRunning()
            // One ride is unambiguous, so go straight into it. With several, the map plus the
            // running-rides bar is the honest surface: picking one would hide the others.
            if running.count == 1, case .idle = state, let only = running.first {
                focus(only)
            }
        }
    }

    /// Reload the set of rides this rider has running, and keep the shared timer ticking.
    func refreshRunning() async {
        if let loaded = try? await rides.history() {
            running = loaded.filter { inFlightStatuses.contains($0.status) }
        }
        if running.isEmpty {
            runningTicker?.cancel()
            runningTicker = nil
        } else if runningTicker == nil {
            runningTicker = Task { [weak self] in
                while !Task.isCancelled {
                    try? await Task.sleep(nanoseconds: 1_000_000_000)
                    self?.tick += 1
                }
            }
        }
    }

    /// Open one of the running rides: the bar's tap target.
    func focus(_ ride: RideResponse) {
        if ride.status == "ACTIVE" { onActive(ride) } else { awaitUnlock(ride) }
    }

    /// Leave the ride screen without ending the ride, so the rider can go back to the map and
    /// take a second scooter. The ride keeps running and stays in `running`.
    func minimise() {
        ticker?.cancel()
        state = .idle
        Task { await refreshRunning() }
    }

    /// Elapsed time for any ride, focused or not: the running-rides bar needs it too.
    func elapsed(of ride: RideResponse) -> Int { elapsedSeconds(ride) }

    /// Running fare for any ride, focused or not.
    func fare(of ride: RideResponse) -> Double { fare(for: ride, seconds: elapsedSeconds(ride)) }

    /// Seconds since the lock confirmed. Falls back to the request time pre-start.
    private func elapsedSeconds(_ ride: RideResponse) -> Int {
        let iso = ISO8601DateFormatter()
        iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        let raw = ride.startedAt ?? ride.requestedAt
        let started = iso.date(from: raw) ?? ISO8601DateFormatter().date(from: raw)
        guard let started else { return 0 }
        return max(0, Int(Date().timeIntervalSince(started)))
    }

    /// Client-side running fare: unlock fee + per-minute rate, minutes rounded up.
    ///
    /// Display only  booking-service calculates the amount actually charged, and the
    /// completion screen shows that figure, not this one.
    private func fare(for ride: RideResponse, seconds: Int) -> Double {
        let minutes = max(0, Int(ceil(Double(seconds) / 60.0)))
        return ride.unlockFee + ride.perMinuteRate * Double(minutes)
    }

    // MARK: - End

    /// Show the pre-charge summary before committing to anything.
    func requestEnd() {
        guard case let .active(ride, seconds, runningFare, _) = state else { return }
        // Stop the clock before leaving .active, so nothing is racing the dialog. The ticker also
        // guards on state, but cancelling here is the honest signal that we have left the screen.
        ticker?.cancel()
        state = .confirmEnd(
            ride: ride,
            elapsedSeconds: seconds,
            estimatedFare: runningFare,
            balanceAfter: balance.map { $0 - runningFare }
        )
    }

    /// Rider backed out  resume the live ride.
    func dismissEnd() {
        guard case let .confirmEnd(ride, _, _, _) = state else { return }
        onActive(ride)
    }

    /// Confirmed. Next stop is the mandatory photo  the end call rejects without one.
    func proceedToPhoto() {
        guard case let .confirmEnd(ride, _, _, _) = state else { return }
        ticker?.cancel()
        state = .photo(ride: ride, captured: nil, uploading: false)
    }

    /// Rider abandoned the photo step  the ride is still running, so go back to it.
    func cancelPhoto() {
        guard case let .photo(ride, _, _) = state else { return }
        onActive(ride)
    }

    func onPhotoCaptured(_ data: Data) {
        guard case let .photo(ride, _, _) = state else { return }
        state = .photo(ride: ride, captured: data, uploading: false)
    }

    func retakePhoto() {
        guard case let .photo(ride, _, _) = state else { return }
        state = .photo(ride: ride, captured: nil, uploading: false)
    }

    /// Upload the photo, then end the ride: zone check → lock → fare → wallet debit.
    func confirmPhotoAndEnd() {
        guard case let .photo(ride, captured, _) = state, let data = captured else { return }
        state = .photo(ride: ride, captured: data, uploading: true)
        Task {
            // Read the position once, outside the do: the refusal path needs the same point the
            // attempt used, or the guidance would be measured from somewhere the rider never was.
            let here = await location.current()
            do {
                let url = try await photos.upload(data)
                state = .ending(ride: ride)
                // The ride ends where the rider is, not where the app guesses: this position is
                // what zone-service checks the parking rule against.
                let invoice = try await rides.endRide(
                    id: ride.id, lat: here.lat, lng: here.lng, photoUrl: url
                )
                state = .completed(ride: ride, invoice: invoice, rating: 0, ratingSubmitted: false)
                refreshBalance()
            } catch {
                // A zone refusal must not cost the rider their photo  they move the
                // scooter and retry from the same step.
                if BlockedReason.classify(error) == .insufficientBalance {
                    // The ride is still running and the scooter is still locked to them, so this
                    // is not the "cannot start" refusal even though the status code is the same.
                    state = .blocked(reason: .balanceToEnd)
                } else if BlockedReason.classify(error) == .outsideZone {
                    zoneRefusal = true
                    state = .photo(ride: ride, captured: data, uploading: false)
                    // "You are outside the parking area" is true but useless on its own. Ask
                    // zone-service what the actual rule is and which open spot is closest, so the
                    // rider is told where to go rather than only where they may not stop.
                    parkingGuidance = await zones.endRefusalGuidance(lat: here.lat, lng: here.lng)
                } else {
                    block(error)
                }
            }
        }
    }

    func clearZoneRefusal() {
        zoneRefusal = false
        parkingGuidance = nil
    }

    // MARK: - Completion

    func rate(_ stars: Int) {
        guard case let .completed(ride, invoice, _, _) = state else { return }
        // TODO(ratings): nothing persists these yet  needs a booking-service endpoint.
        state = .completed(ride: ride, invoice: invoice, rating: stars, ratingSubmitted: true)
    }

    /// Leave the completion screen and return to the hub.
    func finish() {
        ticker?.cancel()
        state = .idle
        refreshBalance()
        // The ride that just ended has left the running set, and any others are still going.
        Task { await refreshRunning() }
    }

    // MARK: - Errors

    func dismissBlocked() { state = .idle }

    private func block(_ error: Error) {
        state = .blocked(reason: BlockedReason.classify(error))
    }

    // MARK: - Balance

    func refreshBalance() {
        Task { balance = try? await wallet.balance().balance }
    }
}
