package main

// ADIF monitor: watches a configurable list of external ADIF files (fldigi's
// RTTY logbook, N1MM, VarAC…) and imports newly appended QSOs into OpsLog as if
// they had been logged here — same enrichment, dedup and automatic upload to
// external services (QRZ, Club Log…). Deliberately simpler than Log4OM's monitor:
// no per-file "upload" / "delete after load" toggles — importing + auto-upload is
// just what happens.
//
// Design notes:
//   - A newly added file starts at its CURRENT size (Offset = -1 sentinel → set to
//     size on first scan) so the QSOs already in it are NOT bulk-imported; only
//     contacts appended AFTER you add the file come in.
//   - Reads only up to the last complete <eor>, so a half-written record waits for
//     the next poll instead of importing a truncated QSO.
//   - Each record is fed through LogUDPLoggedADIF, which already does the full
//     enrichment + ±2-minute dedup (shared udpLogMu, so it can't race the UDP
//     auto-log) + automatic external-service upload.

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"os"
	"strings"
	"time"

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

	"hamlog/internal/applog"
)

const keyADIFMonitor = "adifmon.config"

// ADIFWatchFile is one monitored ADIF file.
type ADIFWatchFile struct {
	Path    string `json:"path"`
	Enabled bool   `json:"enabled"`
	// Offset is the number of bytes already consumed. -1 means "not yet
	// initialised": the first scan sets it to the file's current size so existing
	// history is skipped.
	Offset int64 `json:"offset"`
}

// ADIFMonitorConfig is the whole monitor setup: a master switch + the file list.
type ADIFMonitorConfig struct {
	Enabled bool            `json:"enabled"`
	Files   []ADIFWatchFile `json:"files"`
}

const eorTag = "<eor>"

func (a *App) loadADIFMonitorLocked() ADIFMonitorConfig {
	var cfg ADIFMonitorConfig
	if a.settings == nil {
		return cfg
	}
	s, _ := a.settings.GetGlobal(a.ctx, keyADIFMonitor)
	if strings.TrimSpace(s) != "" {
		_ = json.Unmarshal([]byte(s), &cfg)
	}
	return cfg
}

func (a *App) saveADIFMonitorLocked(cfg ADIFMonitorConfig) {
	b, _ := json.Marshal(cfg)
	a.setSettingGlobal(keyADIFMonitor, string(b))
}

// GetADIFMonitor returns the monitor configuration for the settings UI.
func (a *App) GetADIFMonitor() ADIFMonitorConfig {
	a.adifMonMu.Lock()
	defer a.adifMonMu.Unlock()
	return a.loadADIFMonitorLocked()
}

// SaveADIFMonitor persists the monitor configuration. A file the user just added
// gets Offset = -1 so its existing content is skipped (only QSOs logged AFTER it
// was added import); a file already present keeps its current read position.
func (a *App) SaveADIFMonitor(cfg ADIFMonitorConfig) error {
	a.adifMonMu.Lock()
	defer a.adifMonMu.Unlock()
	old := a.loadADIFMonitorLocked()
	oldOff := make(map[string]int64, len(old.Files))
	for _, f := range old.Files {
		oldOff[strings.TrimSpace(f.Path)] = f.Offset
	}
	for i := range cfg.Files {
		cfg.Files[i].Path = strings.TrimSpace(cfg.Files[i].Path)
		if off, ok := oldOff[cfg.Files[i].Path]; ok {
			cfg.Files[i].Offset = off // keep the read position of an existing file
		} else {
			cfg.Files[i].Offset = -1 // new file → skip its existing history
		}
	}
	a.saveADIFMonitorLocked(cfg)
	return nil
}

// PickADIFMonitorFile opens a file dialog to choose an ADIF file to monitor.
func (a *App) PickADIFMonitorFile() (string, error) {
	if a.ctx == nil {
		return "", fmt.Errorf("no app context")
	}
	return wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{
		Title: "Choose an ADIF file to monitor",
		Filters: []wruntime.FileFilter{
			{DisplayName: "ADIF (*.adi;*.adif)", Pattern: "*.adi;*.adif"},
			{DisplayName: "All files (*.*)", Pattern: "*.*"},
		},
	})
}

// adifMonitorLoop polls the enabled ADIF files every few seconds and imports any
// newly appended QSOs. Runs for the app's lifetime on its own goroutine.
func (a *App) adifMonitorLoop() {
	tick := time.NewTicker(5 * time.Second)
	defer tick.Stop()
	for range tick.C {
		if a.ctx == nil || a.qso == nil {
			continue
		}
		a.scanADIFMonitors()
	}
}

// scanADIFMonitors walks the enabled files once, importing new records and
// persisting advanced offsets.
func (a *App) scanADIFMonitors() {
	a.adifMonMu.Lock()
	cfg := a.loadADIFMonitorLocked()
	a.adifMonMu.Unlock()
	if !cfg.Enabled || len(cfg.Files) == 0 {
		return
	}

	// path → new offset, for the files we advanced this pass.
	advanced := map[string]int64{}
	for i := range cfg.Files {
		f := &cfg.Files[i]
		if !f.Enabled || strings.TrimSpace(f.Path) == "" {
			continue
		}
		fi, err := os.Stat(f.Path)
		if err != nil {
			continue // not present (yet) — try again next tick
		}
		size := fi.Size()
		if f.Offset < 0 {
			// First sight of this file → skip whatever history it already holds.
			advanced[f.Path] = size
			continue
		}
		if size < f.Offset {
			f.Offset = 0 // truncated / rotated → re-read from the start (dedup protects us)
		}
		if size == f.Offset {
			continue // nothing new
		}
		newOff, n := a.importADIFAppend(f.Path, f.Offset, size)
		if newOff != f.Offset {
			advanced[f.Path] = newOff
		}
		if n > 0 {
			applog.Printf("adif monitor: imported %d QSO(s) from %s", n, f.Path)
			if a.ctx != nil {
				wruntime.EventsEmit(a.ctx, "adifmon:imported", map[string]any{"file": f.Path, "count": n})
			}
		}
	}

	if len(advanced) == 0 {
		return
	}
	// Persist the advanced offsets WITHOUT clobbering a concurrent UI save of the
	// file list: re-read the stored config and only patch offsets for paths that
	// still exist there.
	a.adifMonMu.Lock()
	cur := a.loadADIFMonitorLocked()
	for i := range cur.Files {
		if off, ok := advanced[strings.TrimSpace(cur.Files[i].Path)]; ok {
			cur.Files[i].Offset = off
		}
	}
	a.saveADIFMonitorLocked(cur)
	a.adifMonMu.Unlock()
}

// importADIFAppend reads bytes [from,to) of an ADIF file, imports every COMPLETE
// record found (up to the last <eor>) and returns the new offset (just past that
// last <eor>) plus how many QSOs were actually imported (duplicates excluded).
func (a *App) importADIFAppend(path string, from, to int64) (int64, int) {
	fh, err := os.Open(path)
	if err != nil {
		return from, 0
	}
	defer fh.Close()
	if _, err := fh.Seek(from, io.SeekStart); err != nil {
		return from, 0
	}
	buf := make([]byte, to-from)
	nRead, err := io.ReadFull(fh, buf)
	if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
		return from, 0
	}
	buf = buf[:nRead]

	// Only consume up to the last complete <eor>; a half-written trailing record
	// waits for the next poll.
	lower := bytes.ToLower(buf)
	last := bytes.LastIndex(lower, []byte(eorTag))
	if last < 0 {
		return from, 0 // no complete record yet
	}
	end := last + len(eorTag)
	chunk := buf[:end]

	count := 0
	for _, rec := range splitADIFRecords(chunk) {
		if strings.TrimSpace(rec) == "" {
			continue
		}
		if _, err := a.LogUDPLoggedADIF(rec); err == nil {
			count++
		}
		// A duplicate (already in the log within ±2 min) returns an error and is
		// simply not counted — expected when the same QSO is also logged in OpsLog.
	}
	return from + int64(end), count
}

// splitADIFRecords cuts an ADIF byte slice into individual record texts, each
// ending at its <eor> (case-insensitive). Any leading file header (up to the
// first record's <eor>) rides along with the first record — LogUDPLoggedADIF
// parses past an <EOH> header fine, and prepends one when there is none.
func splitADIFRecords(b []byte) []string {
	lower := bytes.ToLower(b)
	var out []string
	start := 0
	for {
		rel := bytes.Index(lower[start:], []byte(eorTag))
		if rel < 0 {
			break
		}
		end := start + rel + len(eorTag)
		out = append(out, string(b[start:end]))
		start = end
	}
	return out
}
