package com.ffii.fpsms.modules.common.alert

import com.ffii.fpsms.m18.entity.M18DataLogRepository
import com.ffii.fpsms.m18.entity.SchedulerSyncLog
import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository
import com.ffii.fpsms.m18.enums.M18DataLogStatus
import com.ffii.fpsms.modules.common.SettingNames
import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService
import com.ffii.fpsms.modules.settings.entity.Settings
import com.ffii.fpsms.modules.settings.service.SettingsService
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.scheduling.support.CronExpression
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import kotlin.jvm.optionals.getOrNull

/**
 * Watches [scheduler_sync_log] after M18 cron jobs and sends SMS when rules fail.
 *
 * - **DO1**: low volume (&lt; min records), FAILED status, zero records, line failures.
 * - **PO / DO2 / master-data**: no SUCCESS log by schedule + grace window.
 * - **PO_LINE**: [m18_data_log] purchase order line FAIL rows (one email per PO code per day).
 */
@Service
open class SchedulerSyncAlertService(
    private val properties: SchedulerSyncAlertProperties,
    private val smsSender: SmsSender,
    private val syncAlertEmailSender: SyncAlertEmailSender,
    private val schedulerSyncLogRepository: SchedulerSyncLogRepository,
    private val m18DataLogRepository: M18DataLogRepository,
    private val settingsService: SettingsService,
    private val webClientBuilder: WebClient.Builder,
    @Value("\${scheduler.m18Sync.enabled:false}") private val m18SyncEnabled: Boolean,
) {
    private val logger = LoggerFactory.getLogger(SchedulerSyncAlertService::class.java)

    private companion object {
        const val PO_LINE_REF_TYPE = "Purchase Order Line"
        const val PO_LINE_ALERT_JOB = "PO_LINE"
    }

    /** Master-data sub-jobs written by [SchedulerService.getM18MasterData]. */
    private val masterDataSyncTypes =
        listOf("Units", "Products", "Vendors", "BusinessUnits", "Currencies")

    open fun runChecks(now: LocalDateTime = LocalDateTime.now()): List<String> {
        val alerts = mutableListOf<Triple<String, String, String>>()
        alerts += checkPoLineFailures(now)

        if (!m18SyncEnabled) {
            for (message in alerts) {
                sendAlert(message.first, message.second, message.third)
            }
            if (alerts.isEmpty()) {
                logger.debug("Sync alert skipped (scheduler.m18Sync.enabled=false)")
            }
            return alerts.map { it.third }
        }

        alerts += checkDo1(now)
        alerts += checkPresenceSync("PO", SettingNames.SCHEDULE_M18_PO, "0 0 2 * * *", now)
        alerts += checkPresenceSync("DO2", SettingNames.SCHEDULE_M18_DO2, SchedulerService.DO2_DEFAULT_CRON, now)
        alerts += checkMasterDataPresence(now)

        for (message in alerts) {
            sendAlert(message.first, message.second, message.third)
        }
        return alerts.map { it.third }
    }

    /**
     * Email when M18 PO line sync wrote FAIL rows to [m18_data_log] today.
     * Dedupes via [sendAlert] using job [PO_LINE_ALERT_JOB] and PO code (one email per PO per day).
     */
    private fun checkPoLineFailures(now: LocalDateTime): List<Triple<String, String, String>> {
        if (!properties.poLine.enabled) {
            return emptyList()
        }

        val dayStart = now.toLocalDate().atStartOfDay()
        val logs =
            m18DataLogRepository.findAllByRefTypeAndStatusAndDeletedIsFalseAndCreatedGreaterThanEqualOrderByIdAsc(
                PO_LINE_REF_TYPE,
                M18DataLogStatus.FAIL,
                dayStart,
            )
        if (logs.isEmpty()) {
            return emptyList()
        }

        val bulletsByPo = linkedMapOf<String, LinkedHashSet<String>>()
        for (log in logs) {
            val raw = log.dataLog?.get("Exception Message")?.toString()
            val parsed = PoLineFailureAlertSupport.parseExceptionMessage(raw) ?: continue
            bulletsByPo.getOrPut(parsed.first) { LinkedHashSet() }.add(parsed.second)
        }
        if (bulletsByPo.isEmpty()) {
            return emptyList()
        }

        return bulletsByPo.map { (poCode, bullets) ->
            Triple(
                PO_LINE_ALERT_JOB,
                poCode,
                PoLineFailureAlertSupport.buildEmailBody(poCode, bullets),
            )
        }
    }

    private fun checkDo1(now: LocalDateTime): List<Triple<String, String, String>> {
        val runDate = now.toLocalDate()
        val scheduledTime = resolveDo1ScheduledTime(runDate) ?: return emptyList()
        val checkAfter = scheduledTime.plusMinutes(properties.do1.graceMinutesAfterSchedule.toLong())
        if (now.isBefore(checkAfter)) {
            return emptyList()
        }

        val log = latestLogOnDay("DO1", runDate) ?: run {
            return listOf(Triple("DO1", "MISSING", alertMessage("DO1", "MISSING", "No DO1 sync log today (expected after ${scheduledTime.toLocalTime()}).")))
        }

        val issues = mutableListOf<String>()
        val cfg = properties.do1

        if (cfg.alertOnFailed && log.status.equals("FAILED", ignoreCase = true)) {
            issues += "status=FAILED ${log.errorMessage.orEmpty()}".trim()
        }
        if (cfg.alertOnZeroRecords && log.recordsProcessed == 0) {
            issues += "0 orders processed (possible M18 list timeout)"
        }
        if (log.recordsProcessed < cfg.minRecordsProcessed) {
            issues += "only ${log.recordsProcessed} processed (min ${cfg.minRecordsProcessed})"
        }
        if (cfg.alertOnLineFailures && log.recordsFailed > 0) {
            issues += "${log.recordsFailed} line(s) failed"
        }

        if (issues.isEmpty()) {
            return emptyList()
        }
        return listOf(Triple("DO1", "PROBLEM", alertMessage("DO1", "PROBLEM", issues.joinToString("; "))))
    }

    private fun checkMasterDataPresence(now: LocalDateTime): List<Triple<String, String, String>> {
        val runDate = now.toLocalDate()
        val cron =
            settingsService.findByName(SettingNames.SCHEDULE_M18_MASTER).getOrNull()?.value
                ?: "0 0 1 * * *"
        val scheduledTime = scheduledTimeToday(cron, runDate) ?: return emptyList()
        val checkAfter = scheduledTime.plusMinutes(properties.presence.graceMinutesAfterSchedule.toLong())
        if (now.isBefore(checkAfter)) {
            return emptyList()
        }

        val missing = masterDataSyncTypes.filter { type ->
            val log = latestLogOnDay(type, runDate)
            log == null || !log.status.equals("SUCCESS", ignoreCase = true)
        }
        if (missing.isEmpty()) {
            return emptyList()
        }
        return listOf(
            Triple(
                "MASTER",
                "MISSING",
                alertMessage("MASTER", "MISSING", "No SUCCESS today for: ${missing.joinToString()}"),
            ),
        )
    }

    private fun checkPresenceSync(
        label: String,
        settingName: String,
        defaultCron: String,
        now: LocalDateTime,
    ): List<Triple<String, String, String>> {
        val runDate = now.toLocalDate()
        val cron = settingsService.findByName(settingName).getOrNull()?.value ?: defaultCron
        val scheduledTime = scheduledTimeToday(cron, runDate) ?: return emptyList()
        val checkAfter = scheduledTime.plusMinutes(properties.presence.graceMinutesAfterSchedule.toLong())
        if (now.isBefore(checkAfter)) {
            return emptyList()
        }

        val log = latestLogOnDay(label, runDate)
        when {
            log == null ->
                return listOf(
                    Triple(
                        label,
                        "MISSING",
                        alertMessage(label, "MISSING", "No $label sync log today (expected after ${scheduledTime.toLocalTime()})."),
                    ),
                )
            !log.status.equals("SUCCESS", ignoreCase = true) ->
                return listOf(
                    Triple(
                        label,
                        log.status.uppercase(),
                        alertMessage(
                            label,
                            log.status.uppercase(),
                            "processed=${log.recordsProcessed} failed=${log.recordsFailed} ${log.errorMessage.orEmpty()}".trim(),
                        ),
                    ),
                )
            else -> return emptyList()
        }
    }

    private fun latestLogOnDay(syncType: String, date: LocalDate): SchedulerSyncLog? {
        val dayStart = date.atStartOfDay()
        val dayEnd = dayStart.plusDays(1)
        return schedulerSyncLogRepository.findFirstBySyncTypeAndStartTimeBetweenOrderByEndTimeDesc(
            syncType,
            dayStart,
            dayEnd,
        )
    }

    private fun resolveDo1ScheduledTime(date: LocalDate): LocalDateTime? {
        val cron =
            if (date.dayOfWeek == DayOfWeek.SATURDAY) {
                settingsService.findByName(SettingNames.SCHEDULE_M18_DO1_SAT).getOrNull()?.value
                    ?: "0 10 3 ? * SAT"
            } else {
                settingsService.findByName(SettingNames.SCHEDULE_M18_DO1).getOrNull()?.value
                    ?: "0 10 19 * * *"
            }
        return scheduledTimeToday(cron, date)
    }

    /** Next fire time on [date] from a 6-field Spring cron, if that day matches the DOW field. */
    internal fun scheduledTimeToday(cronExpression: String, date: LocalDate): LocalDateTime? {
        if (!cronMatchesDate(cronExpression, date)) {
            return null
        }
        val parts = cronExpression.trim().split(Regex("\\s+"))
        if (parts.size != 6) {
            return null
        }
        val second = parts[0].toIntOrNull() ?: 0
        val minute = parts[1].toIntOrNull() ?: return null
        val hour = parts[2].toIntOrNull() ?: return null
        return date.atTime(LocalTime.of(hour, minute, second))
    }

    internal fun cronMatchesDate(cronExpression: String, date: LocalDate): Boolean {
        val parts = cronExpression.trim().split(Regex("\\s+"))
        if (parts.size != 6) {
            return false
        }
        val dowField = parts[5].uppercase()
        if (dowField == "*" || dowField == "?") {
            return true
        }

        val day = date.dayOfWeek
        val tokens = dowField.split(",")
        for (token in tokens) {
            val trimmed = token.trim()
            when {
                trimmed.contains("-") -> {
                    val range = trimmed.split("-")
                    if (range.size == 2) {
                        val from = parseDayOfWeek(range[0]) ?: continue
                        val to = parseDayOfWeek(range[1]) ?: continue
                        if (day.value in from.value..to.value) {
                            return true
                        }
                    }
                }
                else -> {
                    parseDayOfWeek(trimmed)?.let { if (day == it) return true }
                }
            }
        }
        return false
    }

    private fun parseDayOfWeek(token: String): DayOfWeek? =
        when (token.uppercase()) {
            "SUN", "0", "7" -> DayOfWeek.SUNDAY
            "MON", "1" -> DayOfWeek.MONDAY
            "TUE", "2" -> DayOfWeek.TUESDAY
            "WED", "3" -> DayOfWeek.WEDNESDAY
            "THU", "4" -> DayOfWeek.THURSDAY
            "FRI", "5" -> DayOfWeek.FRIDAY
            "SAT", "6" -> DayOfWeek.SATURDAY
            else -> null
        }

    private fun alertMessage(job: String, code: String, detail: String): String =
        "FPSMS $job $code: $detail"

    private fun detailFromMessage(message: String): String {
        val prefix = message.indexOf(": ")
        return if (prefix >= 0) message.substring(prefix + 2) else message
    }

    private fun sendAlert(job: String, code: String, message: String) {
        val dedupeKey = "SCHEDULE.syncAlert.sent.$job.$code.${LocalDate.now()}"
        if (alreadySent(dedupeKey)) {
            logger.debug("Sync alert already sent today: {}", message)
            return
        }

        if (!properties.enabled) {
            logger.warn("[sync-alert disabled] {}", message)
            return
        }

        var anySent = false

        if (properties.email.enabled) {
            val emailTo = properties.email.toAddresses.split(",")
                .map { it.trim() }
                .filter { it.isNotBlank() }
            if (emailTo.isEmpty()) {
                logger.error("Sync alert email skipped (no scheduler.sync-alert.email.to-addresses): {}", message)
            } else if (!syncAlertEmailSender.isSmtpConfigured()) {
                logger.error(
                    "Sync alert email skipped (configure MAIL.smtp.username/password/host in settings): {}",
                    message,
                )
            } else {
                try {
                    val subject = "${properties.email.subjectPrefix} [$job] $code"
                    syncAlertEmailSender.send(emailTo, subject, message)
                    anySent = true
                    logger.info("Sync alert email sent: {}", message)
                } catch (e: Exception) {
                    logger.error("Sync alert email failed: {}", message, e)
                }
            }
        }

        val smsTo = properties.sms.toNumbers.split(",")
            .map { it.trim() }
            .filter { it.isNotBlank() }
        val smsActive =
            properties.sms.enabled &&
                !properties.sms.provider.equals("log", ignoreCase = true) &&
                smsTo.isNotEmpty()
        if (smsActive) {
            try {
                smsSender.send(smsTo, message, job, code, detailFromMessage(message))
                anySent = true
                logger.info("Sync alert SMS sent: {}", message)
            } catch (e: Exception) {
                logger.error("Sync alert SMS failed: {}", message, e)
            }
        }

        if (anySent) {
            markSent(dedupeKey, message)
        } else {
            logger.error("Sync alert not delivered on any channel: {}", message)
        }
    }

    private fun alreadySent(key: String): Boolean =
        settingsService.findByName(key).map { it.value == Settings.VALUE_BOOLEAN_TRUE }.orElse(false)

    private fun markSent(key: String, message: String) {
        val existing = settingsService.findByName(key).orElse(null)
        if (existing != null) {
            settingsService.update(key, Settings.VALUE_BOOLEAN_TRUE)
        } else {
            val setting = Settings()
            setting.name = key
            setting.value = Settings.VALUE_BOOLEAN_TRUE
            setting.category = "SCHEDULE"
            setting.type = Settings.TYPE_BOOLEAN
            settingsService.save(setting)
        }
    }

    /** Validates cron at startup / schedule registration. */
    fun isValidCronExpression(cronExpression: String): Boolean =
        try {
            CronExpression.parse(cronExpression)
            true
        } catch (_: IllegalArgumentException) {
            false
        }

    /**
     * Send one WhatsApp using your Twilio Content template (same shape as Twilio console sample).
     * Defaults: var1=today d/M, var2=now h:mma (e.g. 17/6, 7:30pm). Pass var1=12/1&var2=3pm to match sandbox demo.
     */
    open fun sendTestWhatsApp(var1: String?, var2: String?): String {
        if (!properties.sms.enabled) {
            return "WhatsApp/SMS sync alerts are disabled (scheduler.sync-alert.sms.enabled=false)."
        }
        twilioConfigIssue()?.let { return it }

        val twilio = resolveTwilioSender()!!

        val recipients = properties.sms.toNumbers.split(",")
            .map { it.trim() }
            .filter { it.isNotBlank() }

        val now = LocalDateTime.now()
        val v1 = var1?.trim()?.takeIf { it.isNotEmpty() }
            ?: now.format(DateTimeFormatter.ofPattern("d/M"))
        val v2 = var2?.trim()?.takeIf { it.isNotEmpty() }
            ?: now.format(DateTimeFormatter.ofPattern("h:mma", Locale.ENGLISH))

        twilio.sendWithTemplateVars(recipients, v1, v2)
        return "WhatsApp test sent ContentVariables={\"1\":\"$v1\",\"2\":\"$v2\"} to ${recipients.joinToString()}"
    }

    /** Send a test email via DB SMTP (Office 365). Does not require sync-alert.enabled. */
    open fun sendTestEmail(message: String? = null, subject: String? = null): String {
        val emailTo = properties.email.toAddresses.split(",")
            .map { it.trim() }
            .filter { it.isNotBlank() }
        if (emailTo.isEmpty()) {
            return "Set scheduler.sync-alert.email.to-addresses (e.g. vluk@2fi-solutions.com.hk)"
        }
        if (!syncAlertEmailSender.isSmtpConfigured()) {
            return syncAlertEmailSender.smtpConfigIssue()
                ?: "Configure MAIL.smtp.username, MAIL.smtp.password, MAIL.smtp.host in settings table"
        }
        val from = syncAlertEmailSender.resolveFromAddress() ?: "unknown"
        val body =
            message?.trim()?.takeIf { it.isNotEmpty() }
                ?: (
                    "FPSMS sync alert email test.\n\n" +
                        "If you receive this, Office 365 SMTP is working.\n" +
                        "From (MAIL.smtp.username): $from\n" +
                        "Time: ${LocalDateTime.now()}"
                    )
        val emailSubject =
            subject?.trim()?.takeIf { it.isNotEmpty() }
                ?: "${properties.email.subjectPrefix} [TEST]"
        return try {
            syncAlertEmailSender.send(emailTo, emailSubject, body)
            "Test email sent from $from to ${emailTo.joinToString()}\nSubject: $emailSubject"
        } catch (e: Exception) {
            val hint =
                when {
                    e.message?.contains("530", ignoreCase = true) == true ||
                        e.message?.contains("not authenticated", ignoreCase = true) == true ->
                        "\n\nOffice 365 fix checklist:\n" +
                            "1. UPDATE settings SET value='true' WHERE name='MAIL.smtp.auth';\n" +
                            "2. MAIL.smtp.username must match the mailbox (vluk@2fi-solutions.com.hk)\n" +
                            "3. Use an App password if MFA is on (not your normal login password)\n" +
                            "4. IT must enable 'Authenticated SMTP' for this mailbox in Exchange admin\n" +
                            "5. Restart backend after changing settings (SMTP client is cached)"
                    else -> ""
                }
            "Email send failed: ${e.message}$hint"
        }
    }

    /** Uses injected [TwilioSmsSender] bean, or builds one when Twilio credentials are in config. */
    private fun resolveTwilioSender(): TwilioSmsSender? {
        if (twilioConfigIssue() != null) {
            return null
        }
        if (smsSender is TwilioSmsSender) {
            return smsSender
        }
        return TwilioSmsSender(properties, webClientBuilder)
    }

    private fun twilioConfigIssue(): String? {
        val sms = properties.sms
        val missing = mutableListOf<String>()
        if (sms.accountSid.isBlank()) {
            missing += "TWILIO_ACCOUNT_SID (or scheduler.sync-alert.sms.account-sid)"
        }
        if (sms.authToken.isBlank()) {
            missing += "TWILIO_AUTH_TOKEN (or scheduler.sync-alert.sms.auth-token)"
        }
        if (sms.fromNumber.isBlank()) {
            missing += "TWILIO_FROM_NUMBER"
        }
        if (sms.toNumbers.isBlank()) {
            missing += "SYNC_ALERT_SMS_TO (or scheduler.sync-alert.sms.to-numbers)"
        }
        if (sms.contentSid.isBlank()) {
            missing += "TWILIO_WHATSAPP_CONTENT_SID"
        }
        if (missing.isEmpty()) {
            return null
        }
        return (
            "Twilio not configured. Missing: ${missing.joinToString(", ")}.\n" +
                "Set env vars and restart backend. Example (PowerShell before bootRun):\n" +
                "\$env:TWILIO_ACCOUNT_SID='AC…'; \$env:TWILIO_AUTH_TOKEN='…'; " +
                "\$env:SYNC_ALERT_SMS_PROVIDER='twilio'"
            )
    }
}
