package oonimkall_test

import (
	"encoding/json"
	"errors"
	"strings"
	"testing"
	"time"

	"github.com/google/go-cmp/cmp"
	"github.com/ooni/probe-cli/v3/internal/engine/model"
	"github.com/ooni/probe-cli/v3/pkg/oonimkall"
	"github.com/ooni/probe-cli/v3/pkg/oonimkall/internal/tasks"
)

type eventlike struct {
	Key   string                 `json:"key"`
	Value map[string]interface{} `json:"value"`
}

func TestGood(t *testing.T) {
	if testing.Short() {
		t.Skip("skip test in short mode")
	}
	task, err := oonimkall.StartTask(`{
		"assets_dir": "../testdata/oonimkall/assets",
		"log_level": "DEBUG",
		"name": "Example",
		"options": {
			"software_name": "oonimkall-test",
			"software_version": "0.1.0"
		},
		"state_dir": "../testdata/oonimkall/state",
		"version": 1
	}`)
	if err != nil {
		t.Fatal(err)
	}
	// interrupt the task so we also exercise this functionality
	go func() {
		<-time.After(time.Second)
		task.Interrupt()
	}()
	for !task.IsDone() {
		eventstr := task.WaitForNextEvent()
		var event eventlike
		if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
			t.Fatal(err)
		}
		if event.Key == "failure.startup" {
			t.Fatal("unexpected failure.startup event")
		}
	}
	// make sure we only see task_terminated at this point
	for {
		eventstr := task.WaitForNextEvent()
		var event eventlike
		if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
			t.Fatal(err)
		}
		if event.Key == "task_terminated" {
			break
		}
		t.Fatalf("unexpected event.Key: %s", event.Key)
	}
}

func TestWithMeasurementFailure(t *testing.T) {
	if testing.Short() {
		t.Skip("skip test in short mode")
	}
	task, err := oonimkall.StartTask(`{
		"assets_dir": "../testdata/oonimkall/assets",
		"log_level": "DEBUG",
		"name": "ExampleWithFailure",
		"options": {
			"no_geoip": true,
			"no_resolver_lookup": true,
			"software_name": "oonimkall-test",
			"software_version": "0.1.0"
		},
		"state_dir": "../testdata/oonimkall/state",
		"version": 1
	}`)
	if err != nil {
		t.Fatal(err)
	}
	for !task.IsDone() {
		eventstr := task.WaitForNextEvent()
		var event eventlike
		if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
			t.Fatal(err)
		}
		if event.Key == "failure.startup" {
			t.Fatal("unexpected failure.startup event")
		}
	}
}

func TestInvalidJSON(t *testing.T) {
	task, err := oonimkall.StartTask(`{`)
	var syntaxerr *json.SyntaxError
	if !errors.As(err, &syntaxerr) {
		t.Fatal("not the expected error")
	}
	if task != nil {
		t.Fatal("task is not nil")
	}
}

func TestUnsupportedSetting(t *testing.T) {
	task, err := oonimkall.StartTask(`{
		"assets_dir": "../testdata/oonimkall/assets",
		"log_level": "DEBUG",
		"name": "Example",
		"options": {
			"software_name": "oonimkall-test",
			"software_version": "0.1.0"
		},
		"state_dir": "../testdata/oonimkall/state"
	}`)
	if err != nil {
		t.Fatal(err)
	}
	var seen bool
	for !task.IsDone() {
		eventstr := task.WaitForNextEvent()
		var event eventlike
		if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
			t.Fatal(err)
		}
		if event.Key == "failure.startup" {
			if strings.Contains(eventstr, tasks.FailureInvalidVersion) {
				seen = true
			}
		}
	}
	if !seen {
		t.Fatal("did not see failure.startup with invalid version info")
	}
}

func TestEmptyStateDir(t *testing.T) {
	task, err := oonimkall.StartTask(`{
		"assets_dir": "../testdata/oonimkall/assets",
		"log_level": "DEBUG",
		"name": "Example",
		"options": {
			"software_name": "oonimkall-test",
			"software_version": "0.1.0"
		},
		"version": 1
	}`)
	if err != nil {
		t.Fatal(err)
	}
	var seen bool
	for !task.IsDone() {
		eventstr := task.WaitForNextEvent()
		var event eventlike
		if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
			t.Fatal(err)
		}
		if event.Key == "failure.startup" {
			if strings.Contains(eventstr, "mkdir : no such file or directory") {
				seen = true
			}
		}
	}
	if !seen {
		t.Fatal("did not see failure.startup with info that state dir is empty")
	}
}

func TestUnknownExperiment(t *testing.T) {
	task, err := oonimkall.StartTask(`{
		"assets_dir": "../testdata/oonimkall/assets",
		"log_level": "DEBUG",
		"name": "Antani",
		"options": {
			"software_name": "oonimkall-test",
			"software_version": "0.1.0"
		},
		"state_dir": "../testdata/oonimkall/state",
		"version": 1
	}`)
	if err != nil {
		t.Fatal(err)
	}
	var seen bool
	for !task.IsDone() {
		eventstr := task.WaitForNextEvent()
		var event eventlike
		if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
			t.Fatal(err)
		}
		if event.Key == "failure.startup" {
			if strings.Contains(eventstr, "no such experiment: ") {
				seen = true
			}
		}
	}
	if !seen {
		t.Fatal("did not see failure.startup")
	}
}

func TestInputIsRequired(t *testing.T) {
	if testing.Short() {
		t.Skip("skip test in short mode")
	}
	task, err := oonimkall.StartTask(`{
		"assets_dir": "../testdata/oonimkall/assets",
		"log_level": "DEBUG",
		"name": "ExampleWithInput",
		"options": {
			"software_name": "oonimkall-test",
			"software_version": "0.1.0"
		},
		"state_dir": "../testdata/oonimkall/state",
		"version": 1
	}`)
	if err != nil {
		t.Fatal(err)
	}
	var seen bool
	for !task.IsDone() {
		eventstr := task.WaitForNextEvent()
		var event eventlike
		if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
			t.Fatal(err)
		}
		if event.Key == "failure.startup" {
			if strings.Contains(eventstr, "no input provided") {
				seen = true
			}
		}
	}
	if !seen {
		t.Fatal("did not see failure.startup")
	}
}

func TestMaxRuntime(t *testing.T) {
	if testing.Short() {
		t.Skip("skip test in short mode")
	}
	begin := time.Now()
	task, err := oonimkall.StartTask(`{
		"assets_dir": "../testdata/oonimkall/assets",
		"inputs": ["a", "b", "c"],
		"name": "ExampleWithInput",
		"options": {
			"max_runtime": 1,
			"software_name": "oonimkall-test",
			"software_version": "0.1.0"
		},
		"state_dir": "../testdata/oonimkall/state",
		"version": 1
	}`)
	if err != nil {
		t.Fatal(err)
	}
	for !task.IsDone() {
		eventstr := task.WaitForNextEvent()
		var event eventlike
		if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
			t.Fatal(err)
		}
		if event.Key == "failure.startup" {
			t.Fatal(eventstr)
		}
	}
	// The runtime is long because of ancillary operations and is even more
	// longer because of self shaping we may be performing (especially in
	// CI builds) using `-tags shaping`). We have experimentally determined
	// that ~10 seconds is the typical CI test run time. See:
	//
	// 1. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263788
	//
	// 2. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263855
	//
	// In case there are further timeouts, e.g. in the sessionresolver, the
	// time used by the experiment will be much more. This is for example the
	// case in https://github.com/ooni/probe-engine/issues/1005.
	if time.Since(begin) > 10*time.Second {
		t.Fatal("expected shorter runtime")
	}
}

func TestInterruptExampleWithInput(t *testing.T) {
	if testing.Short() {
		t.Skip("skip test in short mode")
	}
	t.Skip("Skipping broken test; see https://github.com/ooni/probe-cli/v3/internal/engine/issues/992")
	task, err := oonimkall.StartTask(`{
		"assets_dir": "../testdata/oonimkall/assets",
		"inputs": [
			"http://www.kernel.org/",
			"http://www.x.org/",
			"http://www.microsoft.com/",
			"http://www.slashdot.org/",
			"http://www.repubblica.it/",
			"http://www.google.it/",
			"http://ooni.org/"
		],
		"name": "ExampleWithInputNonInterruptible",
		"options": {
			"software_name": "oonimkall-test",
			"software_version": "0.1.0"
		},
		"state_dir": "../testdata/oonimkall/state",
		"version": 1
	}`)
	if err != nil {
		t.Fatal(err)
	}
	var keys []string
	for !task.IsDone() {
		eventstr := task.WaitForNextEvent()
		var event eventlike
		if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
			t.Fatal(err)
		}
		switch event.Key {
		case "failure.startup":
			t.Fatal(eventstr)
		case "status.measurement_start":
			go task.Interrupt()
		}
		// We compress the keys. What matters is basically that we
		// see just one of the many possible measurements here.
		if keys == nil || keys[len(keys)-1] != event.Key {
			keys = append(keys, event.Key)
		}
	}
	expect := []string{
		"status.queued",
		"status.started",
		"status.progress",
		"status.geoip_lookup",
		"status.resolver_lookup",
		"status.progress",
		"status.report_create",
		"status.measurement_start",
		"log",
		"status.progress",
		"measurement",
		"status.measurement_submission",
		"status.measurement_done",
		"status.end",
		"task_terminated",
	}
	if diff := cmp.Diff(expect, keys); diff != "" {
		t.Fatal(diff)
	}
}

func TestInterruptNdt7(t *testing.T) {
	if testing.Short() {
		t.Skip("skip test in short mode")
	}
	task, err := oonimkall.StartTask(`{
		"assets_dir": "../testdata/oonimkall/assets",
		"name": "Ndt7",
		"options": {
			"software_name": "oonimkall-test",
			"software_version": "0.1.0"
		},
		"state_dir": "../testdata/oonimkall/state",
		"version": 1
	}`)
	if err != nil {
		t.Fatal(err)
	}
	go func() {
		<-time.After(11 * time.Second)
		task.Interrupt()
	}()
	var keys []string
	for !task.IsDone() {
		eventstr := task.WaitForNextEvent()
		var event eventlike
		if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
			t.Fatal(err)
		}
		if event.Key == "failure.startup" {
			t.Fatal(eventstr)
		}
		// We compress the keys because we don't know how many
		// status.progress we will see. What matters is that we
		// don't see a measurement submission, since it means
		// that we have interrupted the measurement.
		if keys == nil || keys[len(keys)-1] != event.Key {
			keys = append(keys, event.Key)
		}
	}
	expect := []string{
		"status.queued",
		"status.started",
		"status.progress",
		"status.geoip_lookup",
		"status.resolver_lookup",
		"status.progress",
		"status.report_create",
		"status.measurement_start",
		"status.progress",
		"status.end",
		"task_terminated",
	}
	if diff := cmp.Diff(expect, keys); diff != "" {
		t.Fatal(diff)
	}
}

func TestCountBytesForExample(t *testing.T) {
	if testing.Short() {
		t.Skip("skip test in short mode")
	}
	task, err := oonimkall.StartTask(`{
		"assets_dir": "../testdata/oonimkall/assets",
		"name": "Example",
		"options": {
			"software_name": "oonimkall-test",
			"software_version": "0.1.0"
		},
		"state_dir": "../testdata/oonimkall/state",
		"version": 1
	}`)
	if err != nil {
		t.Fatal(err)
	}
	var downloadKB, uploadKB float64
	for !task.IsDone() {
		eventstr := task.WaitForNextEvent()
		var event eventlike
		if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
			t.Fatal(err)
		}
		switch event.Key {
		case "failure.startup":
			t.Fatal(eventstr)
		case "status.end":
			downloadKB = event.Value["downloaded_kb"].(float64)
			uploadKB = event.Value["uploaded_kb"].(float64)
		}
	}
	if downloadKB == 0 {
		t.Fatal("downloadKB is zero")
	}
	if uploadKB == 0 {
		t.Fatal("uploadKB is zero")
	}
}

func TestPrivacyAndScrubbing(t *testing.T) {
	if testing.Short() {
		t.Skip("skip test in short mode")
	}
	task, err := oonimkall.StartTask(`{
			"assets_dir": "../testdata/oonimkall/assets",
			"name": "Example",
			"options": {
				"software_name": "oonimkall-test",
				"software_version": "0.1.0"
			},
			"state_dir": "../testdata/oonimkall/state",
			"version": 1
		}`)
	if err != nil {
		t.Fatal(err)
	}
	var m *model.Measurement
	for !task.IsDone() {
		eventstr := task.WaitForNextEvent()
		var event eventlike
		if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
			t.Fatal(err)
		}
		switch event.Key {
		case "failure.startup":
			t.Fatal(eventstr)
		case "measurement":
			v := []byte(event.Value["json_str"].(string))
			m = new(model.Measurement)
			if err := json.Unmarshal(v, &m); err != nil {
				t.Fatal(err)
			}
		}
	}
	if m == nil {
		t.Fatal("measurement is nil")
	}
	if m.ProbeASN == "AS0" || m.ProbeCC == "ZZ" || m.ProbeIP != "127.0.0.1" {
		t.Fatal("unexpected result")
	}
}

func TestNonblock(t *testing.T) {
	if testing.Short() {
		t.Skip("skip test in short mode")
	}
	task, err := oonimkall.StartTask(`{
		"assets_dir": "../testdata/oonimkall/assets",
		"name": "Example",
		"options": {
			"software_name": "oonimkall-test",
			"software_version": "0.1.0"
		},
		"state_dir": "../testdata/oonimkall/state",
		"version": 1
	}`)
	if err != nil {
		t.Fatal(err)
	}
	if !task.IsRunning() {
		t.Fatal("The runner should be running at this point")
	}
	// If the task blocks because it emits too much events, this test
	// will run forever and will be killed. Because we have room for up
	// to 128 events in the buffer, we should hopefully be fine.
	for task.IsRunning() {
		time.Sleep(time.Second)
	}
	for !task.IsDone() {
		task.WaitForNextEvent()
	}
}