package main

// Folder synchronisation — one operator, several PCs, one logbook.
//
// The operator points every OpsLog at the SAME folder (Seafile, OneDrive,
// Dropbox, a NAS share). Each machine appends what it logs, edits and deletes
// to its own file in there, and reads the others'. internal/syncfolder holds
// the format and the merge rules, and its package doc explains why the change
// log is a set of append-only files rather than the database itself.
//
// This file is the wiring: settings, the loop, and the three hooks on the
// logging path.
//
// WHAT SYNCHRONISES. Only what happens from the moment it is switched on.
// There is deliberately no mass backfill of the log already on disk: the two
// PCs of an operator who has been logging for years hold the same history
// already (one was seeded from the other, or from the same ADIF), and pushing
// 123 000 contacts through a synced folder to tell the other machine what it
// already knows would cost hours and gain nothing. A contact is stamped with an
// identity when it is touched — logged, edited, deleted — and that is what the
// other machines are told about.
//
// WHY IT STILL RECOGNISES OLD CONTACTS. Because an edit to a 2019 QSO does
// travel, and the receiving machine has that QSO under a different row id and
// no identity. It matches on the contact itself (callsign, minute, band, mode)
// before inserting, so an edit lands on the row already there instead of
// creating a second copy. That is IDByDedupeKey, and it is the whole reason the
// no-backfill decision is safe.
//
// NOT LIVE, AND NOT MEANT TO BE. Two operators working a contest together want
// the shared MySQL logbook, which OpsLog already does. This is for one operator
// whose contacts are spread across a shack PC, a laptop and a portable rig.

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"

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

	"hamlog/internal/applog"
	"hamlog/internal/qso"
	"hamlog/internal/syncfolder"
)

// Settings keys. All PROFILE-SCOPED, and that is load-bearing: each profile can
// point at its own logbook, so each needs its own folder, its own machine id
// (hence its own file — two profiles sharing a folder would otherwise write
// two logbooks into one) and its own read positions.
const (
	keySyncFolder        = "syncfolder.config"
	keySyncFolderMachine = "syncfolder.machine" // this installation's id, minted once
	keySyncFolderOffsets = "syncfolder.offsets" // peer machine id → bytes already read
	keySyncFolderSeq     = "syncfolder.seq"     // this machine's own counter
)

// syncPollInterval is how often the folder is examined. A synced folder is not
// instant anyway — Seafile and OneDrive take seconds to notice a change and
// seconds more to push it — so polling faster would only burn a directory
// listing to learn nothing.
const syncPollInterval = 20 * time.Second

// FolderSyncConfig is what the operator sets.
type FolderSyncConfig struct {
	Enabled bool   `json:"enabled"`
	Folder  string `json:"folder"`
	// Machine is the operator's own name for this PC — "shack", "portable".
	// It only labels the file and the status; the identity that matters is the
	// id minted from it, which carries a random suffix so two PCs both called
	// "shack" still never write to one file.
	Machine string `json:"machine"`
}

// FolderSyncPeer is another machine seen in the folder.
type FolderSyncPeer struct {
	Machine string `json:"machine"`
	// LastChange is the file's modification time — "when did that PC last log
	// anything", which is the question an operator actually asks of this list.
	LastChange string `json:"last_change"`
	Behind     int64  `json:"behind"` // bytes written but not yet read here
}

// FolderSyncStatus is what the settings panel shows.
type FolderSyncStatus struct {
	Enabled   bool             `json:"enabled"`
	Folder    string           `json:"folder"`
	MachineID string           `json:"machine_id"`
	Peers     []FolderSyncPeer `json:"peers"`
	LastSync  string           `json:"last_sync"`
	Sent      int64            `json:"sent"`
	Received  int64            `json:"received"`
	Error     string           `json:"error"`
}

func (a *App) loadFolderSync() FolderSyncConfig {
	var cfg FolderSyncConfig
	if a.settings == nil || !a.settingsScoped.Load() {
		return cfg
	}
	s, _ := a.settings.Get(a.ctx, keySyncFolder)
	if strings.TrimSpace(s) != "" {
		_ = json.Unmarshal([]byte(s), &cfg)
	}
	return cfg
}

// GetFolderSync returns the configuration for the settings panel.
func (a *App) GetFolderSync() FolderSyncConfig {
	a.syncMu.Lock()
	defer a.syncMu.Unlock()
	return a.loadFolderSync()
}

// SaveFolderSync persists the configuration.
//
// The folder is checked by WRITING to it, not by asking whether it exists: a
// cloud folder that is read-only, or a NAS share whose credentials have
// expired, exists perfectly well and would swallow every contact in silence.
// Better to refuse in the settings panel, where the operator is looking.
func (a *App) SaveFolderSync(cfg FolderSyncConfig) error {
	a.syncMu.Lock()
	defer a.syncMu.Unlock()
	cfg.Folder = strings.TrimSpace(cfg.Folder)
	cfg.Machine = strings.TrimSpace(cfg.Machine)
	if cfg.Enabled {
		if cfg.Folder == "" {
			return fmt.Errorf("choose the synchronised folder first")
		}
		if err := checkWritableDir(cfg.Folder); err != nil {
			return err
		}
		if cfg.Machine == "" {
			cfg.Machine = "PC"
		}
	}
	// The id is minted from the name ONCE and then kept, even if the operator
	// renames the PC afterwards. Re-minting would orphan the file already in
	// the folder: the other machines would go on reading the old one for ever
	// and never see another contact from here.
	if cfg.Enabled && a.settings != nil {
		if cur, _ := a.settings.Get(a.ctx, keySyncFolderMachine); strings.TrimSpace(cur) == "" {
			a.setSetting(keySyncFolderMachine, syncfolder.NewMachineID(cfg.Machine))
		}
	}
	b, _ := json.Marshal(cfg)
	a.setSetting(keySyncFolder, string(b))
	applog.Printf("foldersync: enabled=%v folder=%q machine=%q", cfg.Enabled, cfg.Folder, cfg.Machine)
	return nil
}

// checkWritableDir proves the folder can be written to, and cleans up after
// itself.
func checkWritableDir(dir string) error {
	info, err := os.Stat(dir)
	if err != nil {
		return fmt.Errorf("cannot reach %s: %w", dir, err)
	}
	if !info.IsDir() {
		return fmt.Errorf("%s is not a folder", dir)
	}
	probe := filepath.Join(dir, ".opslog-write-test")
	if err := os.WriteFile(probe, []byte("opslog"), 0o644); err != nil {
		return fmt.Errorf("cannot write to %s: %w", dir, err)
	}
	_ = os.Remove(probe)
	return nil
}

// PickFolderSyncFolder opens the folder chooser.
func (a *App) PickFolderSyncFolder() (string, error) {
	if a.ctx == nil {
		return "", fmt.Errorf("no app context")
	}
	return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
		Title: "Choose the folder your PCs already synchronise",
	})
}

// syncStore returns this machine's view of the folder, or nil when folder
// synchronisation is off or not configured. Every caller treats nil as "not
// our business" — the hooks on the logging path especially, where this must
// cost nothing at all for the operators who never turn it on.
func (a *App) syncStore() (*syncfolder.Store, FolderSyncConfig) {
	cfg := a.loadFolderSync()
	if !cfg.Enabled || cfg.Folder == "" || a.settings == nil {
		return nil, cfg
	}
	id, _ := a.settings.Get(a.ctx, keySyncFolderMachine)
	if strings.TrimSpace(id) == "" {
		return nil, cfg
	}
	return syncfolder.New(cfg.Folder, id), cfg
}

// nextSyncSeq hands out this machine's next counter value.
//
// Persisted on every use rather than at shutdown: the counter breaks ties
// between two changes made in the same second, and one that restarted at zero
// after a crash would make an older change beat a newer one for ever.
func (a *App) nextSyncSeq() uint64 {
	n := uint64(0)
	if a.settings != nil {
		s, _ := a.settings.Get(a.ctx, keySyncFolderSeq)
		fmt.Sscanf(strings.TrimSpace(s), "%d", &n)
	}
	n++
	a.setSetting(keySyncFolderSeq, fmt.Sprintf("%d", n))
	return n
}

// syncUIDFor returns a contact's identity, minting and stamping one if it has
// none. This is where an old QSO joins the sync: not in bulk, but the first
// time it is touched.
func (a *App) syncUIDFor(id int64, known string) string {
	if strings.TrimSpace(known) != "" {
		return known
	}
	if a.qso == nil || id <= 0 {
		return ""
	}
	if q, err := a.qso.GetByID(a.ctx, id); err == nil && strings.TrimSpace(q.SyncUID) != "" {
		return q.SyncUID
	}
	uid := syncfolder.NewUID()
	if err := a.qso.SetSyncUID(a.ctx, id, uid); err != nil {
		applog.Printf("foldersync: stamping QSO %d failed: %v", id, err)
		return ""
	}
	return uid
}

// syncPublish records one local change for the other machines.
//
// Never on the critical path of logging: a folder on a network share can block
// for seconds, and a contact must be in the database and on screen long before
// anyone cares that another PC knows about it. Callers run it in a goroutine.
func (a *App) syncPublish(op syncfolder.Op, id int64, q *qso.QSO) {
	a.syncMu.Lock()
	defer a.syncMu.Unlock()
	store, _ := a.syncStore()
	if store == nil {
		return
	}
	known := ""
	if q != nil {
		known = q.SyncUID
	}
	uid := a.syncUIDFor(id, known)
	if uid == "" {
		return
	}
	rec := syncfolder.Record{Op: op, UID: uid, Seq: a.nextSyncSeq()}
	// A deletion carries no contact — the tombstone is the whole message, and
	// the receiving machine finds the row by the identity.
	if op != syncfolder.OpDelete {
		full := q
		if full == nil || full.ID != id {
			got, err := a.qso.GetByID(a.ctx, id)
			if err != nil {
				applog.Printf("foldersync: reading QSO %d back failed: %v", id, err)
				return
			}
			full = &got
		}
		// The row id is this machine's and means nothing anywhere else. Left in,
		// it would be read back as "update local row 4711" on a PC where 4711 is
		// somebody else entirely.
		cp := *full
		cp.ID = 0
		cp.SyncUID = uid
		b, err := json.Marshal(cp)
		if err != nil {
			applog.Printf("foldersync: encoding QSO %d failed: %v", id, err)
			return
		}
		rec.Data = b
	}
	if err := store.Append(rec); err != nil {
		a.syncErr = err.Error()
		applog.Printf("foldersync: append failed: %v", err)
		return
	}
	a.syncErr = ""
	a.syncSent++
}

// syncPublishAsync is what the logging path calls.
func (a *App) syncPublishAsync(op syncfolder.Op, id int64, q *qso.QSO) {
	if a.qso == nil {
		return
	}
	var cp *qso.QSO
	if q != nil {
		c := *q
		cp = &c
	}
	go a.syncPublish(op, id, cp)
}

// syncPublishDeletes records tombstones for rows about to be deleted.
//
// Called BEFORE the delete and synchronously, for the same reason
// deleteRemoteCopies is: once the rows are gone their identities are gone with
// them, and a tombstone naming nothing tells the other machines nothing.
func (a *App) syncPublishDeletes(ids []int64) {
	if a.qso == nil || len(ids) == 0 {
		return
	}
	a.syncMu.Lock()
	store, _ := a.syncStore()
	a.syncMu.Unlock()
	if store == nil {
		return
	}
	// One read for the whole set: see deleteSnapshot. Tombstones are written
	// before the rows go, because a QSO that never had a sync identity has to be
	// given one here — after the DELETE there would be nothing left to stamp.
	for _, q := range a.deleteSnapshot(ids) {
		id := q.ID
		// A contact never touched since the sync was switched on has no identity,
		// and giving it one now is what makes the deletion addressable at all.
		a.syncMu.Lock()
		uid := a.syncUIDFor(id, q.SyncUID)
		if uid != "" {
			if err := store.Append(syncfolder.Record{Op: syncfolder.OpDelete, UID: uid, Seq: a.nextSyncSeq()}); err != nil {
				applog.Printf("foldersync: tombstone for QSO %d failed: %v", id, err)
			} else {
				a.syncSent++
			}
		}
		a.syncMu.Unlock()
	}
}

func (a *App) loadSyncOffsets() map[string]int64 {
	out := map[string]int64{}
	if a.settings == nil {
		return out
	}
	s, _ := a.settings.Get(a.ctx, keySyncFolderOffsets)
	if strings.TrimSpace(s) != "" {
		_ = json.Unmarshal([]byte(s), &out)
	}
	return out
}

func (a *App) saveSyncOffsets(m map[string]int64) {
	b, _ := json.Marshal(m)
	a.setSetting(keySyncFolderOffsets, string(b))
}

// folderSyncLoop reads the other machines' files on an interval, for the life
// of the app. Cheap when switched off: one settings read.
func (a *App) folderSyncLoop() {
	tick := time.NewTicker(syncPollInterval)
	defer tick.Stop()
	for range tick.C {
		if a.ctx == nil || a.qso == nil {
			continue
		}
		if n, err := a.folderSyncPass(); err != nil {
			applog.Printf("foldersync: %v", err)
		} else if n > 0 {
			applog.Printf("foldersync: applied %d change(s) from the folder", n)
		}
	}
}

// SyncFolderNow runs one pass immediately — the "Synchronise now" button, and
// what makes a first setup verifiable without waiting for the timer.
func (a *App) SyncFolderNow() (int, error) {
	return a.folderSyncPass()
}

// folderSyncPass reads every peer's new records once and applies the winners.
func (a *App) folderSyncPass() (int, error) {
	a.syncMu.Lock()
	store, _ := a.syncStore()
	a.syncMu.Unlock()
	if store == nil || a.qso == nil {
		return 0, nil
	}
	peers, err := store.Peers()
	if err != nil {
		a.syncMu.Lock()
		a.syncErr = err.Error()
		a.syncMu.Unlock()
		return 0, err
	}
	offsets := a.loadSyncOffsets()
	var batch []syncfolder.Record
	advanced := map[string]int64{}
	for _, p := range peers {
		recs, next, err := syncfolder.ReadFrom(p.Path, offsets[p.MachineID])
		if err != nil {
			// One unreadable peer — a file mid-upload, a share that dropped —
			// must not stop the others. Its offset is left where it was, so
			// nothing is skipped when it comes back.
			applog.Printf("foldersync: reading %s: %v", p.MachineID, err)
			continue
		}
		batch = append(batch, recs...)
		advanced[p.MachineID] = next
	}
	if len(batch) == 0 {
		a.syncMu.Lock()
		a.syncLast = time.Now()
		a.syncErr = ""
		a.syncMu.Unlock()
		for id, off := range advanced {
			offsets[id] = off
		}
		a.saveSyncOffsets(offsets)
		return 0, nil
	}
	applied := 0
	for _, rec := range syncfolder.Merge(batch) {
		if a.applySyncRecord(rec) {
			applied++
		}
	}
	// Offsets advance only after the batch has been applied. Saved first, a
	// crash in between would lose those changes permanently — the records would
	// never be read again.
	for id, off := range advanced {
		offsets[id] = off
	}
	a.saveSyncOffsets(offsets)

	a.syncMu.Lock()
	a.syncLast = time.Now()
	a.syncReceived += int64(applied)
	a.syncErr = ""
	a.syncMu.Unlock()

	if applied > 0 {
		a.invalidateAwardStats()
		a.clusterStatusMu.Lock()
		a.clusterStatusIdx = nil
		a.clusterStatusMu.Unlock()
		if a.ctx != nil {
			wruntime.EventsEmit(a.ctx, "logbook:changed")
		}
	}
	return applied, nil
}

// applySyncRecord writes one incoming change to the logbook. Reports whether
// anything actually changed.
//
// Deliberately uses the repository directly and NOT AddQSO/UpdateQSO/DeleteQSO:
// those publish to the folder, and a change applied here would be written
// straight back out — two machines echoing each other for ever.
func (a *App) applySyncRecord(rec syncfolder.Record) bool {
	id, found, err := a.qso.IDBySyncUID(a.ctx, rec.UID)
	if err != nil {
		applog.Printf("foldersync: looking up %s: %v", rec.UID, err)
		return false
	}
	if rec.Op == syncfolder.OpDelete {
		if !found {
			return false // never had it, or already deleted here
		}
		if err := a.qso.Delete(a.ctx, id); err != nil {
			applog.Printf("foldersync: deleting QSO %d: %v", id, err)
			return false
		}
		return true
	}
	var q qso.QSO
	if err := json.Unmarshal(rec.Data, &q); err != nil {
		applog.Printf("foldersync: unreadable record for %s: %v", rec.UID, err)
		return false
	}
	if strings.TrimSpace(q.Callsign) == "" {
		return false
	}
	q.SyncUID = rec.UID
	// Not under this identity — but very possibly the same contact under
	// another one, or under none: both PCs were seeded from the same ADIF long
	// before any of this existed. Recognise it rather than log it twice.
	if !found {
		if lid, _, ok, err := a.qso.IDByDedupeKey(a.ctx, q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode); err == nil && ok {
			id, found = lid, true
			_ = a.qso.SetSyncUID(a.ctx, id, rec.UID)
		}
	}
	if found {
		q.ID = id
		if err := a.qso.Update(a.ctx, q); err != nil {
			applog.Printf("foldersync: updating QSO %d: %v", id, err)
			return false
		}
		return true
	}
	q.ID = 0
	newID, err := a.qso.Add(a.ctx, q)
	if err != nil {
		applog.Printf("foldersync: inserting %s: %v", q.Callsign, err)
		return false
	}
	// sync_uid is not in the insert column list — on purpose, so no ordinary
	// write can clobber an identity — so it is stamped straight after.
	if err := a.qso.SetSyncUID(a.ctx, newID, rec.UID); err != nil {
		applog.Printf("foldersync: stamping the new QSO %d: %v", newID, err)
	}
	// A partner's contact counts as worked for this station too — that is the
	// point of a multi-op log — so the worked indexes must learn it exactly as
	// they do for our own. Raw insert, so nothing else does it.
	a.noteWorked(q.Callsign, q.Band, q.Mode)
	return true
}

// GetFolderSyncStatus reports what the operator needs to see: which other PCs
// are in the folder, when each last logged something, and whether anything is
// waiting to be read.
func (a *App) GetFolderSyncStatus() FolderSyncStatus {
	a.syncMu.Lock()
	cfg := a.loadFolderSync()
	store, _ := a.syncStore()
	st := FolderSyncStatus{
		Enabled:  cfg.Enabled,
		Folder:   cfg.Folder,
		Sent:     a.syncSent,
		Received: a.syncReceived,
		Error:    a.syncErr,
	}
	if !a.syncLast.IsZero() {
		st.LastSync = a.syncLast.UTC().Format(time.RFC3339)
	}
	if a.settings != nil {
		st.MachineID, _ = a.settings.Get(a.ctx, keySyncFolderMachine)
	}
	a.syncMu.Unlock()
	if store == nil {
		return st
	}
	peers, err := store.Peers()
	if err != nil {
		st.Error = err.Error()
		return st
	}
	offsets := a.loadSyncOffsets()
	for _, p := range peers {
		fp := FolderSyncPeer{Machine: p.MachineID}
		if behind := p.Size - offsets[p.MachineID]; behind > 0 {
			fp.Behind = behind
		}
		if info, err := os.Stat(p.Path); err == nil {
			fp.LastChange = info.ModTime().UTC().Format(time.RFC3339)
		}
		st.Peers = append(st.Peers, fp)
	}
	return st
}
