package cmd

import (
	"errors"
	"fmt"
	"github.com/urfave/cli/v2"
	"heckel.io/ntfy/v2/client"
	"heckel.io/ntfy/v2/log"
	"heckel.io/ntfy/v2/util"
	"os"
	"os/exec"
	"os/user"
	"path/filepath"
	"sort"
	"strings"
)

func init() {
	commands = append(commands, cmdSubscribe)
}

const (
	clientRootConfigFileUnixAbsolute    = "/etc/ntfy/client.yml"
	clientUserConfigFileUnixRelative    = "ntfy/client.yml"
	clientUserConfigFileWindowsRelative = "ntfy\\client.yml"
)

var flagsSubscribe = append(
	append([]cli.Flag{}, flagsDefault...),
	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
	&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
	&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
	&cli.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"},
	&cli.BoolFlag{Name: "from-config", Aliases: []string{"from_config", "C"}, Usage: "read subscriptions from config file (service mode)"},
	&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
	&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
)

var cmdSubscribe = &cli.Command{
	Name:      "subscribe",
	Aliases:   []string{"sub"},
	Usage:     "Subscribe to one or more topics on a ntfy server",
	UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
	Action:    execSubscribe,
	Category:  categoryClient,
	Flags:     flagsSubscribe,
	Before:    initLogFunc,
	Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for 
every arriving message. There are 3 modes in which the command can be run:

ntfy subscribe TOPIC
  This prints the JSON representation of every incoming message. It is useful when you
  have a command that wants to stream-read incoming JSON messages. Unless --poll is passed,
  this command stays open forever. 

  Examples:
    ntfy subscribe mytopic            # Prints JSON for incoming messages for ntfy.sh/mytopic
    ntfy sub home.lan/backups         # Subscribe to topic on different server
    ntfy sub --poll home.lan/backups  # Just query for latest messages and exit
    ntfy sub -u phil:mypass secret    # Subscribe with username/password
  
ntfy subscribe TOPIC COMMAND
  This executes COMMAND for every incoming messages. The message fields are passed to the
  command as environment variables:

    Variable        Aliases               Description
    --------------- --------------------- -----------------------------------
    $NTFY_ID        $id                   Unique message ID
    $NTFY_TIME      $time                 Unix timestamp of the message delivery
    $NTFY_TOPIC     $topic                Topic name
    $NTFY_MESSAGE   $message, $m          Message body
    $NTFY_TITLE     $title, $t            Message title
    $NTFY_PRIORITY  $priority, $prio, $p  Message priority (1=min, 5=max)
    $NTFY_TAGS      $tags, $tag, $ta      Message tags (comma separated list)
    $NTFY_RAW       $raw                  Raw JSON message

  Examples:
    ntfy sub mytopic 'notify-send "$m"'    # Execute command for incoming messages
    ntfy sub topic1 myscript.sh            # Execute script for incoming messages

ntfy subscribe --from-config
  Service mode (used in ntfy-client.service). This reads the config file and sets up 
  subscriptions for every topic in the "subscribe:" block (see config file).

  Examples: 
    ntfy sub --from-config                           # Read topics from config file
    ntfy sub --config=myclient.yml --from-config     # Read topics from alternate config file

` + clientCommandDescriptionSuffix,
}

func execSubscribe(c *cli.Context) error {
	// Read config and options
	conf, err := loadConfig(c)
	if err != nil {
		return err
	}
	cl := client.New(conf)
	since := c.String("since")
	user := c.String("user")
	token := c.String("token")
	poll := c.Bool("poll")
	scheduled := c.Bool("scheduled")
	fromConfig := c.Bool("from-config")
	topic := c.Args().Get(0)
	command := c.Args().Get(1)

	// Checks
	if user != "" && token != "" {
		return errors.New("cannot set both --user and --token")
	}

	if !fromConfig {
		conf.Subscribe = nil // wipe if --from-config not passed
	}
	var options []client.SubscribeOption
	if since != "" {
		options = append(options, client.WithSince(since))
	}
	if token != "" {
		options = append(options, client.WithBearerAuth(token))
	} else if user != "" {
		var pass string
		parts := strings.SplitN(user, ":", 2)
		if len(parts) == 2 {
			user = parts[0]
			pass = parts[1]
		} else {
			fmt.Fprint(c.App.ErrWriter, "Enter Password: ")
			p, err := util.ReadPassword(c.App.Reader)
			if err != nil {
				return err
			}
			pass = string(p)
			fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
		}
		options = append(options, client.WithBasicAuth(user, pass))
	} else if conf.DefaultToken != "" {
		options = append(options, client.WithBearerAuth(conf.DefaultToken))
	} else if conf.DefaultUser != "" && conf.DefaultPassword != nil {
		options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword))
	}
	if scheduled {
		options = append(options, client.WithScheduled())
	}
	if topic == "" && len(conf.Subscribe) == 0 {
		return errors.New("must specify topic, type 'ntfy subscribe --help' for help")
	}

	// Execute poll or subscribe
	if poll {
		return doPoll(c, cl, conf, topic, command, options...)
	}
	return doSubscribe(c, cl, conf, topic, command, options...)
}

func doPoll(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
	for _, s := range conf.Subscribe { // may be nil
		if auth := maybeAddAuthHeader(s, conf); auth != nil {
			options = append(options, auth)
		}
		if err := doPollSingle(c, cl, s.Topic, s.Command, options...); err != nil {
			return err
		}
	}
	if topic != "" {
		if err := doPollSingle(c, cl, topic, command, options...); err != nil {
			return err
		}
	}
	return nil
}

func doPollSingle(c *cli.Context, cl *client.Client, topic, command string, options ...client.SubscribeOption) error {
	messages, err := cl.Poll(topic, options...)
	if err != nil {
		return err
	}
	for _, m := range messages {
		printMessageOrRunCommand(c, m, command)
	}
	return nil
}

func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
	cmds := make(map[string]string)    // Subscription ID -> command
	for _, s := range conf.Subscribe { // May be nil
		topicOptions := append(make([]client.SubscribeOption, 0), options...)
		for filter, value := range s.If {
			topicOptions = append(topicOptions, client.WithFilter(filter, value))
		}

		if auth := maybeAddAuthHeader(s, conf); auth != nil {
			topicOptions = append(topicOptions, auth)
		}

		subscriptionID, err := cl.Subscribe(s.Topic, topicOptions...)
		if err != nil {
			return err
		}
		if s.Command != "" {
			cmds[subscriptionID] = s.Command
		} else if conf.DefaultCommand != "" {
			cmds[subscriptionID] = conf.DefaultCommand
		} else {
			cmds[subscriptionID] = ""
		}
	}
	if topic != "" {
		subscriptionID, err := cl.Subscribe(topic, options...)
		if err != nil {
			return err
		}
		cmds[subscriptionID] = command
	}
	for m := range cl.Messages {
		cmd, ok := cmds[m.SubscriptionID]
		if !ok {
			continue
		}
		log.Debug("%s Dispatching received message: %s", logMessagePrefix(m), m.Raw)
		printMessageOrRunCommand(c, m, cmd)
	}
	return nil
}

func maybeAddAuthHeader(s client.Subscribe, conf *client.Config) client.SubscribeOption {
	// if an explicit empty token or empty user:pass is given, exit without auth
	if (s.Token != nil && *s.Token == "") || (s.User != nil && *s.User == "" && s.Password != nil && *s.Password == "") {
		return client.WithEmptyAuth()
	}

	// check for subscription token then subscription user:pass
	if s.Token != nil && *s.Token != "" {
		return client.WithBearerAuth(*s.Token)
	}
	if s.User != nil && *s.User != "" && s.Password != nil {
		return client.WithBasicAuth(*s.User, *s.Password)
	}

	// if no subscription token nor subscription user:pass, check for default token then default user:pass
	if conf.DefaultToken != "" {
		return client.WithBearerAuth(conf.DefaultToken)
	}
	if conf.DefaultUser != "" && conf.DefaultPassword != nil {
		return client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)
	}
	return nil
}

func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string) {
	if command != "" {
		runCommand(c, command, m)
	} else {
		log.Debug("%s Printing raw message", logMessagePrefix(m))
		fmt.Fprintln(c.App.Writer, m.Raw)
	}
}

func runCommand(c *cli.Context, command string, m *client.Message) {
	if err := runCommandInternal(c, command, m); err != nil {
		log.Warn("%s Command failed: %s", logMessagePrefix(m), err.Error())
	}
}

func runCommandInternal(c *cli.Context, script string, m *client.Message) error {
	scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.%s", os.TempDir(), util.RandomString(10), scriptExt)
	log.Debug("%s Running command '%s' via temporary script %s", logMessagePrefix(m), script, scriptFile)
	script = scriptHeader + script
	if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil {
		return err
	}
	defer os.Remove(scriptFile)
	log.Debug("%s Executing script %s", logMessagePrefix(m), scriptFile)
	cmd := exec.Command(scriptLauncher[0], append(scriptLauncher[1:], scriptFile)...)
	cmd.Stdin = c.App.Reader
	cmd.Stdout = c.App.Writer
	cmd.Stderr = c.App.ErrWriter
	cmd.Env = envVars(m)
	return cmd.Run()
}

func envVars(m *client.Message) []string {
	env := make([]string, 0)
	env = append(env, envVar(m.ID, "NTFY_ID", "id")...)
	env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...)
	env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...)
	env = append(env, envVar(m.Message, "NTFY_MESSAGE", "message", "m")...)
	env = append(env, envVar(m.Title, "NTFY_TITLE", "title", "t")...)
	env = append(env, envVar(fmt.Sprintf("%d", m.Priority), "NTFY_PRIORITY", "priority", "prio", "p")...)
	env = append(env, envVar(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "tag", "ta")...)
	env = append(env, envVar(m.Raw, "NTFY_RAW", "raw")...)
	sort.Strings(env)
	if log.IsTrace() {
		log.Trace("%s With environment:\n%s", logMessagePrefix(m), strings.Join(env, "\n"))
	}
	return append(os.Environ(), env...)
}

func envVar(value string, vars ...string) []string {
	env := make([]string, 0)
	for _, v := range vars {
		env = append(env, fmt.Sprintf("%s=%s", v, value))
	}
	return env
}

func loadConfig(c *cli.Context) (*client.Config, error) {
	filename := c.String("config")
	if filename != "" {
		return client.LoadConfig(filename)
	}
	configFile, err := defaultClientConfigFile()
	if err != nil {
		log.Warn("Could not determine default client config file: %s", err.Error())
	} else {
		if s, _ := os.Stat(configFile); s != nil {
			return client.LoadConfig(configFile)
		}
		log.Debug("Config file %s not found", configFile)
	}
	log.Debug("Loading default config")
	return client.NewConfig(), nil
}

//lint:ignore U1000 Conditionally used in different builds
func defaultClientConfigFileUnix() (string, error) {
	u, err := user.Current()
	if err != nil {
		return "", fmt.Errorf("could not determine current user: %w", err)
	}
	configFile := clientRootConfigFileUnixAbsolute
	if u.Uid != "0" {
		homeDir, err := os.UserConfigDir()
		if err != nil {
			return "", fmt.Errorf("could not determine user config dir: %w", err)
		}
		return filepath.Join(homeDir, clientUserConfigFileUnixRelative), nil
	}
	return configFile, nil
}

//lint:ignore U1000 Conditionally used in different builds
func defaultClientConfigFileWindows() (string, error) {
	homeDir, err := os.UserConfigDir()
	if err != nil {
		return "", fmt.Errorf("could not determine user config dir: %w", err)
	}
	return filepath.Join(homeDir, clientUserConfigFileWindowsRelative), nil
}

func logMessagePrefix(m *client.Message) string {
	return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID)
}
