//go:build openbsd

package home

import (
	"fmt"
	"os"
	"os/signal"
	"path/filepath"
	"strings"
	"syscall"
	"text/template"

	"github.com/AdguardTeam/AdGuardHome/internal/aghos"
	"github.com/AdguardTeam/golibs/errors"
	"github.com/AdguardTeam/golibs/log"
	"github.com/AdguardTeam/golibs/stringutil"
	"github.com/kardianos/service"
)

// OpenBSD Service Implementation
//
// The file contains OpenBSD implementations for service.System and
// service.Service interfaces.  It uses the default approach for RunCom-based
// services systems, e.g. rc.d script.  It's written as if it was in a separate
// package and has only one internal dependency.
//
// TODO(e.burkov):  Perhaps, file a PR to github.com/kardianos/service.

// sysVersion is the version of local service.System interface implementation.
const sysVersion = "openbsd-runcom"

// chooseSystem checks the current system detected and substitutes it with local
// implementation if needed.
func chooseSystem() {
	service.ChooseSystem(openbsdSystem{})
}

// openbsdSystem is the service.System to be used on the OpenBSD.
type openbsdSystem struct{}

// String implements service.System interface for openbsdSystem.
func (openbsdSystem) String() string {
	return sysVersion
}

// Detect implements service.System interface for openbsdSystem.
func (openbsdSystem) Detect() (ok bool) {
	return true
}

// Interactive implements service.System interface for openbsdSystem.
func (openbsdSystem) Interactive() (ok bool) {
	return os.Getppid() != 1
}

// New implements service.System interface for openbsdSystem.
func (openbsdSystem) New(i service.Interface, c *service.Config) (s service.Service, err error) {
	return &openbsdRunComService{
		i:   i,
		cfg: c,
	}, nil
}

// openbsdRunComService is the RunCom-based service.Service to be used on the
// OpenBSD.
type openbsdRunComService struct {
	i   service.Interface
	cfg *service.Config
}

// Platform implements service.Service interface for *openbsdRunComService.
func (*openbsdRunComService) Platform() (p string) {
	return "openbsd"
}

// String implements service.Service interface for *openbsdRunComService.
func (s *openbsdRunComService) String() string {
	return stringutil.Coalesce(s.cfg.DisplayName, s.cfg.Name)
}

// getBool returns the value of the given name from kv, assuming the value is a
// boolean.  If the value isn't found or is not of the type, the defaultValue is
// returned.
func getBool(kv service.KeyValue, name string, defaultValue bool) (val bool) {
	var ok bool
	if val, ok = kv[name].(bool); ok {
		return val
	}

	return defaultValue
}

// getString returns the value of the given name from kv, assuming the value is
// a string.  If the value isn't found or is not of the type, the defaultValue
// is returned.
func getString(kv service.KeyValue, name, defaultValue string) (val string) {
	var ok bool
	if val, ok = kv[name].(string); ok {
		return val
	}

	return defaultValue
}

// getFuncNiladic returns the value of the given name from kv, assuming the
// value is a func().  If the value isn't found or is not of the type, the
// defaultValue is returned.
func getFuncNiladic(kv service.KeyValue, name string, defaultValue func()) (val func()) {
	var ok bool
	if val, ok = kv[name].(func()); ok {
		return val
	}

	return defaultValue
}

const (
	// optionUserService is the UserService option name.
	optionUserService = "UserService"

	// optionUserServiceDefault is the UserService option default value.
	optionUserServiceDefault = false

	// errNoUserServiceRunCom is returned when the service uses some custom
	// path to script.
	errNoUserServiceRunCom errors.Error = "user services are not supported on " + sysVersion
)

// scriptPath returns the absolute path to the script.  It's commonly used to
// send commands to the service.
func (s *openbsdRunComService) scriptPath() (cp string, err error) {
	if getBool(s.cfg.Option, optionUserService, optionUserServiceDefault) {
		return "", errNoUserServiceRunCom
	}

	const scriptPathPref = "/etc/rc.d"

	return filepath.Join(scriptPathPref, s.cfg.Name), nil
}

const (
	// optionRunComScript is the RunCom script option name.
	optionRunComScript = "RunComScript"

	// runComScript is the default RunCom script.
	runComScript = `#!/bin/sh
#
# $OpenBSD: {{ .SvcInfo }}

daemon="{{.Path}}"
daemon_flags={{ .Arguments | args }}

. /etc/rc.d/rc.subr

rc_bg=YES

rc_cmd $1
`
)

// template returns the script template to put into rc.d.
func (s *openbsdRunComService) template() (t *template.Template) {
	tf := map[string]any{
		"args": func(sl []string) string {
			return `"` + strings.Join(sl, " ") + `"`
		},
	}

	return template.Must(template.New("").Funcs(tf).Parse(getString(
		s.cfg.Option,
		optionRunComScript,
		runComScript,
	)))
}

// execPath returns the absolute path to the executable to be run as a service.
func (s *openbsdRunComService) execPath() (path string, err error) {
	if c := s.cfg; c != nil && len(c.Executable) != 0 {
		return filepath.Abs(c.Executable)
	}

	if path, err = os.Executable(); err != nil {
		return "", err
	}

	return filepath.Abs(path)
}

// annotate wraps errors.Annotate applying a common error format.
func (s *openbsdRunComService) annotate(action string, err error) (annotated error) {
	return errors.Annotate(err, "%s %s %s service: %w", action, sysVersion, s.cfg.Name)
}

// Install implements service.Service interface for *openbsdRunComService.
func (s *openbsdRunComService) Install() (err error) {
	defer func() { err = s.annotate("installing", err) }()

	if err = s.writeScript(); err != nil {
		return err
	}

	return s.configureSysStartup(true)
}

// configureSysStartup adds s into the group of packages started with system.
func (s *openbsdRunComService) configureSysStartup(enable bool) (err error) {
	cmd := "enable"
	if !enable {
		cmd = "disable"
	}

	var code int
	code, _, err = aghos.RunCommand("rcctl", cmd, s.cfg.Name)
	if err != nil {
		return err
	} else if code != 0 {
		return fmt.Errorf("rcctl finished with code %d", code)
	}

	return nil
}

// writeScript tries to write the script for the service.
func (s *openbsdRunComService) writeScript() (err error) {
	var scriptPath string
	if scriptPath, err = s.scriptPath(); err != nil {
		return err
	}

	if _, err = os.Stat(scriptPath); !errors.Is(err, os.ErrNotExist) {
		return fmt.Errorf("script already exists at %s", scriptPath)
	}

	var execPath string
	if execPath, err = s.execPath(); err != nil {
		return err
	}

	t := s.template()
	f, err := os.Create(scriptPath)
	if err != nil {
		return fmt.Errorf("creating rc.d script file: %w", err)
	}
	defer f.Close()

	err = t.Execute(f, &struct {
		*service.Config
		Path    string
		SvcInfo string
	}{
		Config:  s.cfg,
		Path:    execPath,
		SvcInfo: getString(s.cfg.Option, "SvcInfo", s.String()),
	})
	if err != nil {
		return err
	}

	return errors.Annotate(
		os.Chmod(scriptPath, 0o755),
		"changing rc.d script file permissions: %w",
	)
}

// Uninstall implements service.Service interface for *openbsdRunComService.
func (s *openbsdRunComService) Uninstall() (err error) {
	defer func() { err = s.annotate("uninstalling", err) }()

	if err = s.configureSysStartup(false); err != nil {
		return err
	}

	var scriptPath string
	if scriptPath, err = s.scriptPath(); err != nil {
		return err
	}

	if err = os.Remove(scriptPath); errors.Is(err, os.ErrNotExist) {
		return service.ErrNotInstalled
	}

	return errors.Annotate(err, "removing rc.d script: %w")
}

// optionRunWait is the name of the option associated with function which waits
// for the service to be stopped.
const optionRunWait = "RunWait"

// runWait is the default function to wait for service to be stopped.
func runWait() {
	sigChan := make(chan os.Signal, 3)
	signal.Notify(sigChan, syscall.SIGTERM, os.Interrupt)
	<-sigChan
}

// Run implements service.Service interface for *openbsdRunComService.
func (s *openbsdRunComService) Run() (err error) {
	if err = s.i.Start(s); err != nil {
		return err
	}

	getFuncNiladic(s.cfg.Option, optionRunWait, runWait)()

	return s.i.Stop(s)
}

// runCom calls the script with the specified cmd.
func (s *openbsdRunComService) runCom(cmd string) (out string, err error) {
	var scriptPath string
	if scriptPath, err = s.scriptPath(); err != nil {
		return "", err
	}

	// TODO(e.burkov):  It's possible that os.ErrNotExist is caused by
	// something different than the service script's non-existence.  Keep it
	// in mind, when replace the aghos.RunCommand.
	var outData []byte
	_, outData, err = aghos.RunCommand(scriptPath, cmd)
	if errors.Is(err, os.ErrNotExist) {
		return "", service.ErrNotInstalled
	}

	return string(outData), err
}

// Status implements service.Service interface for *openbsdRunComService.
func (s *openbsdRunComService) Status() (status service.Status, err error) {
	defer func() { err = s.annotate("getting status of", err) }()

	var out string
	if out, err = s.runCom("check"); err != nil {
		return service.StatusUnknown, err
	}

	name := s.cfg.Name
	switch out {
	case fmt.Sprintf("%s(ok)\n", name):
		return service.StatusRunning, nil
	case fmt.Sprintf("%s(failed)\n", name):
		return service.StatusStopped, nil
	default:
		return service.StatusUnknown, service.ErrNotInstalled
	}
}

// Start implements service.Service interface for *openbsdRunComService.
func (s *openbsdRunComService) Start() (err error) {
	_, err = s.runCom("start")

	return s.annotate("starting", err)
}

// Stop implements service.Service interface for *openbsdRunComService.
func (s *openbsdRunComService) Stop() (err error) {
	_, err = s.runCom("stop")

	return s.annotate("stopping", err)
}

// Restart implements service.Service interface for *openbsdRunComService.
func (s *openbsdRunComService) Restart() (err error) {
	if err = s.Stop(); err != nil {
		return err
	}

	return s.Start()
}

// Logger implements service.Service interface for *openbsdRunComService.
func (s *openbsdRunComService) Logger(errs chan<- error) (l service.Logger, err error) {
	if service.ChosenSystem().Interactive() {
		return service.ConsoleLogger, nil
	}

	return s.SystemLogger(errs)
}

// SystemLogger implements service.Service interface for *openbsdRunComService.
func (s *openbsdRunComService) SystemLogger(errs chan<- error) (l service.Logger, err error) {
	return newSysLogger(s.cfg.Name, errs)
}

// newSysLogger returns a stub service.Logger implementation.
func newSysLogger(_ string, _ chan<- error) (service.Logger, error) {
	return sysLogger{}, nil
}

// sysLogger wraps calls of the logging functions understandable for service
// interfaces.
type sysLogger struct{}

// Error implements service.Logger interface for sysLogger.
func (sysLogger) Error(v ...any) error {
	log.Error(fmt.Sprint(v...))

	return nil
}

// Warning implements service.Logger interface for sysLogger.
func (sysLogger) Warning(v ...any) error {
	log.Info("warning: %s", fmt.Sprint(v...))

	return nil
}

// Info implements service.Logger interface for sysLogger.
func (sysLogger) Info(v ...any) error {
	log.Info(fmt.Sprint(v...))

	return nil
}

// Errorf implements service.Logger interface for sysLogger.
func (sysLogger) Errorf(format string, a ...any) error {
	log.Error(format, a...)

	return nil
}

// Warningf implements service.Logger interface for sysLogger.
func (sysLogger) Warningf(format string, a ...any) error {
	log.Info("warning: %s", fmt.Sprintf(format, a...))

	return nil
}

// Infof implements service.Logger interface for sysLogger.
func (sysLogger) Infof(format string, a ...any) error {
	log.Info(format, a...)

	return nil
}