package main

import (
	"context"
	"fmt"
	"time"

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

	"hamlog/internal/applog"
	"hamlog/internal/db"
	"hamlog/internal/offlineq"
	"hamlog/internal/qso"
)

// ── Offline safety net ────────────────────────────────────────────────
//
// With the shared MySQL logbook, the network sits on the critical path of every
// write: lose it and you can't log at all. Rather than a fragile "fall back to
// SQLite" (which would leave you with a partial log and needs a restart), we
// keep the architecture exactly as it is and add a NET:
//
//	DB unreachable → the QSO is parked in a local ADIF outbox (never lost)
//	DB back        → replayed into the logbook, idempotently, file archived
//
// It only ever PUSHES your own QSOs — no mirror, no pull, no merge. That's what
// keeps it small. Accepted trade-off: while offline you don't see other ops'
// QSOs, and worked-before doesn't know about your own pending ones (they're
// listed separately in the UI instead).

// offlineReplayEvery is how often we re-probe the database while QSOs are
// waiting. Short enough to feel automatic, long enough not to hammer a dead host.
const offlineReplayEvery = 15 * time.Second

// OfflineStatus is what the UI shows: are we parked, and how much is waiting.
type OfflineStatus struct {
	Offline bool   `json:"offline"` // last write failed because the DB was unreachable
	Pending int    `json:"pending"` // QSOs sitting in the outbox
	Path    string `json:"path"`    // where the outbox physically is
}

// queueOffline parks a QSO that couldn't reach the database. Returns false when
// queueing isn't applicable (local SQLite can't "go offline") or the write to the
// outbox itself failed — in which case the caller MUST surface the original error
// rather than pretend the QSO was saved.
func (a *App) queueOffline(q qso.QSO, cause error) bool {
	if a.offlineQ == nil || !db.IsMySQL() {
		return false
	}
	qid, err := a.offlineQ.Append(q)
	if err != nil {
		// The net itself tore: do NOT claim success.
		applog.Printf("offline: FAILED to park %s in the outbox: %v (original: %v)", q.Callsign, err, cause)
		return false
	}
	applog.Printf("offline: DB unreachable (%v) — parked %s in the outbox (id %s)", cause, q.Callsign, qid)
	a.offlineMode = true
	a.emitOfflineStatus()
	return true
}

// emitOfflineStatus pushes the banner state to the UI.
func (a *App) emitOfflineStatus() {
	if a.ctx == nil || a.offlineQ == nil {
		return
	}
	wruntime.EventsEmit(a.ctx, "offline:status", a.GetOfflineStatus())
}

// GetOfflineStatus reports whether we're parked and how many QSOs are waiting.
func (a *App) GetOfflineStatus() OfflineStatus {
	if a.offlineQ == nil {
		return OfflineStatus{}
	}
	n := a.offlineQ.Count()
	return OfflineStatus{
		Offline: a.offlineMode && n > 0,
		Pending: n,
		Path:    a.offlineQ.Path(),
	}
}

// GetPendingQSOs lists the QSOs waiting in the outbox, so the operator can SEE
// what they logged while the database was down instead of flying blind.
func (a *App) GetPendingQSOs() ([]qso.QSO, error) {
	if a.offlineQ == nil {
		return nil, nil
	}
	return a.offlineQ.Pending()
}

// RetryOfflineSync forces a replay attempt now (the "Retry" button) instead of
// waiting for the next tick.
func (a *App) RetryOfflineSync() (int, error) {
	return a.replayOfflineQueue()
}

// offlineReplayLoop re-probes the database while QSOs are waiting and replays
// them the moment it answers.
func (a *App) offlineReplayLoop() {
	t := time.NewTicker(offlineReplayEvery)
	defer t.Stop()
	for range t.C {
		if a.offlineQ == nil || a.offlineQ.Count() == 0 {
			continue
		}
		if a.logDb == nil {
			continue
		}
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		err := a.logDb.PingContext(ctx)
		cancel()
		if err != nil {
			continue // still down — keep waiting, the QSOs are safe on disk
		}
		if n, rerr := a.replayOfflineQueue(); rerr != nil {
			applog.Printf("offline: replay failed: %v", rerr)
		} else if n > 0 {
			applog.Printf("offline: replayed %d QSO(s) into the logbook", n)
		}
	}
}

// replayOfflineQueue imports the outbox into the logbook. It is IDEMPOTENT: each
// parked QSO carries a queue id, and one already present in the log is skipped —
// so a crash between "inserted" and "removed from the file" can never duplicate a
// contact. The file is archived BEFORE being cleared.
func (a *App) replayOfflineQueue() (int, error) {
	if a.offlineQ == nil || a.qso == nil {
		return 0, nil
	}
	pending, err := a.offlineQ.Pending()
	if err != nil {
		return 0, err
	}
	if len(pending) == 0 {
		a.offlineMode = false
		a.emitOfflineStatus()
		return 0, nil
	}

	imported := 0
	var failed []qso.QSO
	for _, q := range pending {
		qid := ""
		if q.Extras != nil {
			qid = q.Extras[qso.OfflineQueueKey]
		}
		// Idempotency guard: already replayed on an earlier (interrupted) pass?
		if qid != "" {
			if exists, e := a.qso.ExistsByQueueID(a.ctx, qid); e == nil && exists {
				imported++ // it IS in the log — treat as done, drop from the outbox
				continue
			} else if e != nil && db.IsConnLost(e) {
				failed = append(failed, q) // DB went away again mid-replay
				continue
			}
		}
		if _, e := a.qso.Add(a.ctx, q); e != nil {
			applog.Printf("offline: replay of %s failed: %v", q.Callsign, e)
			failed = append(failed, q)
			continue
		}
		// These are contacts the operator MADE — the worked indexes have to learn
		// them, or every one of them keeps its NEW badge in the cluster and decode
		// lists until the next restart. a.qso.Add is the raw insert; only AddQSO
		// does this for you, and the outbox replay does not go through it.
		a.noteWorked(q.Callsign, q.Band, q.Mode)
		imported++
	}

	// Archive the outbox as it was, then keep only what still failed.
	if imported > 0 {
		if dst, e := a.offlineQ.Archive(); e != nil {
			applog.Printf("offline: archive failed: %v", e)
		} else if dst != "" {
			applog.Printf("offline: outbox archived to %s", dst)
		}
	}
	if e := a.offlineQ.Rewrite(failed); e != nil {
		return imported, fmt.Errorf("offline: rewrite outbox: %w", e)
	}

	a.offlineMode = len(failed) > 0
	a.emitOfflineStatus()
	if imported > 0 && a.ctx != nil {
		wruntime.EventsEmit(a.ctx, "logbook:changed")
		wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("%d QSO(s) en attente ont été enregistrés", imported))
	}
	return imported, nil
}

// newOfflineQueue builds the outbox in OpsLog's data directory — deliberately NOT
// in a cloud-synced folder: byte-level file replication (Seafile/OneDrive) is the
// very thing this design avoids.
func newOfflineQueue(dataDir string) *offlineq.Queue { return offlineq.New(dataDir) }
