// Copyright (c) 2023 Joshua Rich <joshua.rich@gmail.com>
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT

package linux

import (
	"context"
	"errors"
	"os"
	"strings"
	"sync"

	"github.com/godbus/dbus/v5"
	"github.com/rs/zerolog/log"
)

//go:generate stringer -type=dbusType -output dbusTypesStringer.go -linecomment
const (
	SessionBus dbusType = iota // session
	SystemBus                  // system
)

type dbusType int

type Bus struct {
	conn           *dbus.Conn
	signals        chan *dbus.Signal
	signalMatchers map[string]func(*dbus.Signal)
	matchRequests  chan signalMatcher
	busType        dbusType
	mu             sync.RWMutex
}

func (bus *Bus) signalHandler(ctx context.Context) {
	bus.conn.Signal(bus.signals)
	defer bus.conn.RemoveSignal(bus.signals)
	for {
		select {
		case <-ctx.Done():
			return
		case signal := <-bus.signals:
			// bus.mu.RLock()
			// defer bus.mu.Unlock()
			for matchPath, handlerFunc := range bus.signalMatchers {
				if strings.Contains(string(signal.Path), matchPath) {
					handlerFunc(signal)
				}
			}
		case request := <-bus.matchRequests:
			// bus.mu.Lock()
			// defer bus.mu.Unlock()
			bus.signalMatchers[request.match] = request.handler
		}
	}
}

// NewBus sets up DBus connections and channels for receiving signals. It creates both a system and session bus connection.
func NewBus(ctx context.Context, t dbusType) *Bus {
	var conn *dbus.Conn
	var err error
	switch t {
	case SessionBus:
		conn, err = dbus.ConnectSessionBus(dbus.WithContext(ctx))
	case SystemBus:
		conn, err = dbus.ConnectSystemBus(dbus.WithContext(ctx))
	}
	if err != nil {
		log.Error().Err(err).
			Msgf("Could not connect to %s bus.", t.String())
		return nil
	} else {
		bus := &Bus{
			conn:           conn,
			signals:        make(chan *dbus.Signal),
			signalMatchers: make(map[string]func(*dbus.Signal)),
			matchRequests:  make(chan signalMatcher),
			busType:        t,
		}
		go bus.signalHandler(ctx)
		return bus
	}
}

type signalMatcher struct {
	handler func(*dbus.Signal)
	match   string
}

// busRequest contains properties for building different types of DBus requests
type busRequest struct {
	bus          *Bus
	eventHandler func(*dbus.Signal)
	path         dbus.ObjectPath
	event        string
	dest         string
	match        []dbus.MatchOption
}

func NewBusRequest(busType dbusType) *busRequest {
	b := dbusAPI.EndPoint(busType)
	return &busRequest{
		bus: b,
	}
}

// Path defines the DBus path on which a request will operate
func (r *busRequest) Path(p dbus.ObjectPath) *busRequest {
	r.path = p
	return r
}

// Match defines DBus routing match rules on which a request will operate
func (r *busRequest) Match(m []dbus.MatchOption) *busRequest {
	r.match = m
	return r
}

// Event defines an event on which a DBus request should match
func (r *busRequest) Event(e string) *busRequest {
	r.event = e
	return r
}

// Handler defines a function that will handle a matched DBus signal
func (r *busRequest) Handler(h func(*dbus.Signal)) *busRequest {
	r.eventHandler = h
	return r
}

// Destination defines the location/interface on a given DBus path for a request
// to operate
func (r *busRequest) Destination(d string) *busRequest {
	r.dest = d
	return r
}

// GetProp fetches the specified property from DBus with the options specified
// in the builder
func (r *busRequest) GetProp(prop string) (dbus.Variant, error) {
	if r.bus != nil {
		obj := r.bus.conn.Object(r.dest, r.path)
		res, err := obj.GetProperty(prop)
		if err != nil {
			log.Warn().Err(err).
				Msgf("Unable to retrieve property %s (%s)", prop, r.dest)
			return dbus.MakeVariant(""), err
		}
		return res, nil
	} else {
		return dbus.MakeVariant(""), errors.New("no bus connection")
	}
}

// SetProp sets the specific property to the specified value
func (r *busRequest) SetProp(prop string, value dbus.Variant) error {
	if r.bus != nil {
		obj := r.bus.conn.Object(r.dest, r.path)
		return obj.SetProperty(prop, value)
	}
	return errors.New("no bus connection")
}

// GetData fetches DBus data from the given method in the builder
func (r *busRequest) GetData(method string, args ...interface{}) *dbusData {
	d := new(dbusData)
	if r.bus != nil {
		obj := r.bus.conn.Object(r.dest, r.path)
		var err error
		if args != nil {
			err = obj.Call(method, 0, args...).Store(&d.data)
		} else {
			err = obj.Call(method, 0).Store(&d.data)
		}
		if err != nil {
			log.Warn().Err(err).
				Msgf("Unable to execute %s on %s (args: %s)", method, r.dest, args)
		}
		return d
	} else {
		log.Error().Msg("No bus connection.")
		return d
	}
}

// Call executes the given method in the builder and returns the error state
func (r *busRequest) Call(method string, args ...interface{}) error {
	if r.bus != nil {
		obj := r.bus.conn.Object(r.dest, r.path)
		if args != nil {
			return obj.Call(method, 0, args...).Err
		} else {
			return obj.Call(method, 0).Err
		}
	} else {
		return errors.New("no bus connection")
	}
}

// AddWatch adds a DBus watch to the bus with the given options in the builder
func (r *busRequest) AddWatch(ctx context.Context) error {
	if r.bus == nil {
		return errors.New("no bus connection")
	}
	if err := r.bus.conn.AddMatchSignalContext(ctx, r.match...); err != nil {
		return err
	} else {
		log.Trace().Caller().
			Msgf("Adding watch on %s for %s", r.path, r.event)
		r.bus.matchRequests <- signalMatcher{
			match:   string(r.path),
			handler: r.eventHandler,
		}
	}
	return nil
}

type dbusData struct {
	data interface{}
}

// AsVariantMap formats DBus data as a map[string]dbus.Variant
func (d *dbusData) AsVariantMap() map[string]dbus.Variant {
	if d.data != nil {
		wanted := make(map[string]dbus.Variant)
		for k, v := range d.data.(map[string]interface{}) {
			wanted[k] = dbus.MakeVariant(v)
		}
		return wanted
	} else {
		return nil
	}
}

// AsStringMap formats DBus data as a map[string]string
func (d *dbusData) AsStringMap() map[string]string {
	if d.data != nil {
		return d.data.(map[string]string)
	} else {
		return nil
	}
}

// AsObjectPathList formats DBus data as a []dbus.ObjectPath
func (d *dbusData) AsObjectPathList() []dbus.ObjectPath {
	if d.data != nil {
		return d.data.([]dbus.ObjectPath)
	} else {
		return nil
	}
}

// AsStringList formats DBus data as a []string
func (d *dbusData) AsStringList() []string {
	if d.data != nil {
		return d.data.([]string)
	} else {
		return nil
	}
}

// AsObjectPath formats DBus data as a dbus.ObjectPath
func (d *dbusData) AsObjectPath() dbus.ObjectPath {
	if d.data != nil {
		return d.data.(dbus.ObjectPath)
	} else {
		return ""
	}
}

// variantToValue converts a dbus.Variant type into the specified Go native
// type.
func variantToValue[S any](variant dbus.Variant) S {
	var value S
	err := variant.Store(&value)
	if err != nil {
		log.Warn().Err(err).
			Msgf("Unable to convert dbus variant %v to type %T.", variant, value)
		return value
	}
	return value
}

// findPortal is a helper function to work out which portal interface should be
// used for getting information on running apps.
func findPortal() string {
	switch os.Getenv("XDG_CURRENT_DESKTOP") {
	case "KDE":
		return "org.freedesktop.impl.portal.desktop.kde"
	case "GNOME":
		return "org.freedesktop.impl.portal.desktop.kde"
	default:
		log.Warn().Msg("Unsupported desktop/window environment.")
		return ""
	}
}

// GetHostname will try to fetch the hostname of the device from DBus. Failing
// that, it will default to using "localhost"
func GetHostname(ctx context.Context) string {
	var dBusDest = "org.freedesktop.hostname1"
	hostnameFromDBus, err := NewBusRequest(SystemBus).
		Path(dbus.ObjectPath("/org/freedesktop/hostname1")).
		Destination(dBusDest).
		GetProp(dBusDest + ".Hostname")
	if err != nil {
		return "localhost"
	} else {
		return string(variantToValue[[]uint8](hostnameFromDBus))
	}
}

// GetHardwareDetails will try to get a hardware vendor and model from DBus.
// Failing that, it will try to read them from the /sys filesystem. If that
// fails, it returns empty strings for these values
func GetHardwareDetails(ctx context.Context) (string, string) {
	var vendor, model string
	var dBusDest = "org.freedesktop.hostname1"
	var dBusPath = "/org/freedesktop/hostname1"
	hwVendorFromDBus, err := NewBusRequest(SystemBus).
		Path(dbus.ObjectPath(dBusPath)).
		Destination(dBusDest).
		GetProp(dBusDest + ".HardwareVendor")
	if err != nil {
		hwVendor, err := os.ReadFile("/sys/devices/virtual/dmi/id/board_vendor")
		if err != nil {
			vendor = "Unknown Vendor"
		} else {
			vendor = strings.TrimSpace(string(hwVendor))
		}
	} else {
		vendor = string(variantToValue[[]uint8](hwVendorFromDBus))
	}
	hwModelFromDBus, err := NewBusRequest(SystemBus).
		Path(dbus.ObjectPath(dBusPath)).
		Destination(dBusDest).
		GetProp(dBusDest + ".HardwareVendor")
	if err != nil {
		hwModel, err := os.ReadFile("/sys/devices/virtual/dmi/id/product_name")
		if err != nil {
			model = "Unknown Vendor"
		} else {
			model = strings.TrimSpace(string(hwModel))
		}
	} else {
		model = string(variantToValue[[]uint8](hwModelFromDBus))
	}
	return vendor, model
}