// Copyright 2024-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//go:build !skip_mqtt_tests

package server

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"os"
	"os/exec"
	"strconv"
	"strings"
	"testing"

	"github.com/nats-io/nuid"
)

type mqttDial string

type mqttTarget struct {
	singleServers []*Server
	clusters      []*cluster
	configs       []mqttTestConfig
	all           []mqttDial
}

type mqttTestConfig struct {
	name string
	pub  []mqttDial
	sub  []mqttDial
}

func TestXMQTTCompliance(t *testing.T) {
	if mqttCLICommandPath == _EMPTY_ {
		t.Skip(`"mqtt" command is not found in $PATH nor $MQTT_CLI. See https://hivemq.github.io/mqtt-cli/docs/installation/#debian-package for installation instructions`)
	}

	o := testMQTTDefaultOptions()
	s := testMQTTRunServer(t, o)
	o = s.getOpts()
	defer testMQTTShutdownServer(s)

	cmd := exec.Command(mqttCLICommandPath, "test", "-V", "3", "-p", strconv.Itoa(o.MQTT.Port))

	output, err := cmd.CombinedOutput()
	t.Log(string(output))
	if err != nil {
		if exitError, ok := err.(*exec.ExitError); ok {
			t.Fatalf("mqtt cli exited with error: %v", exitError)
		}
	}
}

func TestXMQTTRetainedMessages(t *testing.T) {
	if mqttTestCommandPath == _EMPTY_ {
		t.Skip(`"mqtt-test" command is not found in $PATH.`)
	}

	for _, topo := range []struct {
		name  string
		makef func(testing.TB) *mqttTarget
	}{
		{
			name:  "single server",
			makef: mqttMakeTestServer,
		},
		{
			name:  "cluster",
			makef: mqttMakeTestCluster(4, ""),
		},
	} {
		t.Run(topo.name, func(t *testing.T) {
			target := topo.makef(t)
			t.Cleanup(target.Shutdown)

			// initialize the MQTT assets by "touching" all nodes in the
			// cluster, but then reload to start with fresh server state.
			for _, dial := range target.all {
				mqttInitTestServer(t, dial)
			}

			numRMS := 100
			strNumRMS := strconv.Itoa(numRMS)
			topics := make([]string, len(target.configs))

			for i, tc := range target.configs {
				// Publish numRMS retained messages one at a time,
				// round-robin across pub nodes. Remember the topic for each
				// test config to check the subs after reload.
				topic := "subret_" + nuid.Next()
				topics[i] = topic
				iNode := 0
				for i := 0; i < numRMS; i++ {
					pubTopic := fmt.Sprintf("%s/%d", topic, i)
					dial := tc.pub[iNode%len(tc.pub)]
					mqttRunExCommandTest(t, "pub", dial,
						"--retain",
						"--topic", pubTopic,
						"--qos", "0",
						"--size", "128", // message size 128 bytes
					)
					iNode++
				}
			}

			// Check all sub nodes for retained messages
			for i, tc := range target.configs {
				for _, dial := range tc.sub {
					mqttRunExCommandTest(t, "sub", dial,
						"--retained", strNumRMS,
						"--qos", "0",
						"--topic", topics[i],
					)
				}
			}

			// Reload the target
			target.Reload(t)

			// Now check again
			for i, tc := range target.configs {
				for _, dial := range tc.sub {
					mqttRunExCommandTestRetry(t, 1, "sub", dial,
						"--retained", strNumRMS,
						"--qos", "0",
						"--topic", topics[i],
					)
				}
			}
		})
	}
}

func mqttInitTestServer(tb testing.TB, dial mqttDial) {
	tb.Helper()
	mqttRunExCommandTestRetry(tb, 5, "pub", dial)
}

func mqttNewDialForServer(s *Server, username, password string) mqttDial {
	o := s.getOpts().MQTT
	return mqttNewDial(username, password, o.Host, o.Port, s.Name())
}

func mqttNewDial(username, password, host string, port int, comment string) mqttDial {
	d := ""
	switch {
	case username != "" && password != "":
		d = fmt.Sprintf("%s:%s@%s:%d", username, password, host, port)
	case username != "":
		d = fmt.Sprintf("%s@%s:%d", username, host, port)
	default:
		d = fmt.Sprintf("%s:%d", host, port)
	}
	if comment != "" {
		d += "#" + comment
	}
	return mqttDial(d)
}

func (d mqttDial) Get() (u, p, s, c string) {
	if d == "" {
		return "", "", "127.0.0.1:1883", ""
	}
	in := string(d)
	if i := strings.LastIndex(in, "#"); i != -1 {
		c = in[i+1:]
		in = in[:i]
	}
	if i := strings.LastIndex(in, "@"); i != -1 {
		up := in[:i]
		in = in[i+1:]
		u = up
		if i := strings.Index(up, ":"); i != -1 {
			u = up[:i]
			p = up[i+1:]
		}
	}
	s = in
	return u, p, s, c
}

func (d mqttDial) Name() string {
	_, _, _, c := d.Get()
	return c
}

func (t *mqttTarget) Reload(tb testing.TB) {
	tb.Helper()

	for _, c := range t.clusters {
		c.stopAll()
		c.restartAllSamePorts()
	}

	for i, s := range t.singleServers {
		o := s.getOpts()
		s.Shutdown()
		t.singleServers[i] = testMQTTRunServer(tb, o)
	}

	for _, dial := range t.all {
		mqttInitTestServer(tb, dial)
	}
}

func (t *mqttTarget) Shutdown() {
	for _, c := range t.clusters {
		c.shutdown()
	}
	for _, s := range t.singleServers {
		testMQTTShutdownServer(s)
	}
}

func mqttMakeTestServer(tb testing.TB) *mqttTarget {
	tb.Helper()
	o := testMQTTDefaultOptions()
	s := testMQTTRunServer(tb, o)
	all := []mqttDial{mqttNewDialForServer(s, "", "")}
	return &mqttTarget{
		singleServers: []*Server{s},
		all:           all,
		configs: []mqttTestConfig{
			{
				name: "single server",
				pub:  all,
				sub:  all,
			},
		},
	}
}

func mqttMakeTestCluster(size int, domain string) func(tb testing.TB) *mqttTarget {
	return func(tb testing.TB) *mqttTarget {
		tb.Helper()
		if size < 3 {
			tb.Fatal("cluster size must be at least 3")
		}

		if domain != "" {
			domain = "domain: " + domain + ", "
		}
		clusterConf := `
	listen: 127.0.0.1:-1

	server_name: %s
	jetstream: {max_mem_store: 256MB, max_file_store: 2GB, ` + domain + `store_dir: '%s'}

	leafnodes {
		listen: 127.0.0.1:-1
	}

	cluster {
		name: %s
		listen: 127.0.0.1:%d
		routes = [%s]
	}

	mqtt {
		listen: 127.0.0.1:-1
		stream_replicas: 3
	}

	accounts {
		ONE { users = [ { user: "one", pass: "p" } ]; jetstream: enabled }
		$SYS { users = [ { user: "admin", pass: "s3cr3t!" } ] }
	}
`
		cl := createJetStreamClusterWithTemplate(tb, clusterConf, "MQTT", size)
		cl.waitOnLeader()

		all := []mqttDial{}
		for _, s := range cl.servers {
			all = append(all, mqttNewDialForServer(s, "one", "p"))
		}

		return &mqttTarget{
			clusters: []*cluster{cl},
			all:      all,
			configs: []mqttTestConfig{
				{
					name: "publish to one",
					pub: []mqttDial{
						mqttNewDialForServer(cl.randomServer(), "one", "p"),
					},
					sub: all,
				},
				{
					name: "publish to all",
					pub:  all,
					sub:  all,
				},
			},
		}
	}
}

var mqttCLICommandPath = func() string {
	p := os.Getenv("MQTT_CLI")
	if p == "" {
		p, _ = exec.LookPath("mqtt")
	}
	return p
}()

var mqttTestCommandPath = func() string {
	p, _ := exec.LookPath("mqtt-test")
	return p
}()

func mqttRunExCommandTest(tb testing.TB, subCommand string, dial mqttDial, extraArgs ...string) *MQTTBenchmarkResult {
	tb.Helper()
	return mqttRunExCommandTestRetry(tb, 1, subCommand, dial, extraArgs...)
}

func mqttRunExCommandTestRetry(tb testing.TB, n int, subCommand string, dial mqttDial, extraArgs ...string) (r *MQTTBenchmarkResult) {
	tb.Helper()
	var err error
	for i := 0; i < n; i++ {
		if r, err = mqttTryExCommandTest(tb, subCommand, dial, extraArgs...); err == nil {
			return r
		}

		if i < (n - 1) {
			tb.Logf("failed to %q %s to %q on attempt %v, will retry.", subCommand, extraArgs, dial.Name(), i)
		} else {
			tb.Fatal(err)
		}
	}
	return nil
}

func mqttTryExCommandTest(tb testing.TB, subCommand string, dial mqttDial, extraArgs ...string) (r *MQTTBenchmarkResult, err error) {
	tb.Helper()
	if mqttTestCommandPath == "" {
		tb.Skip(`"mqtt-test" command is not found in $PATH.`)
	}

	args := []string{subCommand, // "-q",
		"-s", string(dial),
	}
	args = append(args, extraArgs...)
	cmd := exec.Command(mqttTestCommandPath, args...)

	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return nil, fmt.Errorf("error executing %q: %v", cmd.String(), err)
	}
	defer stdout.Close()
	errbuf := bytes.Buffer{}
	cmd.Stderr = &errbuf
	if err = cmd.Start(); err != nil {
		return nil, fmt.Errorf("error executing %q: %v", cmd.String(), err)
	}
	out, err := io.ReadAll(stdout)
	if err != nil {
		return nil, fmt.Errorf("error executing %q: failed to read output: %v", cmd.String(), err)
	}
	if err = cmd.Wait(); err != nil {
		return nil, fmt.Errorf("error executing %q: %v\n\n%s\n\n%s", cmd.String(), err, string(out), errbuf.String())
	}

	r = &MQTTBenchmarkResult{}
	if err := json.Unmarshal(out, r); err != nil {
		tb.Fatalf("error executing %q: failed to decode output: %v\n\n%s\n\n%s", cmd.String(), err, string(out), errbuf.String())
	}
	return r, nil
}
