// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package actions

import (
	"testing"
	"time"

	"forgejo.org/modules/optional"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestActionScheduleSpec_NewActionScheduleSpec(t *testing.T) {
	tests := []struct {
		name        string
		refTime     time.Time
		cronPattern string
		timeZone    string
		want        string
		wantErr     assert.ErrorAssertionFunc
	}{
		{
			name:        "without timezone",
			refTime:     time.Date(2026, 4, 6, 11, 56, 0, 0, time.UTC),
			cronPattern: "58 14 * * *",
			want:        "2026-04-06T14:58:00Z",
			wantErr:     assert.NoError,
		},
		{
			name:        "with separate timezone",
			refTime:     time.Date(2026, 4, 6, 11, 56, 0, 0, time.UTC),
			cronPattern: "58 14 * * *",
			timeZone:    "Europe/Tallinn", // +03 (EEST)
			want:        "2026-04-06T11:58:00Z",
			wantErr:     assert.NoError,
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			s, err := NewActionScheduleSpec(test.cronPattern, optional.FromNonDefault(test.timeZone), test.refTime)
			test.wantErr(t, err)

			if err == nil {
				assert.Equal(t, test.want, s.Next.AsTime().UTC().Format(time.RFC3339))
			}
		})
	}
}

func TestActionScheduleSpec_Parse(t *testing.T) {
	// Mock the local timezone is not UTC
	local := time.Local
	tz, err := time.LoadLocation("Asia/Shanghai")
	require.NoError(t, err)
	defer func() {
		time.Local = local
	}()
	time.Local = tz

	tests := []struct {
		name     string
		refTime  time.Time
		spec     string
		timeZone string
		want     string
		wantErr  assert.ErrorAssertionFunc
	}{
		{
			name:    "regular",
			refTime: time.Date(2024, 7, 31, 15, 47, 55, 0, time.Local),
			spec:    "0 10 * * *",
			want:    "2024-07-31T10:00:00Z",
			wantErr: assert.NoError,
		},
		{
			name:    "invalid",
			refTime: time.Date(2024, 7, 31, 15, 47, 55, 0, time.Local),
			spec:    "0 10 * *",
			want:    "",
			wantErr: assert.Error,
		},
		{
			name:    "with TZ in cron schedule",
			refTime: time.Date(2024, 7, 31, 15, 47, 55, 0, time.Local),
			spec:    "TZ=America/New_York 0 10 * * *",
			want:    "2024-07-31T14:00:00Z",
			wantErr: assert.NoError,
		},
		{
			name:    "with CRON_TZ in cron schedule",
			refTime: time.Date(2024, 7, 31, 15, 47, 55, 0, time.Local),
			spec:    "CRON_TZ=America/New_York 0 10 * * *",
			want:    "2024-07-31T14:00:00Z",
			wantErr: assert.NoError,
		},
		{
			name:     "with separate time zone",
			refTime:  time.Date(2024, 7, 31, 15, 47, 55, 0, time.Local),
			spec:     "0 10 * * *",
			timeZone: "America/New_York",
			want:     "2024-07-31T14:00:00Z",
			wantErr:  assert.NoError,
		},
		{
			name:     "separate time zone takes precedence over inlined time zone",
			refTime:  time.Date(2024, 7, 31, 15, 47, 55, 0, time.Local),
			spec:     "CRON_TZ=Europe/Berlin 0 10 * * *",
			timeZone: "America/New_York",
			want:     "2024-07-31T14:00:00Z",
			wantErr:  assert.NoError,
		},
		{
			name:    "time zone irrelevant",
			refTime: time.Date(2024, 7, 31, 15, 47, 55, 0, time.Local),
			spec:    "@every 5m",
			want:    "2024-07-31T07:52:55Z",
			wantErr: assert.NoError,
		},
		{
			// The various cron implementations handle the DST jump forwards differently. The most popular approaches
			// are (a) scheduling all jobs at 3 o'clock that were supposed to run between 2 and 3 o'clock, or (b)
			// skipping the execution on that day because any time between 2 and 3 o'clock never happened. Forgejo uses
			// option B because the code it inherited already did that and was exposed to users.
			name:     "skips execution during DST jump forwards",
			refTime:  time.Date(2025, 3, 30, 0, 55, 0, 0, time.UTC), // 01:55 local time
			spec:     "10 2 * * *",                                  // The clock jumps at 2 o'clock to 3 o'clock.
			timeZone: "Europe/Berlin",
			want:     "2025-03-31T00:10:00Z",
			wantErr:  assert.NoError,
		},
		{
			name:     "executes a first time before DST jump backwards",
			refTime:  time.Date(2025, 10, 26, 0, 5, 0, 0, time.UTC), // 02:05 local time
			spec:     "10 2 * * *",                                  // The clock jumps at 3 o'clock to 2 o'clock.
			timeZone: "Europe/Berlin",
			want:     "2025-10-26T00:10:00Z",
			wantErr:  assert.NoError,
		},
		{
			name:     "executes a second time after DST jump backwards",
			refTime:  time.Date(2025, 10, 26, 1, 5, 0, 0, time.UTC), // 02:05 local time
			spec:     "10 2 * * *",                                  // The clock jumps at 3 o'clock to 2 o'clock.
			timeZone: "Europe/Berlin",
			want:     "2025-10-26T01:10:00Z",
			wantErr:  assert.NoError,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			s := &ActionScheduleSpec{
				Spec:     tt.spec,
				TimeZone: optional.FromNonDefault(tt.timeZone),
			}
			got, err := s.Parse()
			tt.wantErr(t, err)

			if err == nil {
				assert.Equal(t, tt.want, got.Next(tt.refTime).UTC().Format(time.RFC3339))
			}
		})
	}
}
