// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved.
// Use of this source code is governed by the MIT-license that can be
// found in the LICENSE file.

// Package app implements the core functionality shared by cli and api.
package app

import (
	"context"
	"crypto/sha1"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"github.com/harenber/ptc-go/v2/pactor"
	"github.com/la5nta/pat/api/types"
	"github.com/la5nta/pat/cfg"
	"github.com/la5nta/pat/internal/buildinfo"
	"github.com/la5nta/pat/internal/debug"
	"github.com/la5nta/pat/internal/directories"
	"github.com/la5nta/pat/internal/forms"
	"github.com/la5nta/pat/internal/propagation"
	"github.com/la5nta/wl2k-go/fbb"
	"github.com/la5nta/wl2k-go/mailbox"
	"github.com/la5nta/wl2k-go/rigcontrol/hamlib"
	"github.com/la5nta/wl2k-go/transport"
	"github.com/la5nta/wl2k-go/transport/ardop"
	"github.com/la5nta/wl2k-go/transport/ax25"
	"github.com/la5nta/wl2k-go/transport/ax25/agwpe"
	"github.com/n8jja/Pat-Vara/vara"
)

const (
	MethodArdop  = "ardop"
	MethodTelnet = "telnet"
	MethodPactor = "pactor"
	MethodVaraHF = "varahf"
	MethodVaraFM = "varafm"

	MethodAX25          = "ax25"
	MethodAX25AGWPE     = MethodAX25 + "+agwpe"
	MethodAX25Linux     = MethodAX25 + "+linux"
	MethodAX25SerialTNC = MethodAX25 + "+serial-tnc"

	// TODO: Remove after some release cycles (2023-05-21)
	MethodSerialTNCDeprecated = "serial-tnc"
)

type Options struct {
	IgnoreBusy bool // Move to connect?
	SendOnly   bool // Move to connect?
	RadioOnly  bool

	Robust       bool
	MyCall       string
	Listen       string
	MailboxPath  string
	ConfigPath   string
	PrehooksPath string
	LogPath      string
	EventLogPath string
	FormsPath    string
}

type App struct {
	options  Options
	config   cfg.Config
	OnReload func() error

	mbox     *mailbox.DirHandler
	formsMgr *forms.Manager

	exchangeChan   chan ex        // The channel that the exchange loop is listening on
	exchangeConn   net.Conn       // Pointer to the active session connection (exchange)
	dialing        *transport.URL // The connect URL currently being dialed (if any)
	dialCancelFunc func()         // Context cancellation function for aborting while dialing.
	listenHub      *ListenerHub

	promptHub    *PromptHub
	websocketHub WSHub

	// Persistent modem connections
	ardop  *ardop.TNC
	agwpe  *agwpe.TNCPort
	pactor *pactor.Modem
	varaHF *vara.Modem
	varaFM *vara.Modem

	rigs map[string]rig

	predictor propagation.Predictor

	eventLog   *EventLogger
	termWriter io.WriteCloser // termWriter writes to both stdout and the log file (web gui echoes log file)
}

// A rig holds a VFO and a closer for the underlying rig connection.
type rig struct {
	hamlib.VFO
	io.Closer
}

func New(opts Options) *App {
	opts.MailboxPath = filepath.Clean(opts.MailboxPath)
	opts.FormsPath = filepath.Clean(opts.FormsPath)
	opts.ConfigPath = filepath.Clean(opts.ConfigPath)
	opts.LogPath = filepath.Clean(opts.LogPath)
	opts.EventLogPath = filepath.Clean(opts.EventLogPath)

	return &App{options: opts, websocketHub: noopWSSocket{}}
}

func (a *App) Mailbox() *mailbox.DirHandler { return a.mbox }

func (a *App) FormsManager() *forms.Manager { return a.formsMgr }

func (a *App) Config() cfg.Config { return a.config }

func (a *App) Options() Options { return a.options }

func (a *App) PromptHub() *PromptHub { return a.promptHub }

func (a *App) Reload() error { return a.OnReload() }

func (a *App) VFOForRig(rig string) (hamlib.VFO, bool) { r, ok := a.rigs[rig]; return r, ok }

// Locator implements forms.LocatorProvider
func (a *App) Locator() string { return a.config.Locator }

func (a *App) VFOForTransport(transport string) (vfo hamlib.VFO, rigName string, ok bool, err error) {
	var rig string
	switch {
	case transport == MethodArdop:
		rig = a.config.Ardop.Rig
	case transport == MethodAX25, strings.HasPrefix(transport, MethodAX25+"+"):
		rig = a.config.AX25.Rig
	case transport == MethodPactor:
		rig = a.config.Pactor.Rig
	case transport == MethodVaraHF:
		rig = a.config.VaraHF.Rig
	case transport == MethodVaraFM:
		rig = a.config.VaraFM.Rig
	default:
		return vfo, "", false, fmt.Errorf("not supported with transport '%s'", transport)
	}
	if rig == "" {
		return vfo, "", false, fmt.Errorf("missing rig reference in config section for %s", transport)
	}
	vfo, ok = a.VFOForRig(rig)
	return vfo, rig, ok, nil
}

func (a *App) EnableWebSocket(ctx context.Context, wsHub WSHub) error {
	a.websocketHub = wsHub
	a.promptHub.AddPrompter(wsHub)
	return nil
}

func (a *App) Run(ctx context.Context, cmd Command, args []string) {
	debug.Printf("Version: %s", buildinfo.VersionString())
	debug.Printf("Command: %s %v", cmd.Str, args)
	debug.Printf("Mailbox dir is\t'%s'", a.options.MailboxPath)
	debug.Printf("Forms dir is\t'%s'", a.options.FormsPath)
	debug.Printf("Config file is\t'%s'", a.options.ConfigPath)
	debug.Printf("Log file is \t'%s'", a.options.LogPath)
	debug.Printf("Event log file is\t'%s'", a.options.EventLogPath)
	directories.MigrateLegacyDataDir()

	a.listenHub = NewListenerHub(a)
	a.listenHub.websocketHub = a.websocketHub
	a.promptHub = NewPromptHub()

	// Skip initialization for some commands
	switch cmd.Str {
	case "configure", "version":
		cmd.HandleFunc(ctx, a, args)
		return
	}

	// Enable the GZIP extension experiment by default
	if _, ok := os.LookupEnv("GZIP_EXPERIMENT"); !ok {
		os.Setenv("GZIP_EXPERIMENT", "1")
	}

	os.Setenv("PATH", fmt.Sprintf(`%s%c%s`, a.options.PrehooksPath, os.PathListSeparator, os.Getenv("PATH")))

	// Parse configuration file
	var err error
	a.config, err = LoadConfig(a.options.ConfigPath, cfg.DefaultConfig)
	if err != nil {
		log.Fatalf("Unable to load/write config: %s", err)
	}

	// Initialize logger
	f, err := os.Create(a.options.LogPath)
	if err != nil {
		log.Fatalf("Unable to create log file at %s: %v", a.options.LogPath, err)
	}
	a.termWriter = struct {
		io.Writer
		io.Closer
	}{io.MultiWriter(f, os.Stdout), f}
	log.SetOutput(io.MultiWriter(f, os.Stderr)) // web gui echoes the log file

	a.eventLog, err = NewEventLogger(a.options.EventLogPath)
	if err != nil {
		log.Fatal("Unable to open event log file:", err)
	}

	// Read command line options from config if unset
	if a.options.MyCall == "" && a.config.MyCall == "" {
		fmt.Fprint(os.Stderr, "Missing mycall\n")
		if cmd.Str != "http" {
			os.Exit(1)
		}
	} else if a.options.MyCall == "" {
		a.options.MyCall = a.config.MyCall
	}

	// Ensure mycall is all upper case.
	a.options.MyCall = strings.ToUpper(a.options.MyCall)

	// Don't use config password if we don't use config mycall
	if !strings.EqualFold(a.options.MyCall, a.config.MyCall) {
		a.config.SecureLoginPassword = ""
	}

	if a.options.Listen == "" && len(a.config.Listen) > 0 {
		a.options.Listen = strings.Join(a.config.Listen, ",")
	}

	// Initialize HF prediction engine (VOACAP)
	switch p := a.config.Prediction; p.Engine {
	case cfg.PredictionEngineVOACAP, cfg.PredictionEngineAuto:
		voacap, err := propagation.NewVOACAPPredictor(p.VOACAP.Executable, p.VOACAP.DataDir)
		if err != nil {
			// Only log error if engine is set explicitly
			if p.Engine == cfg.PredictionEngineVOACAP {
				log.Println("Failed to initialize VOACAP:", err)
			} else {
				debug.Printf("Failed to initialize VOACAP: %v", err)
			}
			break
		}
		debug.Printf("Prediction engine: %s (%q)", p.Engine, voacap.Version())
		a.predictor = propagation.WithCaching(voacap)
	case cfg.PredictionEngineVOACAPAPI:
		voacap := propagation.NewVOACAPAPIPredictor(p.VOACAPAPI.BaseURL)
		debug.Printf("Prediction engine: %s (%q)", p.Engine, voacap.Version())
		a.predictor = propagation.WithCaching(voacap)
	}

	// init forms subsystem
	a.formsMgr = forms.NewManager(forms.Config{
		FormsPath:       a.options.FormsPath,
		SequencePath:    filepath.Join(directories.StateDir(), "template-sequence-number.json"),
		SequenceFormat:  "%03d",
		MyCall:          a.options.MyCall,
		AppVersion:      buildinfo.AppName + " " + buildinfo.VersionStringShort(),
		UserAgent:       buildinfo.UserAgent(),
		GPSd:            a.config.GPSd,
		LocatorProvider: a,
	})

	// Load the mailbox handler
	a.mbox = mailbox.NewDirHandler(
		filepath.Join(a.options.MailboxPath, a.options.MyCall),
		a.options.SendOnly,
	)
	// Ensure the mailbox handler is ready
	if err := a.mbox.Prepare(); err != nil {
		log.Fatal(err)
	}

	if cmd.MayConnect {
		a.loadHamlibRigs(a.config.HamlibRigs)
		a.exchangeChan = a.exchangeLoop(ctx)

		go func() {
			if a.config.VersionReportingDisabled {
				return
			}
			for {
				a.postVersionUpdate()                  // 24 hour hold on success
				a.checkPasswordRecoveryEmailIsSet(ctx) // 14 day hold on success
				select {
				case <-time.After(6 * time.Hour): // Retry every 6 hours
				case <-ctx.Done():
					return
				}
			}
		}()
	}

	if cmd.LongLived {
		if a.options.Listen != "" {
			a.Listen(a.options.Listen)
		}
		if a.config.GPSd.UpdateLocator {
			go a.gpsdLocatorUpdater(ctx)
		}
	}

	// Start command execution
	cmd.HandleFunc(ctx, a, args)
}

type Heard struct {
	Callsign string    `json:"callsign"`
	Time     time.Time `json:"time"`
}

func (a *App) ActiveListeners() []string {
	slice := []string{}
	for _, tl := range a.listenHub.Active() {
		slice = append(slice, tl.Name())
	}
	sort.Strings(slice)
	return slice
}

func (a *App) Heard() map[string][]Heard {
	heard := make(map[string][]Heard)
	if a.ardop != nil {
		for callsign, time := range a.ardop.Heard() {
			heard[MethodArdop] = append(heard[MethodArdop], Heard{
				Callsign: callsign,
				Time:     time,
			})
		}
	}

	if ax25, err := ax25.Heard(a.Config().AX25Linux.Port); err == nil {
		for callsign, time := range ax25 {
			heard[MethodAX25Linux] = append(heard[MethodAX25Linux], Heard{
				Callsign: callsign,
				Time:     time,
			})
		}
	}

	return heard
}

func (a *App) GetStatus() types.Status {
	configHash := func(c cfg.Config) string {
		h := sha1.New()
		if err := json.NewEncoder(h).Encode(c); err != nil {
			panic(err)
		}
		return fmt.Sprintf("%x", h.Sum(nil))
	}

	status := types.Status{
		ActiveListeners: a.ActiveListeners(),
		Dialing:         a.dialing != nil,
		Connected:       a.exchangeConn != nil,
		HTTPClients:     a.websocketHub.ClientAddrs(),
		ConfigHash:      configHash(a.config),
	}

	if a.exchangeConn != nil {
		addr := a.exchangeConn.RemoteAddr()
		status.RemoteAddr = fmt.Sprintf("%s:%s", addr.Network(), addr)
	}

	return status
}

func (a *App) Close() {
	debug.Printf("Starting cleanup")
	defer func() {
		debug.Printf("Cleanup done")
		if a.termWriter != nil {
			a.termWriter.Close()
		}
	}()

	debug.Printf("Closing active connection and/or listeners")
	a.AbortActiveConnection(false)
	a.listenHub.Close()

	debug.Printf("Closing modems")
	if a.ardop != nil {
		if err := a.ardop.Close(); err != nil {
			log.Printf("Failure to close ardop TNC: %s", err)
		}
	}
	if a.pactor != nil {
		if err := a.pactor.Close(); err != nil {
			log.Printf("Failure to close pactor modem: %s", err)
		}
	}
	if a.varaFM != nil {
		if err := a.varaFM.Close(); err != nil {
			log.Printf("Failure to close varafm modem: %s", err)
		}
	}
	if a.varaHF != nil {
		if err := a.varaHF.Close(); err != nil {
			log.Printf("Failure to close varahf modem: %s", err)
		}
	}
	if a.agwpe != nil {
		if err := a.agwpe.Close(); err != nil {
			log.Printf("Failure to close AGWPE TNC: %s", err)
		}
	}

	// Close rigs
	debug.Printf("Closing rigs")
	for name, r := range a.rigs {
		if err := r.Close(); err != nil {
			log.Printf("Failure to close rig %s: %s", name, err)
		}
	}

	a.promptHub.Close()
	a.websocketHub.Close()
	a.eventLog.Close()
	a.formsMgr.Close()
}

func (a *App) onServiceMessageReceived(msg *fbb.Message) {
	// Recover any panic here, as we really REALLY don't want the user to lose system messages due to panics.
	defer func() {
		if r := recover(); r != nil {
			log.Println(r)
		}
	}()

	// Write all service messages to the log
	body, _ := msg.Body()
	subject := msg.Subject()
	fmt.Fprintln(a.termWriter)
	fmt.Fprintln(a.termWriter, strings.Repeat("=", (60-len(subject))/2), subject, strings.Repeat("=", (60-len(subject))/2))
	fmt.Fprintln(a.termWriter, strings.TrimSpace(body))
	fmt.Fprintln(a.termWriter, strings.Repeat("=", 62))
	fmt.Fprintln(a.termWriter)

	// Handle account activation email
	if isActivation, password := isAccountActivationMessage(msg); isActivation && password != "" {
		fmt.Fprintln(a.termWriter, "DO NOT LOSE YOUR PASSWORD:", password)
		fmt.Fprintln(a.termWriter)
	}
}

func (a *App) loadHamlibRigs(rigsConfig map[string]cfg.HamlibConfig) {
	a.rigs = make(map[string]rig, len(rigsConfig))
	for name, conf := range rigsConfig {
		if conf.Address == "" {
			log.Printf("Missing address-field for rig '%s', skipping.", name)
			continue
		}
		if conf.Network == "" {
			conf.Network = "tcp"
		}

		r, err := hamlib.Open(conf.Network, conf.Address)
		if err != nil {
			log.Printf("Initialization hamlib rig %s failed: %s.", name, err)
			continue
		}

		var vfo hamlib.VFO
		switch strings.ToUpper(conf.VFO) {
		case "A", "VFOA":
			vfo, err = r.VFOA()
		case "B", "VFOB":
			vfo, err = r.VFOB()
		case "":
			vfo = r.CurrentVFO()
		default:
			log.Printf("Cannot load rig '%s': Unrecognized VFO identifier '%s'", name, conf.VFO)
			r.Close() // Close rig if we can't use it
			continue
		}

		if err != nil {
			log.Printf("Cannot load rig '%s': Unable to select VFO: %s", name, err)
			r.Close() // Close rig if we can't use it
			continue
		}

		f, err := vfo.GetFreq()
		if err != nil {
			log.Printf("Unable to get frequency from rig %s: %s.", name, err)
		} else {
			log.Printf("%s ready. Dial frequency is %s.", name, Frequency(f))
		}

		a.rigs[name] = rig{VFO: vfo, Closer: r}
	}
}

func (a *App) SetConnectAliases(aliases map[string]string) error {
	onDisk, err := LoadConfig(a.options.ConfigPath, cfg.DefaultConfig)
	if err != nil {
		return err
	}
	onDisk.ConnectAliases = aliases
	if err := WriteConfig(onDisk, a.options.ConfigPath); err != nil {
		return err
	}
	a.config.ConnectAliases = aliases
	return nil
}
