package main

import (
	"database/sql"
	"strings"
	"time"

	wruntime "github.com/wailsapp/wails/v2/pkg/runtime"

	"hamlog/internal/applog"
)

// emitLiveStatusChanged nudges the frontend to re-read GetLiveStations right when
// the shared table actually changes (a row appeared or was removed) — so the
// "stations on air" widget updates the instant a QSO is published, matching the
// bottom ON-AIR badge instead of waiting for its poll.
func (a *App) emitLiveStatusChanged() {
	if a.ctx != nil {
		wruntime.EventsEmit(a.ctx, "livestatus:updated", nil)
	}
}

// Live operator status — for multi-operator events on a SHARED MySQL logbook
// (e.g. a special-event call like TM74FR with several ops on different bands).
// Each OpsLog instance heartbeats its current activity (operator call + station
// call + freq/band/mode from CAT) into a `live_status` table every ~15s. A tiny
// web script on the operator's own server reads that table and renders a live
// page/image that the QRZ.com bio can embed (`<img src=…>`). OpsLog only WRITES
// to the DB — it is not a web server. Rows older than a couple of minutes are
// "stale" (operator went offline); the web side ignores them.

// liveOnlineWindow is how long after the last logged contact an operator still
// counts as "on air". Leaving the log open without working anyone flips them
// offline once this elapses; logging a new QSO flips them back online.
const liveOnlineWindow = 5 * time.Minute

// noteLiveQSO records that this operator just logged a new contact and pushes the
// live status right away, so they flip back to online the instant they work
// someone. Called from the logging paths (manual entry, UDP auto-log).
func (a *App) noteLiveQSO() {
	a.liveActMu.Lock()
	a.liveLastQSOAt = time.Now()
	a.liveActMu.Unlock()
	if a.liveStatusActive() {
		go a.publishLiveStatus()
	}
}

// seedLiveLastQSO primes liveLastQSOAt from the DB at launch, so an operator who
// worked someone shortly before (re)starting OpsLog is shown "on air" right away
// instead of offline until their next QSO.
func (a *App) seedLiveLastQSO() {
	if a.qso == nil {
		return
	}
	op, _ := a.liveStatusOperator()
	if op == "" {
		return
	}
	if t, ok := a.qso.LastQSOTime(a.ctx, op); ok {
		a.liveActMu.Lock()
		if a.liveLastQSOAt.IsZero() {
			a.liveLastQSOAt = t
		}
		a.liveActMu.Unlock()
	}
}

// liveLastQSOTime is the authoritative "last contact" instant for this operator:
// the most recent of the in-memory stamp (this session's local logs, updated
// instantly) AND the DB (a contact that arrived via the SHARED logbook from another
// station, or one logged before launch). Used by both the published status and the
// UI badge so on-air/offline is right in every multi-op case.
func (a *App) liveLastQSOTime() time.Time {
	a.liveActMu.Lock()
	last := a.liveLastQSOAt
	a.liveActMu.Unlock()
	if a.qso != nil {
		if op, _ := a.liveStatusOperator(); op != "" {
			if t, ok := a.qso.LastQSOTime(a.ctx, op); ok && t.After(last) {
				last = t
			}
		}
	}
	return last
}

// LiveLastQSOAgeSec returns seconds since this operator's last logged QSO, or -1 if
// none is known — the UI polls it for the "on air" badge.
func (a *App) LiveLastQSOAgeSec() int {
	last := a.liveLastQSOTime()
	if last.IsZero() {
		return -1
	}
	return int(time.Since(last).Seconds())
}

// liveStatusLoop heartbeats the current activity while enabled. Started once at
// startup; cheap no-op when disabled or not on MySQL.
func (a *App) liveStatusLoop() {
	defer func() { _ = recover() }() // never crash the app from here
	applog.Printf("livestatus: loop started")
	a.seedLiveLastQSO()   // so online/offline is right at launch, not only after the next QSO
	a.publishLiveStatus() // attempt immediately, don't wait the first tick
	t := time.NewTicker(15 * time.Second)
	defer t.Stop()
	for range t.C {
		a.publishLiveStatus()
	}
}

// liveStatusActive reports whether publishing should run. Always on for a shared
// MySQL logbook (no user toggle: going offline is automatic — no QSO for 5 min
// removes the row — so there is nothing to opt out of).
func (a *App) liveStatusActive() bool {
	return a.logDb != nil && a.dbBackend == "mysql"
}

// liveStatusOperator returns this instance's operator id (the operator callsign,
// falling back to the station callsign for a single-op setup). The callsign and
// operator live on the ACTIVE PROFILE (station_profiles table), NOT in the
// settings KV — read them there.
func (a *App) liveStatusOperator() (op, station string) {
	if a.profiles == nil {
		return "", ""
	}
	p, err := a.profiles.Active(a.ctx)
	if err != nil {
		return "", ""
	}
	station = strings.ToUpper(strings.TrimSpace(p.Callsign))
	op = strings.ToUpper(strings.TrimSpace(p.Operator))
	if op == "" {
		op = station
	}
	return op, station
}

// ReportLiveActivity is called by the UI with the current entry-strip freq/band/
// mode, used as a fallback for live status when the CAT isn't connected.
func (a *App) ReportLiveActivity(freqHz int64, band, mode string) {
	nb := strings.ToUpper(strings.TrimSpace(band))
	nm := strings.ToUpper(strings.TrimSpace(mode))
	a.liveActMu.Lock()
	changed := a.liveFreqHz != freqHz || a.liveBand != nb || a.liveMode != nm
	a.liveFreqHz = freqHz
	a.liveBand = nb
	a.liveMode = nm
	// Push a fresh row PROMPTLY when the operator's freq/band/mode changes, instead
	// of waiting for the next 15 s heartbeat — otherwise "who's on air" showed a
	// stale band and took up to a minute to catch up. Debounced so spinning the VFO
	// doesn't hammer the shared MySQL.
	if changed {
		if a.livePublishTimer != nil {
			a.livePublishTimer.Stop()
		}
		a.livePublishTimer = time.AfterFunc(1500*time.Millisecond, func() { a.publishLiveStatus() })
	}
	a.liveActMu.Unlock()
}

// publishLiveStatus upserts this operator's current activity. Best effort, with
// explicit logging so a silent no-op is diagnosable.
func (a *App) publishLiveStatus() {
	if a.logDb == nil || a.dbBackend != "mysql" {
		return // not a MySQL logbook — nothing to do (silent, runs every 15s)
	}
	op, station := a.liveStatusOperator()
	if op == "" {
		applog.Printf("livestatus: nothing published — no operator/callsign set (Settings → Station)")
		return
	}
	var freqHz int64
	var band, mode string
	if a.cat != nil {
		st := a.cat.State()
		if st.Connected {
			freqHz, band, mode = st.FreqHz, st.Band, st.Mode
		}
	}
	// Fall back to whatever the entry strip last reported (so band/mode/freq are
	// published even when the CAT isn't connected).
	a.liveActMu.Lock()
	if freqHz == 0 {
		freqHz = a.liveFreqHz
	}
	if band == "" {
		band = a.liveBand
	}
	if mode == "" {
		mode = a.liveMode
	}
	a.liveActMu.Unlock()
	lastQSO := a.liveLastQSOTime() // authoritative (in-memory OR shared DB)
	// On air = a new contact was logged within the window. An operator who leaves
	// the log open but stops working goes offline after `liveOnlineWindow`; the next
	// QSO puts them back on. never-logged (zero time) → offline.
	online := !lastQSO.IsZero() && time.Since(lastQSO) < liveOnlineWindow
	if err := a.ensureLiveStatusTable(); err != nil {
		applog.Printf("livestatus: CREATE TABLE failed: %v", err)
		return
	}
	// Offline → REMOVE the row entirely, not just flip a flag: a status page that
	// lists the present rows (the common case, keyed on updated_at) then shows the
	// operator as gone without having to read the online column. The row reappears
	// on the next QSO. This is the whole point — no one shows on air when they're not.
	if !online {
		if _, err := a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op); err != nil {
			applog.Printf("livestatus: offline DELETE failed: %v", err)
		}
		a.emitLiveStatusChanged()
		return
	}
	lastQSOArg := lastQSO.UTC()
	_, err := a.logDb.ExecContext(a.ctx,
		"INSERT INTO live_status (operator, station, freq_hz, band, mode, online, version, last_qso_at, updated_at) "+
			"VALUES (?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
			"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
			"band=VALUES(band), mode=VALUES(mode), online=VALUES(online), version=VALUES(version), "+
			"last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()",
		op, station, freqHz, band, mode, 1, appVersion, lastQSOArg)
	if err != nil {
		applog.Printf("livestatus: INSERT failed: %v", err)
		return
	}
	applog.Printf("livestatus: published op=%s station=%s %dHz %s %s ON AIR", op, station, freqHz, band, mode)
	a.emitLiveStatusChanged()
}

// LiveStation is one operator's live status for the multi-op "who's on air" widget.
type LiveStation struct {
	Operator string `json:"operator"`
	Station  string `json:"station"`
	FreqHz   int64  `json:"freq_hz"`
	Band     string `json:"band"`
	Mode     string `json:"mode"`
	Online   bool   `json:"online"`  // logged a QSO in the last 5 min
	Version  string `json:"version"` // that operator's OpsLog version
	AgeSec   int    `json:"age_sec"` // seconds since their last heartbeat (stale = OpsLog closed)
}

// GetLiveStations returns every operator's live status from the shared MySQL
// logbook (empty on a local SQLite logbook). Rows whose heartbeat is very stale
// (OpsLog closed without clearing its row) are dropped. Online stations first.
func (a *App) GetLiveStations() []LiveStation {
	if a.logDb == nil || a.dbBackend != "mysql" {
		return nil
	}
	if err := a.ensureLiveStatusTable(); err != nil {
		return nil
	}
	rows, err := a.logDb.QueryContext(a.ctx,
		"SELECT operator, COALESCE(station,''), COALESCE(freq_hz,0), COALESCE(band,''), "+
			"COALESCE(mode,''), COALESCE(online,0), COALESCE(version,''), "+
			"TIMESTAMPDIFF(SECOND, updated_at, UTC_TIMESTAMP()) "+
			"FROM live_status ORDER BY online DESC, operator")
	if err != nil {
		applog.Printf("livestatus: list failed: %v", err)
		return nil
	}
	defer rows.Close()
	out := []LiveStation{}
	for rows.Next() {
		var s LiveStation
		var online int
		var age sql.NullInt64
		if err := rows.Scan(&s.Operator, &s.Station, &s.FreqHz, &s.Band, &s.Mode, &online, &s.Version, &age); err != nil {
			continue
		}
		// Drop rows from an OpsLog that hasn't heartbeated in a while (closed): the
		// heartbeat is every 15 s, so > 3 min means it's gone.
		if age.Valid && age.Int64 > 180 {
			continue
		}
		s.Online = online == 1
		if age.Valid {
			s.AgeSec = int(age.Int64)
		}
		out = append(out, s)
	}
	return out
}

// ensureLiveStatusTable creates/upgrades the live_status table ONCE per logbook
// connection. It used to run the CREATE + 3 ALTERs on every call — and it is
// called from every publish (15s heartbeat) AND every GetLiveStations poll (5s),
// so that was 4 wasted round-trips per call on a remote MySQL, adding latency to
// the "who's on air" widget and holding pool connections for nothing.
func (a *App) ensureLiveStatusTable() error {
	db := a.logDb
	a.liveTableMu.Lock()
	done := a.liveTableFor == db // re-ensure after a profile switch swaps the logbook
	a.liveTableMu.Unlock()
	if done {
		return nil
	}
	if err := a.ensureLiveStatusTableDDL(); err != nil {
		return err
	}
	a.liveTableMu.Lock()
	a.liveTableFor = db
	a.liveTableMu.Unlock()
	return nil
}

func (a *App) ensureLiveStatusTableDDL() error {
	if _, err := a.logDb.ExecContext(a.ctx,
		"CREATE TABLE IF NOT EXISTS live_status ("+
			"operator VARCHAR(32) PRIMARY KEY, "+
			"station VARCHAR(32), "+
			"freq_hz BIGINT, "+
			"band VARCHAR(16), "+
			"mode VARCHAR(16), "+
			"online TINYINT DEFAULT 0, "+
			"version VARCHAR(32), "+
			"last_qso_at DATETIME NULL, "+
			"updated_at DATETIME)"); err != nil {
		return err
	}
	// Add newer columns to a table created by an older build. MySQL has no portable
	// "ADD COLUMN IF NOT EXISTS", so just run the ALTERs and ignore the duplicate-
	// column error when they already exist.
	for _, ddl := range []string{
		"ALTER TABLE live_status ADD COLUMN online TINYINT DEFAULT 0",
		"ALTER TABLE live_status ADD COLUMN version VARCHAR(32)",
		"ALTER TABLE live_status ADD COLUMN last_qso_at DATETIME NULL",
	} {
		if _, err := a.logDb.ExecContext(a.ctx, ddl); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
			applog.Printf("livestatus: %q: %v", ddl, err)
		}
	}
	return nil
}

// clearLiveStatus removes this operator's row (on disable / shutdown).
func (a *App) clearLiveStatus() {
	if a.logDb == nil || a.dbBackend != "mysql" {
		return
	}
	op, _ := a.liveStatusOperator()
	if op == "" {
		return
	}
	_, _ = a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op)
}
