import CoreLocation
import Foundation

/// The signals relevance scoring reads from a note. A plain value type so
/// scoring is testable without SwiftData.
struct RelevanceSignals: Sendable, Equatable {
    var latitude: Double?
    var longitude: Double?
    var placeName: String?
    var locality: String?
    var createdAt: Date
    var timeZoneID: String?
}

/// The recall-time context a query scores against.
struct RelevanceQuery: Sendable {
    var latitude: Double?
    var longitude: Double?
    var placeName: String?
    var locality: String?
    var date: Date = Date()
    var timeZone: TimeZone = .current
}

/// Context-based ranking: given "where am I, what time is it" now, how likely
/// is this note the one the user wants at their fingertips?
///
/// The score is a sum of independent affinity terms, each decaying smoothly
/// from its ideal (same spot, same hour, just written). Weights are tuned so
/// physical proximity dominates — being back at the coffee shop should
/// surface the barista's name even if the note is months old.
enum Relevance {
    /// Score at or above which a note belongs in the "right here, right now"
    /// shelf.
    static let surfacingThreshold = 1.0

    static func score(_ note: RelevanceSignals, query: RelevanceQuery) -> Double {
        var score = 0.0

        // Proximity: exponential decay with ~250 m scale — full weight inside
        // a venue, negligible a few kilometers away.
        if let noteLat = note.latitude, let noteLon = note.longitude,
           let hereLat = query.latitude, let hereLon = query.longitude {
            let meters = CLLocation(latitude: noteLat, longitude: noteLon)
                .distance(from: CLLocation(latitude: hereLat, longitude: hereLon))
            score += 3.0 * exp(-meters / 250)
        }

        // Same named place (exact, case-insensitive) — reverse geocoding is
        // stable enough that this is a strong "you are back here" signal even
        // when GPS wobbles.
        if let notePlace = note.placeName, let herePlace = query.placeName,
           !notePlace.isEmpty, notePlace.caseInsensitiveCompare(herePlace) == .orderedSame {
            score += 1.5
        } else if let noteCity = note.locality, let hereCity = query.locality,
                  !noteCity.isEmpty, noteCity.caseInsensitiveCompare(hereCity) == .orderedSame {
            score += 0.3
        }

        // Time-of-day affinity: Gaussian over circular hour distance
        // (σ ≈ 2.5 h) — a "morning" note resurfaces in the morning.
        let noteHour = localHour(of: note.createdAt, timeZoneID: note.timeZoneID)
        let queryHour = Double(Calendar.current.component(.hour, from: query.date))
            + Double(Calendar.current.component(.minute, from: query.date)) / 60
        let hourDelta = circularHourDistance(noteHour, queryHour)
        score += 0.6 * exp(-(hourDelta * hourDelta) / (2 * 2.5 * 2.5))

        // Same weekday: weak habitual signal (the Saturday farmers market).
        var noteCalendar = Calendar.current
        if let id = note.timeZoneID, let zone = TimeZone(identifier: id) {
            noteCalendar.timeZone = zone
        }
        var queryCalendar = Calendar.current
        queryCalendar.timeZone = query.timeZone
        if noteCalendar.component(.weekday, from: note.createdAt)
            == queryCalendar.component(.weekday, from: query.date) {
            score += 0.2
        }

        // Recency: fresh notes float regardless of place (~45-day half-life
        // feel; purely a tiebreaker against the location terms).
        let ageDays = max(0, query.date.timeIntervalSince(note.createdAt)) / 86_400
        score += 0.5 * exp(-ageDays / 45)

        return score
    }

    /// Hour-of-day (fractional) of `date` in the zone the note was taken in,
    /// falling back to the current zone.
    private static func localHour(of date: Date, timeZoneID: String?) -> Double {
        var calendar = Calendar.current
        if let timeZoneID, let zone = TimeZone(identifier: timeZoneID) {
            calendar.timeZone = zone
        }
        let hour = calendar.component(.hour, from: date)
        let minute = calendar.component(.minute, from: date)
        return Double(hour) + Double(minute) / 60
    }

    /// Distance between two hours on the 24-hour circle (0...12).
    private static func circularHourDistance(_ a: Double, _ b: Double) -> Double {
        let diff = abs(a - b).truncatingRemainder(dividingBy: 24)
        return min(diff, 24 - diff)
    }
}

extension RelevanceSignals {
    init(entity: NoteEntity) {
        self.init(
            latitude: entity.latitude,
            longitude: entity.longitude,
            placeName: entity.placeName,
            locality: entity.locality,
            createdAt: entity.createdAt,
            timeZoneID: entity.timeZoneID
        )
    }
}

extension RelevanceQuery {
    @MainActor
    init(context service: ContextService) {
        self.init(
            latitude: service.current.latitude,
            longitude: service.current.longitude,
            placeName: service.current.placeName,
            locality: service.current.locality
        )
    }
}
