// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

package setting

import (
	"cmp"
	"encoding/json"
	"testing"
	"time"

	jsonv2 "github.com/go-json-experiment/json"
	"tailscale.com/util/syspolicy/internal"
	"tailscale.com/util/syspolicy/pkey"
	"tailscale.com/util/syspolicy/ptype"
)

const (
	VisibleByPolicy    = ptype.VisibleByPolicy
	ShowChoiceByPolicy = ptype.ShowChoiceByPolicy
)

func TestMergeSnapshots(t *testing.T) {
	tests := []struct {
		name   string
		s1, s2 *Snapshot
		want   *Snapshot
	}{
		{
			name: "both-nil",
			s1:   nil,
			s2:   nil,
			want: NewSnapshot(map[pkey.Key]RawItem{}),
		},
		{
			name: "both-empty",
			s1:   NewSnapshot(map[pkey.Key]RawItem{}),
			s2:   NewSnapshot(map[pkey.Key]RawItem{}),
			want: NewSnapshot(map[pkey.Key]RawItem{}),
		},
		{
			name: "first-nil",
			s1:   nil,
			s2: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(true),
			}),
			want: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(true),
			}),
		},
		{
			name: "first-empty",
			s1:   NewSnapshot(map[pkey.Key]RawItem{}),
			s2: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
			}),
			want: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
			}),
		},
		{
			name: "second-nil",
			s1: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(true),
			}),
			s2: nil,
			want: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(true),
			}),
		},
		{
			name: "second-empty",
			s1: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
			}),
			s2: NewSnapshot(map[pkey.Key]RawItem{}),
			want: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
			}),
		},
		{
			name: "no-conflicts",
			s1: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
			}),
			s2: NewSnapshot(map[pkey.Key]RawItem{
				"Setting4": RawItemOf(2 * time.Hour),
				"Setting5": RawItemOf(VisibleByPolicy),
				"Setting6": RawItemOf(ShowChoiceByPolicy),
			}),
			want: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
				"Setting4": RawItemOf(2 * time.Hour),
				"Setting5": RawItemOf(VisibleByPolicy),
				"Setting6": RawItemOf(ShowChoiceByPolicy),
			}),
		},
		{
			name: "with-conflicts",
			s1: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(true),
			}),
			s2: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(456),
				"Setting3": RawItemOf(false),
				"Setting4": RawItemOf(2 * time.Hour),
			}),
			want: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(456),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
				"Setting4": RawItemOf(2 * time.Hour),
			}),
		},
		{
			name: "with-scope-first-wins",
			s1: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(true),
			}, DeviceScope),
			s2: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(456),
				"Setting3": RawItemOf(false),
				"Setting4": RawItemOf(2 * time.Hour),
			}, CurrentUserScope),
			want: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(true),
				"Setting4": RawItemOf(2 * time.Hour),
			}, CurrentUserScope),
		},
		{
			name: "with-scope-second-wins",
			s1: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(true),
			}, CurrentUserScope),
			s2: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(456),
				"Setting3": RawItemOf(false),
				"Setting4": RawItemOf(2 * time.Hour),
			}, DeviceScope),
			want: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(456),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
				"Setting4": RawItemOf(2 * time.Hour),
			}, CurrentUserScope),
		},
		{
			name: "with-scope-both-empty",
			s1:   NewSnapshot(map[pkey.Key]RawItem{}, CurrentUserScope),
			s2:   NewSnapshot(map[pkey.Key]RawItem{}, DeviceScope),
			want: NewSnapshot(map[pkey.Key]RawItem{}, CurrentUserScope),
		},
		{
			name: "with-scope-first-empty",
			s1:   NewSnapshot(map[pkey.Key]RawItem{}, CurrentUserScope),
			s2: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(true)}, DeviceScope, NewNamedOrigin("TestPolicy", DeviceScope)),
			want: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(true),
			}, CurrentUserScope, NewNamedOrigin("TestPolicy", DeviceScope)),
		},
		{
			name: "with-scope-second-empty",
			s1: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(true),
			}, CurrentUserScope),
			s2: NewSnapshot(map[pkey.Key]RawItem{}),
			want: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(true),
			}, CurrentUserScope),
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := MergeSnapshots(tt.s1, tt.s2)
			if !got.Equal(tt.want) {
				t.Errorf("got %v, want %v", got, tt.want)
			}
		})
	}
}

func TestSnapshotEqual(t *testing.T) {
	tests := []struct {
		name           string
		s1, s2         *Snapshot
		wantEqual      bool
		wantEqualItems bool
	}{
		{
			name:           "nil-nil",
			s1:             nil,
			s2:             nil,
			wantEqual:      true,
			wantEqualItems: true,
		},
		{
			name:           "nil-empty",
			s1:             nil,
			s2:             NewSnapshot(map[pkey.Key]RawItem{}),
			wantEqual:      true,
			wantEqualItems: true,
		},
		{
			name:           "empty-nil",
			s1:             NewSnapshot(map[pkey.Key]RawItem{}),
			s2:             nil,
			wantEqual:      true,
			wantEqualItems: true,
		},
		{
			name:           "empty-empty",
			s1:             NewSnapshot(map[pkey.Key]RawItem{}),
			s2:             NewSnapshot(map[pkey.Key]RawItem{}),
			wantEqual:      true,
			wantEqualItems: true,
		},
		{
			name: "first-nil",
			s1:   nil,
			s2: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
			}),
			wantEqual:      false,
			wantEqualItems: false,
		},
		{
			name: "first-empty",
			s1:   NewSnapshot(map[pkey.Key]RawItem{}),
			s2: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
			}),
			wantEqual:      false,
			wantEqualItems: false,
		},
		{
			name: "second-nil",
			s1: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(true),
			}),
			s2:             nil,
			wantEqual:      false,
			wantEqualItems: false,
		},
		{
			name: "second-empty",
			s1: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
			}),
			s2:             NewSnapshot(map[pkey.Key]RawItem{}),
			wantEqual:      false,
			wantEqualItems: false,
		},
		{
			name: "same-items-same-order-no-scope",
			s1: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
			}),
			s2: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
			}),
			wantEqual:      true,
			wantEqualItems: true,
		},
		{
			name: "same-items-same-order-same-scope",
			s1: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
			}, DeviceScope),
			s2: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
			}, DeviceScope),
			wantEqual:      true,
			wantEqualItems: true,
		},
		{
			name: "same-items-different-order-same-scope",
			s1: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
			}, DeviceScope),
			s2: NewSnapshot(map[pkey.Key]RawItem{
				"Setting3": RawItemOf(false),
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
			}, DeviceScope),
			wantEqual:      true,
			wantEqualItems: true,
		},
		{
			name: "same-items-same-order-different-scope",
			s1: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
			}, DeviceScope),
			s2: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
			}, CurrentUserScope),
			wantEqual:      false,
			wantEqualItems: true,
		},
		{
			name: "different-items-same-scope",
			s1: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(123),
				"Setting2": RawItemOf("String"),
				"Setting3": RawItemOf(false),
			}, DeviceScope),
			s2: NewSnapshot(map[pkey.Key]RawItem{
				"Setting4": RawItemOf(2 * time.Hour),
				"Setting5": RawItemOf(VisibleByPolicy),
				"Setting6": RawItemOf(ShowChoiceByPolicy),
			}, DeviceScope),
			wantEqual:      false,
			wantEqualItems: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if gotEqual := tt.s1.Equal(tt.s2); gotEqual != tt.wantEqual {
				t.Errorf("WantEqual: got %v, want %v", gotEqual, tt.wantEqual)
			}
			if gotEqualItems := tt.s1.EqualItems(tt.s2); gotEqualItems != tt.wantEqualItems {
				t.Errorf("WantEqualItems: got %v, want %v", gotEqualItems, tt.wantEqualItems)
			}
		})
	}
}

func TestSnapshotString(t *testing.T) {
	tests := []struct {
		name       string
		snapshot   *Snapshot
		wantString string
	}{
		{
			name:       "nil",
			snapshot:   nil,
			wantString: "{Empty}",
		},
		{
			name:       "empty",
			snapshot:   NewSnapshot(nil),
			wantString: "{Empty}",
		},
		{
			name:       "empty-with-scope",
			snapshot:   NewSnapshot(nil, DeviceScope),
			wantString: "{Empty, Device}",
		},
		{
			name:       "empty-with-origin",
			snapshot:   NewSnapshot(nil, NewNamedOrigin("Test Policy", DeviceScope)),
			wantString: "{Empty, Test Policy (Device)}",
		},
		{
			name: "non-empty",
			snapshot: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemOf(2 * time.Hour),
				"Setting2": RawItemOf(VisibleByPolicy),
				"Setting3": RawItemOf(ShowChoiceByPolicy),
			}, NewNamedOrigin("Test Policy", DeviceScope)),
			wantString: `{Test Policy (Device)}
Setting1 = 2h0m0s
Setting2 = show
Setting3 = user-decides`,
		},
		{
			name: "non-empty-with-item-origin",
			snapshot: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemWith(42, nil, NewNamedOrigin("Test Policy", DeviceScope)),
			}),
			wantString: `Setting1 = 42 - {Test Policy (Device)}`,
		},
		{
			name: "non-empty-with-item-error",
			snapshot: NewSnapshot(map[pkey.Key]RawItem{
				"Setting1": RawItemWith(nil, NewErrorText("bang!"), nil),
			}),
			wantString: `Setting1 = Error{"bang!"}`,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if gotString := tt.snapshot.String(); gotString != tt.wantString {
				t.Errorf("got %v\nwant %v", gotString, tt.wantString)
			}
		})
	}
}

func TestMarshalUnmarshalSnapshot(t *testing.T) {
	tests := []struct {
		name     string
		snapshot *Snapshot
		wantJSON string
		wantBack *Snapshot
	}{
		{
			name:     "Nil",
			snapshot: (*Snapshot)(nil),
			wantJSON: "null",
			wantBack: NewSnapshot(nil),
		},
		{
			name:     "Zero",
			snapshot: &Snapshot{},
			wantJSON: "{}",
		},
		{
			name:     "Bool/True",
			snapshot: NewSnapshot(map[pkey.Key]RawItem{"BoolPolicy": RawItemOf(true)}),
			wantJSON: `{"Settings": {"BoolPolicy": {"Value": true}}}`,
		},
		{
			name:     "Bool/False",
			snapshot: NewSnapshot(map[pkey.Key]RawItem{"BoolPolicy": RawItemOf(false)}),
			wantJSON: `{"Settings": {"BoolPolicy": {"Value": false}}}`,
		},
		{
			name:     "String/Non-Empty",
			snapshot: NewSnapshot(map[pkey.Key]RawItem{"StringPolicy": RawItemOf("StringValue")}),
			wantJSON: `{"Settings": {"StringPolicy": {"Value": "StringValue"}}}`,
		},
		{
			name:     "String/Empty",
			snapshot: NewSnapshot(map[pkey.Key]RawItem{"StringPolicy": RawItemOf("")}),
			wantJSON: `{"Settings": {"StringPolicy": {"Value": ""}}}`,
		},
		{
			name:     "Integer/NonZero",
			snapshot: NewSnapshot(map[pkey.Key]RawItem{"IntPolicy": RawItemOf(uint64(42))}),
			wantJSON: `{"Settings": {"IntPolicy": {"Value": 42}}}`,
		},
		{
			name:     "Integer/Zero",
			snapshot: NewSnapshot(map[pkey.Key]RawItem{"IntPolicy": RawItemOf(uint64(0))}),
			wantJSON: `{"Settings": {"IntPolicy": {"Value": 0}}}`,
		},
		{
			name:     "String-List",
			snapshot: NewSnapshot(map[pkey.Key]RawItem{"ListPolicy": RawItemOf([]string{"Value1", "Value2"})}),
			wantJSON: `{"Settings": {"ListPolicy": {"Value": ["Value1", "Value2"]}}}`,
		},
		{
			name:     "Duration/Zero",
			snapshot: NewSnapshot(map[pkey.Key]RawItem{"DurationPolicy": RawItemOf(time.Duration(0))}),
			wantJSON: `{"Settings": {"DurationPolicy": {"Value": "0s"}}}`,
			wantBack: NewSnapshot(map[pkey.Key]RawItem{"DurationPolicy": RawItemOf("0s")}),
		},
		{
			name:     "Duration/NonZero",
			snapshot: NewSnapshot(map[pkey.Key]RawItem{"DurationPolicy": RawItemOf(2 * time.Hour)}),
			wantJSON: `{"Settings": {"DurationPolicy": {"Value": "2h0m0s"}}}`,
			wantBack: NewSnapshot(map[pkey.Key]RawItem{"DurationPolicy": RawItemOf("2h0m0s")}),
		},
		{
			name: "Empty/With-Summary",
			snapshot: NewSnapshot(
				map[pkey.Key]RawItem{},
				SummaryWith(CurrentUserScope, NewNamedOrigin("TestSource", DeviceScope)),
			),
			wantJSON: `{"Summary": {"Origin": {"Name": "TestSource", "Scope": "Device"}, "Scope": "User"}}`,
		},
		{
			name: "Setting/With-Summary",
			snapshot: NewSnapshot(
				map[pkey.Key]RawItem{"PolicySetting": RawItemOf(uint64(42))},
				SummaryWith(CurrentUserScope, NewNamedOrigin("TestSource", DeviceScope)),
			),
			wantJSON: `{
					"Summary": {"Origin": {"Name": "TestSource", "Scope": "Device"}, "Scope": "User"},
					"Settings": {"PolicySetting": {"Value": 42}}
				}`,
		},
		{
			name: "Settings/With-Origins",
			snapshot: NewSnapshot(
				map[pkey.Key]RawItem{
					"SettingA": RawItemWith(uint64(42), nil, NewNamedOrigin("SourceA", DeviceScope)),
					"SettingB": RawItemWith("B", nil, NewNamedOrigin("SourceB", CurrentProfileScope)),
					"SettingC": RawItemWith(true, nil, NewNamedOrigin("SourceC", CurrentUserScope)),
				},
			),
			wantJSON: `{
					"Settings": {
						"SettingA": {"Value": 42, "Origin": {"Name": "SourceA", "Scope": "Device"}},
						"SettingB": {"Value": "B", "Origin": {"Name": "SourceB", "Scope": "Profile"}},
						"SettingC": {"Value": true, "Origin": {"Name": "SourceC", "Scope": "User"}}
					}
				}`,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			doTest := func(t *testing.T, useJSONv2 bool) {
				var gotJSON []byte
				var err error
				if useJSONv2 {
					gotJSON, err = jsonv2.Marshal(tt.snapshot)
				} else {
					gotJSON, err = json.Marshal(tt.snapshot)
				}
				if err != nil {
					t.Fatal(err)
				}

				if got, want, equal := internal.EqualJSONForTest(t, gotJSON, []byte(tt.wantJSON)); !equal {
					t.Errorf("JSON: got %s; want %s", got, want)
				}

				gotBack := &Snapshot{}
				if useJSONv2 {
					err = jsonv2.Unmarshal(gotJSON, &gotBack)
				} else {
					err = json.Unmarshal(gotJSON, &gotBack)
				}
				if err != nil {
					t.Fatal(err)
				}

				if wantBack := cmp.Or(tt.wantBack, tt.snapshot); !gotBack.Equal(wantBack) {
					t.Errorf("Snapshot: got %+v; want %+v", gotBack, wantBack)
				}
			}

			t.Run("json", func(t *testing.T) { doTest(t, false) })
			t.Run("jsonv2", func(t *testing.T) { doTest(t, true) })
		})
	}
}
