/*
 * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
 */

package at.bitfire.synctools.icalendar

import androidx.annotation.VisibleForTesting
import at.bitfire.synctools.exception.ResourceMappingException
import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate
import net.fortuna.ical4j.model.Parameter
import net.fortuna.ical4j.model.parameter.TzId
import net.fortuna.ical4j.model.property.DateListProperty
import net.fortuna.ical4j.model.property.DateProperty
import java.time.DateTimeException
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.temporal.Temporal
import java.util.logging.Level
import java.util.logging.Logger
import kotlin.jvm.optionals.getOrNull

object DatePropertyTzMapper {

    private val logger: Logger
        get() = Logger.getLogger(javaClass.name)

    /**
     * Normalizes the date property to a system-compatible temporal representation.
     *
     * Processes the underlying date or date-time of the DateProperty to ensure compatibility
     * with content providers by converting ical4j-specific temporal types to system-known types:
     *
     * - Converts OffsetDateTime to Instant (UTC timestamp).
     * - Converts ZonedDateTime with ical4j-based timezones to ZonedDateTime with system-known ZoneId.
     * - Leaves Instant, LocalDate, LocalDateTime, and other temporal types unchanged.
     *
     * Note: Since ical4j 4.3.0, `DateProperty.getDate()` no longer throws for unknown TZIDs under
     * relaxed validation — it returns a floating `LocalDateTime` instead. `normalizedDate()` returns
     * this as-is; downstream code treats it as a floating date-time using the system default timezone.
     *
     * @return A normalized Temporal object:
     *         - Instant for UTC date-times (originally OffsetDateTime).
     *         - ZonedDateTime with system-known ZoneId for date-times with TZID.
     *         - Original Temporal type for other cases (Instant, LocalDate, LocalDateTime, etc.).
     */
    fun DateProperty<*>.normalizedDate(): Temporal {
        /* This date is generated by ical4j's TemporalAdapter and uses
        - OffsetDateTime for UTC date-times ("...Z") and
        - ZonedDateTime with ical4j-based timezones for date-times with TZID.
        Since ical4j 4.3.0, an unknown TZID (no matching VTIMEZONE) is returned as LocalDateTime. */

        return when (val origDate: Temporal = date) {
            // In content providers, there's no concept of offset date-times. We just want the UTC timestamp instead.
            is OffsetDateTime ->
                origDate.toInstant()

            // In case of date-time with TZID, make sure that we have a ZonedDateTime with a system-known ZoneId.
            is ZonedDateTime ->
                normalizeZonedDateTime(
                    origDate = origDate,
                    tzId = getParameter<TzId>(Parameter.TZID).getOrNull()?.value
                )

            else ->
                // return Instant and LocalDate/... as it is
                origDate
        }
    }

    /**
     * Normalizes the date properties to a system-compatible temporal representation. Just like
     * [normalizedDate], but for date lists.
     *
     * Processes the underlying dates or date-times of the DateListProperty to ensure compatibility
     * with content providers by converting ical4j-specific temporal types to system-known types:
     *
     * - Converts OffsetDateTime to Instant (UTC timestamp).
     * - Converts ZonedDateTime with ical4j-based timezones to ZonedDateTime with system-known ZoneId.
     * - Leaves Instant, LocalDate, and other temporal types unchanged.
     *
     * @see normalizedDate
     *
     * @return A list of normalized Temporal objects:
     *         - Instant for UTC date-times (originally OffsetDateTime).
     *         - ZonedDateTime with system-known ZoneId for date-times with TZID.
     *         - Original Temporal type for other cases (Instant, LocalDate, etc.).
     */
    fun DateListProperty<*>.normalizedDates(): List<Temporal> {
        /* These dates are generated by ical4j's TemporalAdapter and use
        - OffsetDateTime for UTC date-times ("...Z") and
        - ZonedDateTime with ical4j-based timezones for date-times with TZID. */

        val origDates: List<Temporal> = try {
            dates
        } catch (e: DateTimeException) {
            val message = "Error while getting list of dates"
            logger.log(Level.WARNING, message, e)
            throw ResourceMappingException(message, e)
        }

        return origDates.map { origDate ->
            when (origDate) {
                // In content providers, there's no concept of offset date-times. We just want the UTC timestamp instead.
                is OffsetDateTime ->
                    origDate.toInstant()

                // In case of date-time with TZID, make sure that we have a ZonedDateTime with a system-known ZoneId.
                is ZonedDateTime ->
                    normalizeZonedDateTime(
                        origDate = origDate,
                        tzId = getParameter<TzId>(Parameter.TZID).getOrNull()?.value
                    )

                else ->
                    // return Instant and LocalDate/... as it is
                    origDate
            }
        }
    }

    private fun normalizeZonedDateTime(origDate: ZonedDateTime, tzId: String?): ZonedDateTime {
        /* In case of ZonedDateTime, date.zone.id will look like "ical4j~<uuid>" (if taken from
        ical4j database) or "ical4j-local-xxx" (if generated from VTIMEZONE). We want to
        replace such ical4j-based timezones by system timezones because the content providers
        need a system timezone ID. */

        // Get corresponding system TZID (usually the same – "Europe/Vienna" in our example)
        val systemTzId = systemTzId(tzId)
        if (systemTzId != null) {
            // Timezone is known by system, we want to use the same time string
            // ("ddmmyyyyTHHMMSS"), but with the system timezone instead of the ical4j timezone.
            val systemTz = ZoneId.of(tzId)
            val result = origDate.withZoneSameLocal(systemTz)

            val origDateInstant = origDate.toInstant()
            val resultInstant = result.toInstant()
            if (origDateInstant != resultInstant)
                logger.log(
                    Level.WARNING, "Different timestamps of normalized {0} ({1}) and original {2} ({3}) ZonedDateTime",
                    arrayOf(result, resultInstant.toEpochMilli(), origDate, origDateInstant.toEpochMilli())
                )

            return result

        } else {
            // Timezone ID unknown or timezone not known by system, fall back to same timestamp, but
            // with system default timezone.
            logger.warning("ZonedDateTime ($origDate) with unknown timezone ($tzId), using calculated timestamp in system default timezone")
            return origDate.withZoneSameInstant(ZoneId.systemDefault())
        }
    }


    /**
     * Attempts to find a matching system timezone ID for the given original timezone ID.
     *
     * This method first checks for an exact case-insensitive match among the available system timezone IDs.
     * If no exact match is found, it then searches for a partial match where either the system ID contains
     * the original ID or vice versa (case-insensitive comparison).
     *
     * @param origTzId The original timezone ID to match against system timezone IDs. Can be null.
     * @return The matching system timezone ID if found, otherwise null. Returns null if the input is null.
     */
    @VisibleForTesting
    internal fun systemTzId(origTzId: String?): String? {
        if (origTzId == null)
            return null

        val systemIds = ZoneId.getAvailableZoneIds()
        // First, try to find an exact match (case-insensitive)
        for (systemId in systemIds)
            if (origTzId.equals(systemId, ignoreCase = true))
                return systemId

        // Otherwise, try to find a partial match (sometimes the origTzId is something like
        // "/freeassociation.sourceforge.net/Tzfile/Europe/Vienna"). This shouldn't be
        // case-insensitive, because that would for instance return "EST" for "Westeuropäische Sommerzeit".
        for (systemId in systemIds)
            if (systemId.contains(origTzId) || origTzId.contains(systemId))
                return systemId

        // No result
        return null
    }

}