//
// This file is part of pluggable-discovery-protocol-handler.
//
// Copyright 2024 ARDUINO SA (http://www.arduino.cc/)
//
// This software is released under the GNU General Public License version 3,
// which covers the main part of arduino-cli.
// The terms of this license can be found at:
// https://www.gnu.org/licenses/gpl-3.0.en.html
//
// You can be released from the requirements of the above licenses by purchasing
// a commercial license. Buying such a license is mandatory if you want to modify or
// otherwise use the software for commercial activities involving the Arduino
// software without disclosing the source code of your own applications. To purchase
// a commercial license, send an email to license@arduino.cc.
//

package discovery

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"strings"
	"sync"
	"time"

	"github.com/arduino/go-paths-helper"
)

// Client is a tool that detects communication ports to interact
// with the boards.
type Client struct {
	id                   string
	processArgs          []string
	process              *paths.Process
	outgoingCommandsPipe io.Writer
	incomingMessagesChan <-chan *discoveryMessage
	userAgent            string
	logger               ClientLogger

	// All the following fields are guarded by statusMutex
	statusMutex           sync.Mutex
	incomingMessagesError error
	eventChan             chan<- *Event
}

// ClientLogger is the interface that must be implemented by a logger
// to be used in the discovery client.
type ClientLogger interface {
	Debugf(format string, args ...interface{})
	Errorf(format string, args ...interface{})
}

type nullClientLogger struct{}

func (l *nullClientLogger) Debugf(format string, args ...interface{}) {}
func (l *nullClientLogger) Errorf(format string, args ...interface{}) {}

type discoveryMessage struct {
	EventType       string  `json:"eventType"`
	Message         string  `json:"message"`
	Error           bool    `json:"error"`
	ProtocolVersion int     `json:"protocolVersion"` // Used in HELLO command
	Ports           []*Port `json:"ports"`           // Used in LIST command
	Port            *Port   `json:"port"`            // Used in add and remove events
}

func (msg discoveryMessage) String() string {
	s := fmt.Sprintf("type: %s", msg.EventType)
	if msg.Message != "" {
		s += fmt.Sprintf(", message: %s", msg.Message)
	}
	if msg.ProtocolVersion != 0 {
		s += fmt.Sprintf(", protocol version: %d", msg.ProtocolVersion)
	}
	if len(msg.Ports) > 0 {
		s += fmt.Sprintf(", ports: %s", msg.Ports)
	}
	if msg.Port != nil {
		s += fmt.Sprintf(", port: %s", msg.Port)
	}
	return s
}

// Event is a pluggable discovery event
type Event struct {
	Type        string
	Port        *Port
	DiscoveryID string
}

// NewClient create a new pluggable discovery client
func NewClient(id string, args ...string) *Client {
	return &Client{
		id:          id,
		processArgs: args,
		userAgent:   "pluggable-discovery-protocol-handler",
		logger:      &nullClientLogger{},
	}
}

// SetUserAgent sets the user agent to be used in the discovery
func (disc *Client) SetUserAgent(userAgent string) {
	disc.userAgent = userAgent
}

// SetLogger sets the logger to be used in the discovery
func (disc *Client) SetLogger(logger ClientLogger) {
	disc.logger = logger
}

// GetID returns the identifier for this discovery
func (disc *Client) GetID() string {
	return disc.id
}

func (disc *Client) String() string {
	return disc.id
}

func (disc *Client) jsonDecodeLoop(in io.Reader, outChan chan<- *discoveryMessage) {
	decoder := json.NewDecoder(in)
	closeAndReportError := func(err error) {
		disc.statusMutex.Lock()
		disc.incomingMessagesError = err
		disc.stopSync()
		disc.killProcess()
		disc.statusMutex.Unlock()
		close(outChan)
		if err != nil {
			disc.logger.Errorf("Stopped decode loop: %v", err)
		} else {
			disc.logger.Debugf("Stopped decode loop")
		}
	}

	for {
		var msg discoveryMessage
		if err := decoder.Decode(&msg); err != nil {
			closeAndReportError(err)
			return
		}
		disc.logger.Debugf("Received message %s", msg)
		if msg.EventType == "add" {
			if msg.Port == nil {
				closeAndReportError(errors.New("invalid 'add' message: missing port"))
				return
			}
			disc.statusMutex.Lock()
			if disc.eventChan != nil {
				disc.eventChan <- &Event{"add", msg.Port, disc.GetID()}
			}
			disc.statusMutex.Unlock()
		} else if msg.EventType == "remove" {
			if msg.Port == nil {
				closeAndReportError(errors.New("invalid 'remove' message: missing port"))
				return
			}
			disc.statusMutex.Lock()
			if disc.eventChan != nil {
				disc.eventChan <- &Event{"remove", msg.Port, disc.GetID()}
			}
			disc.statusMutex.Unlock()
		} else {
			outChan <- &msg
		}
	}
}

// Alive returns true if the discovery is running and false otherwise.
func (disc *Client) Alive() bool {
	disc.statusMutex.Lock()
	defer disc.statusMutex.Unlock()
	return disc.process != nil
}

func (disc *Client) waitMessage(timeout time.Duration) (*discoveryMessage, error) {
	select {
	case msg := <-disc.incomingMessagesChan:
		if msg == nil {
			disc.statusMutex.Lock()
			err := disc.incomingMessagesError
			disc.statusMutex.Unlock()
			return nil, err
		}
		return msg, nil
	case <-time.After(timeout):
		return nil, fmt.Errorf("timeout waiting for message from %s", disc)
	}
}

func (disc *Client) sendCommand(command string) error {
	disc.logger.Debugf("Sending command %s", strings.TrimSpace(command))
	data := []byte(command)
	for {
		n, err := disc.outgoingCommandsPipe.Write(data)
		if err != nil {
			return err
		}
		if n == len(data) {
			return nil
		}
		data = data[n:]
	}
}

func (disc *Client) runProcess() error {
	disc.logger.Debugf("Starting discovery process")
	proc, err := paths.NewProcess(nil, disc.processArgs...)
	if err != nil {
		return err
	}
	stdout, err := proc.StdoutPipe()
	if err != nil {
		return err
	}
	stdin, err := proc.StdinPipe()
	if err != nil {
		return err
	}
	disc.outgoingCommandsPipe = stdin

	messageChan := make(chan *discoveryMessage)
	disc.incomingMessagesChan = messageChan
	go disc.jsonDecodeLoop(stdout, messageChan)

	if err := proc.Start(); err != nil {
		return err
	}

	disc.statusMutex.Lock()
	defer disc.statusMutex.Unlock()
	disc.process = proc
	disc.logger.Debugf("Discovery process started")
	return nil
}

func (disc *Client) killProcess() {
	disc.logger.Debugf("Killing discovery process")
	if process := disc.process; process != nil {
		disc.process = nil
		if err := process.Kill(); err != nil {
			disc.logger.Errorf("Killing discovery process: %v", err)
		}
		if err := process.Wait(); err != nil {
			disc.logger.Errorf("Waiting discovery process termination: %v", err)
		}
	}
	disc.logger.Debugf("Discovery process killed")
}

// Run starts the discovery executable process and sends the HELLO command to the discovery to agree on the
// pluggable discovery protocol. This must be the first command to run in the communication with the discovery.
// If the process is started but the HELLO command fails the process is killed.
func (disc *Client) Run() (err error) {
	if err = disc.runProcess(); err != nil {
		return err
	}

	defer func() {
		// If the discovery process is started successfully but the HELLO handshake
		// fails the discovery is an unusable state, we kill the process to avoid
		// further issues down the line.
		if err == nil {
			return
		}
		disc.statusMutex.Lock()
		disc.killProcess()
		disc.statusMutex.Unlock()
	}()

	if err = disc.sendCommand("HELLO 1 \"arduino-cli " + disc.userAgent + "\"\n"); err != nil {
		return err
	}
	if msg, err := disc.waitMessage(time.Second * 10); err != nil {
		return fmt.Errorf("calling HELLO: %w", err)
	} else if msg.EventType != "hello" {
		return fmt.Errorf("event out of sync, expected 'hello', received '%s'", msg.EventType)
	} else if msg.Error {
		return fmt.Errorf("command failed: %s", msg.Message)
	} else if strings.ToUpper(msg.Message) != "OK" {
		return fmt.Errorf("communication out of sync, expected 'OK', received '%s'", msg.Message)
	} else if msg.ProtocolVersion > 1 {
		return fmt.Errorf("protocol version not supported: requested 1, got %d", msg.ProtocolVersion)
	}
	return nil
}

// Start initializes and start the discovery internal subroutines. This command must be
// called before List.
func (disc *Client) Start() error {
	if err := disc.sendCommand("START\n"); err != nil {
		return err
	}
	if msg, err := disc.waitMessage(time.Second * 10); err != nil {
		return fmt.Errorf("calling START: %w", err)
	} else if msg.EventType != "start" {
		return fmt.Errorf("event out of sync, expected 'start', received '%s'", msg.EventType)
	} else if msg.Error {
		return fmt.Errorf("command failed: %s", msg.Message)
	} else if strings.ToUpper(msg.Message) != "OK" {
		return fmt.Errorf("communication out of sync, expected 'OK', received '%s'", msg.Message)
	}
	return nil
}

// Stop stops the discovery internal subroutines and possibly free the internally
// used resources. This command should be called if the client wants to pause the
// discovery for a while.
func (disc *Client) Stop() error {
	if err := disc.sendCommand("STOP\n"); err != nil {
		return err
	}
	if msg, err := disc.waitMessage(time.Second * 10); err != nil {
		return fmt.Errorf("calling STOP: %w", err)
	} else if msg.EventType != "stop" {
		return fmt.Errorf("event out of sync, expected 'stop', received '%s'", msg.EventType)
	} else if msg.Error {
		return fmt.Errorf("command failed: %s", msg.Message)
	} else if strings.ToUpper(msg.Message) != "OK" {
		return fmt.Errorf("communication out of sync, expected 'OK', received '%s'", msg.Message)
	}
	disc.statusMutex.Lock()
	defer disc.statusMutex.Unlock()
	disc.stopSync()
	return nil
}

func (disc *Client) stopSync() {
	if disc.eventChan != nil {
		disc.eventChan <- &Event{"stop", nil, disc.GetID()}
		close(disc.eventChan)
		disc.eventChan = nil
	}
}

// Quit terminates the discovery. No more commands can be accepted by the discovery.
func (disc *Client) Quit() {
	_ = disc.sendCommand("QUIT\n")
	if _, err := disc.waitMessage(time.Second * 5); err != nil {
		disc.logger.Errorf("Quitting discovery: %s", err)
	}
	disc.statusMutex.Lock()
	disc.stopSync()
	disc.killProcess()
	disc.statusMutex.Unlock()
}

// List executes an enumeration of the ports and returns a list of the available
// ports at the moment of the call.
func (disc *Client) List() ([]*Port, error) {
	if err := disc.sendCommand("LIST\n"); err != nil {
		return nil, err
	}
	if msg, err := disc.waitMessage(time.Second * 10); err != nil {
		return nil, fmt.Errorf("calling LIST: %w", err)
	} else if msg.EventType != "list" {
		return nil, fmt.Errorf("event out of sync, expected 'list', received '%s'", msg.EventType)
	} else if msg.Error {
		return nil, fmt.Errorf("command failed: %s", msg.Message)
	} else {
		return msg.Ports, nil
	}
}

// StartSync puts the discovery in "events" mode: the discovery will send "add"
// and "remove" events each time a new port is detected or removed respectively.
// After calling StartSync an initial burst of "add" events may be generated to
// report all the ports available at the moment of the start.
// It also creates a channel used to receive events from the pluggable discovery.
// The event channel must be consumed as quickly as possible since it may block the
// discovery if it becomes full. The channel size is configurable.
func (disc *Client) StartSync(size int) (<-chan *Event, error) {
	if err := disc.sendCommand("START_SYNC\n"); err != nil {
		return nil, err
	}

	if msg, err := disc.waitMessage(time.Second * 10); err != nil {
		return nil, fmt.Errorf("calling START_SYNC: %w", err)
	} else if msg.EventType != "start_sync" {
		return nil, fmt.Errorf("evemt out of sync, expected 'start_sync', received '%s'", msg.EventType)
	} else if msg.Error {
		return nil, fmt.Errorf("command failed: %s", msg.Message)
	} else if strings.ToUpper(msg.Message) != "OK" {
		return nil, fmt.Errorf("communication out of sync, expected 'OK', received '%s'", msg.Message)
	}

	// In case there is already an existing event channel in use we close it before creating a new one.
	disc.statusMutex.Lock()
	defer disc.statusMutex.Unlock()
	disc.stopSync()
	c := make(chan *Event, size)
	disc.eventChan = c
	return c, nil
}
