package app

import (
	"database/sql"
	"reflect"
	"testing"
	"time"
)

// helperTestStruct mirrors the shape of the domain structs GetFieldsAndValues
// is used against: an ID field that must always be excluded, plus a mix of
// field types (string, int, bool, pointer) carrying unrelated db/json struct
// tags. GetFieldsAndValues keys off reflect field Name, not struct tags, so
// these tags exist purely to prove they have no effect on the result.
type helperTestStruct struct {
	ID     int64  `db:"id" json:"id"`
	Name   string `db:"name" json:"full_name"`
	Age    int    `db:"age" json:"years"`
	Active bool   `db:"active" json:"is_active"`
	Score  *float64
}

func TestGetFieldsAndValues(t *testing.T) {
	t.Run("excludes ID and zero-value fields, keeps struct tags out of the result", func(t *testing.T) {
		obj := &helperTestStruct{
			ID:     100,
			Name:   "Alice",
			Age:    30,
			Active: true,
			Score:  nil,
		}

		got := GetFieldsAndValues(obj)

		wantNames := []string{"Name", "Age", "Active"}
		if !reflect.DeepEqual(got.fieldNames, wantNames) {
			t.Fatalf("fieldNames = %v, want %v", got.fieldNames, wantNames)
		}

		wantValues := []interface{}{"Alice", 30, true}
		if !reflect.DeepEqual(got.fieldValues, wantValues) {
			t.Fatalf("fieldValues = %v, want %v", got.fieldValues, wantValues)
		}

		for _, name := range got.fieldNames {
			if name == "ID" {
				t.Fatalf("ID field must never be included, got names %v", got.fieldNames)
			}
			// Struct tags on this type use snake_case/alternate names
			// (e.g. "full_name", "is_active"); confirm none of those
			// leaked into the result in place of the Go field name.
			if name == "full_name" || name == "is_active" || name == "years" {
				t.Fatalf("result used a struct tag name instead of the field name: %v", got.fieldNames)
			}
		}
	})

	t.Run("ID excluded even when non-zero and all other fields zero", func(t *testing.T) {
		obj := &helperTestStruct{ID: 42}

		got := GetFieldsAndValues(obj)

		// Name is a string field: per IsZero's documented behavior, strings
		// (including "") are never considered zero, so it is always kept.
		wantNames := []string{"Name"}
		if !reflect.DeepEqual(got.fieldNames, wantNames) {
			t.Fatalf("fieldNames = %v, want %v", got.fieldNames, wantNames)
		}

		wantValues := []interface{}{""}
		if !reflect.DeepEqual(got.fieldValues, wantValues) {
			t.Fatalf("fieldValues = %v, want %v", got.fieldValues, wantValues)
		}
	})

	t.Run("non-nil pointer to a zero value is treated as zero and excluded", func(t *testing.T) {
		zero := 0.0
		obj := &helperTestStruct{Name: "x", Score: &zero}

		got := GetFieldsAndValues(obj)

		for _, name := range got.fieldNames {
			if name == "Score" {
				t.Fatalf("Score pointing at a zero float64 should be excluded, got names %v", got.fieldNames)
			}
		}
	})

	t.Run("non-nil pointer to a non-zero value is included as the pointer itself", func(t *testing.T) {
		score := 87.5
		obj := &helperTestStruct{Name: "x", Score: &score}

		got := GetFieldsAndValues(obj)

		idx := -1
		for i, name := range got.fieldNames {
			if name == "Score" {
				idx = i
			}
		}
		if idx == -1 {
			t.Fatalf("expected Score field to be included, got names %v", got.fieldNames)
		}

		gotPtr, ok := got.fieldValues[idx].(*float64)
		if !ok {
			t.Fatalf("Score value has type %T, want *float64", got.fieldValues[idx])
		}
		if gotPtr != &score {
			t.Fatalf("Score value pointer = %p, want the original field pointer %p", gotPtr, &score)
		}
		if *gotPtr != score {
			t.Fatalf("Score value = %v, want %v", *gotPtr, score)
		}
	})

	t.Run("empty struct fields collapse to an empty result besides always-kept strings", func(t *testing.T) {
		obj := &helperTestStruct{}

		got := GetFieldsAndValues(obj)

		wantNames := []string{"Name"}
		if !reflect.DeepEqual(got.fieldNames, wantNames) {
			t.Fatalf("fieldNames = %v, want %v", got.fieldNames, wantNames)
		}
	})
}

func TestIsZero(t *testing.T) {
	// numericOnly has no string fields, so its zero value genuinely trips
	// the struct-recursion base case in IsZero.
	type numericOnly struct {
		A int
		B bool
	}
	// nested carries a string field, which per IsZero's documented
	// "strings are never zero" rule makes the whole struct read as
	// non-zero even when every field is at its zero value.
	type nested struct {
		A int
		B string
	}

	zero := 0
	nonZero := 5

	tests := []struct {
		name string
		v    reflect.Value
		want bool
	}{
		{"empty string is treated as non-zero", reflect.ValueOf(""), false},
		{"non-empty string is non-zero", reflect.ValueOf("hi"), false},
		{"zero int is zero", reflect.ValueOf(0), true},
		{"non-zero int is non-zero", reflect.ValueOf(7), false},
		{"false bool is zero", reflect.ValueOf(false), true},
		{"true bool is non-zero", reflect.ValueOf(true), false},
		{"zero float64 is zero", reflect.ValueOf(0.0), true},
		{"non-zero float64 is non-zero", reflect.ValueOf(1.5), false},
		{"nil slice is zero (len 0)", reflect.ValueOf([]int(nil)), true},
		{"empty non-nil slice is zero (len 0)", reflect.ValueOf([]int{}), true},
		{"non-empty slice is non-zero even if elements are zero", reflect.ValueOf([]int{0}), false},
		{"zero-length array is zero", reflect.ValueOf([0]int{}), true},
		{"non-zero-length array of zero elements is non-zero (len-based check)", reflect.ValueOf([3]int{0, 0, 0}), false},
		{"zero-value struct with no string fields is zero", reflect.ValueOf(numericOnly{}), true},
		{"struct with any non-zero field is non-zero", reflect.ValueOf(numericOnly{A: 1}), false},
		{"struct containing a string field (even empty) is always non-zero", reflect.ValueOf(nested{}), false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := IsZero(tt.v); got != tt.want {
				t.Errorf("IsZero(%v) = %v, want %v", tt.v, got, tt.want)
			}
		})
	}

	t.Run("nil pointer is zero", func(t *testing.T) {
		var p *int
		if got := IsZero(reflect.ValueOf(p)); got != true {
			t.Errorf("IsZero(nil *int) = %v, want true", got)
		}
	})

	t.Run("non-nil pointer to zero value is zero", func(t *testing.T) {
		p := &zero
		if got := IsZero(reflect.ValueOf(p)); got != true {
			t.Errorf("IsZero(&0) = %v, want true", got)
		}
	})

	t.Run("non-nil pointer to non-zero value is non-zero", func(t *testing.T) {
		p := &nonZero
		if got := IsZero(reflect.ValueOf(p)); got != false {
			t.Errorf("IsZero(&5) = %v, want false", got)
		}
	})

	t.Run("nil interface is zero", func(t *testing.T) {
		var i interface{}
		v := reflect.ValueOf(&i).Elem()
		if got := IsZero(v); got != true {
			t.Errorf("IsZero(nil interface) = %v, want true", got)
		}
	})

	t.Run("interface holding a zero value is zero", func(t *testing.T) {
		var i interface{} = 0
		v := reflect.ValueOf(&i).Elem()
		if got := IsZero(v); got != true {
			t.Errorf("IsZero(interface holding 0) = %v, want true", got)
		}
	})

	t.Run("interface holding a non-zero value is non-zero", func(t *testing.T) {
		var i interface{} = 9
		v := reflect.ValueOf(&i).Elem()
		if got := IsZero(v); got != false {
			t.Errorf("IsZero(interface holding 9) = %v, want false", got)
		}
	})
}

func TestSqlDateToString(t *testing.T) {
	t.Run("nil pointer returns nil", func(t *testing.T) {
		if got := SqlDateToString(nil); got != nil {
			t.Fatalf("SqlDateToString(nil) = %v, want nil", got)
		}
	})

	t.Run("invalid NullTime returns nil", func(t *testing.T) {
		d := &sql.NullTime{Valid: false, Time: time.Date(2024, 3, 5, 9, 15, 30, 0, time.UTC)}
		if got := SqlDateToString(d); got != nil {
			t.Fatalf("SqlDateToString(invalid) = %v, want nil", got)
		}
	})

	t.Run("valid NullTime in UTC formats with the dateTimeFormat literal offset suffix", func(t *testing.T) {
		d := &sql.NullTime{Valid: true, Time: time.Date(2024, 3, 5, 9, 15, 30, 0, time.UTC)}
		got := SqlDateToString(d)
		if got == nil {
			t.Fatal("SqlDateToString(valid) = nil, want non-nil")
		}
		want := "2024-03-05T09:15:30-0800"
		if *got != want {
			t.Fatalf("SqlDateToString(valid) = %q, want %q", *got, want)
		}
	})

	t.Run("valid NullTime in a different zone still carries the literal -0800 suffix", func(t *testing.T) {
		// dateTimeFormat's "-0800" is not a recognized Go reference-time zone
		// token (the reference offset is -0700), so Format treats it as a
		// literal suffix rather than the Time's actual UTC offset. This test
		// pins down that existing behavior rather than "correct" tz math.
		est := time.FixedZone("EST", -5*3600)
		d := &sql.NullTime{Valid: true, Time: time.Date(2024, 3, 5, 9, 15, 30, 0, est)}
		got := SqlDateToString(d)
		if got == nil {
			t.Fatal("SqlDateToString(valid) = nil, want non-nil")
		}
		want := "2024-03-05T09:15:30-0800"
		if *got != want {
			t.Fatalf("SqlDateToString(valid, EST) = %q, want %q", *got, want)
		}
	})
}

func TestParseDateTime(t *testing.T) {
	t.Run("nil pointer returns an error", func(t *testing.T) {
		got, err := ParseDateTime(nil)
		if err == nil {
			t.Fatal("ParseDateTime(nil) error = nil, want error")
		}
		if got != nil {
			t.Fatalf("ParseDateTime(nil) time = %v, want nil", got)
		}
	})

	t.Run("empty string returns an error", func(t *testing.T) {
		s := ""
		if _, err := ParseDateTime(&s); err == nil {
			t.Fatal("ParseDateTime(\"\") error = nil, want error")
		}
	})

	t.Run("unparseable string returns an error", func(t *testing.T) {
		s := "not-a-date"
		if _, err := ParseDateTime(&s); err == nil {
			t.Fatal("ParseDateTime(garbage) error = nil, want error")
		}
	})

	t.Run("dateFormat (date only)", func(t *testing.T) {
		s := "2024-01-15"
		got, err := ParseDateTime(&s)
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		assertDateTimeParts(t, got, 2024, time.January, 15, 0, 0, 0)
	})

	t.Run("dateTimeFormat requires the literal -0800 suffix", func(t *testing.T) {
		s := "2024-01-15T10:30:00-0800"
		got, err := ParseDateTime(&s)
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		assertDateTimeParts(t, got, 2024, time.January, 15, 10, 30, 0)
	})

	t.Run("a differently-offset string that matches no supported format errors", func(t *testing.T) {
		// -0700 does not literal-match dateTimeFormat's "-0800" suffix, has
		// no weekday/month name for RFC1123, and lacks the colon RFC3339
		// requires in its offset - so it matches none of the four formats.
		s := "2024-01-15T10:30:00-0700"
		if _, err := ParseDateTime(&s); err == nil {
			t.Fatal("expected error for an offset not matching any supported format")
		}
	})

	t.Run("RFC1123", func(t *testing.T) {
		s := "Mon, 15 Jan 2024 15:04:05 UTC"
		got, err := ParseDateTime(&s)
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		assertDateTimeParts(t, got, 2024, time.January, 15, 15, 4, 5)
	})

	t.Run("RFC3339 with Z", func(t *testing.T) {
		s := "2024-01-15T10:30:00Z"
		got, err := ParseDateTime(&s)
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		assertDateTimeParts(t, got, 2024, time.January, 15, 10, 30, 0)
	})

	t.Run("RFC3339 with a colon offset", func(t *testing.T) {
		s := "2024-01-15T10:30:00+05:00"
		got, err := ParseDateTime(&s)
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		assertDateTimeParts(t, got, 2024, time.January, 15, 10, 30, 0)

		_, offset := got.Zone()
		if offset != 5*3600 {
			t.Fatalf("offset = %d seconds, want %d seconds", offset, 5*3600)
		}
	})
}

// assertDateTimeParts checks the wall-clock components a caller actually
// wrote in the input string. It deliberately avoids asserting on
// time.Time's Location, since ParseDateTime falls back to time.Local for
// formats with no zone info in the string, and that varies by host/CI
// timezone configuration - the numeric components do not.
func assertDateTimeParts(t *testing.T, got *time.Time, year int, month time.Month, day, hour, min, sec int) {
	t.Helper()
	if got == nil {
		t.Fatal("parsed time is nil")
	}
	if got.Year() != year || got.Month() != month || got.Day() != day {
		t.Fatalf("date = %04d-%02d-%02d, want %04d-%02d-%02d", got.Year(), got.Month(), got.Day(), year, month, day)
	}
	if got.Hour() != hour || got.Minute() != min || got.Second() != sec {
		t.Fatalf("time = %02d:%02d:%02d, want %02d:%02d:%02d", got.Hour(), got.Minute(), got.Second(), hour, min, sec)
	}
}
