package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"sync"
	"testing"
	"time"

	"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/config"
	"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/healthz"
	"git.tswf.io/infra/go-synapse-backupper/pkg/domain"
)

type fakeScheduler struct {
	mu          sync.Mutex
	startCalled bool
	stopCalled  bool
	stoppedCtx  context.Context
	stopCancel  context.CancelFunc
}

func (f *fakeScheduler) Start() {
	f.mu.Lock()
	f.startCalled = true
	f.mu.Unlock()
}

func (f *fakeScheduler) Stop() context.Context {
	f.mu.Lock()
	f.stopCalled = true
	f.mu.Unlock()
	return f.stoppedCtx
}

func (f *fakeScheduler) wasStarted() bool {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.startCalled
}

func (f *fakeScheduler) wasStopped() bool {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.stopCalled
}

type exitPanic int

func (e exitPanic) Error() string { return fmt.Sprintf("exit %d", int(e)) }

func TestRunCmd_Use(t *testing.T) {
	if runCmd.Use != "run" {
		t.Errorf("runCmd.Use = %q, want %q", runCmd.Use, "run")
	}
}

func TestRunCmd_SIGTERM_stopsScheduler(t *testing.T) {
	origNewScheduler := newScheduler
	origOsExit := osExitFunc
	origRunOnce := runOnceFunc
	defer func() {
		newScheduler = origNewScheduler
		osExitFunc = origOsExit
		runOnceFunc = origRunOnce
	}()

	fake := &fakeScheduler{}
	fake.stoppedCtx, fake.stopCancel = context.WithCancel(context.Background())

	newScheduler = func(expr string, job func()) (domain.Scheduler, error) {
		return fake, nil
	}

	runOnceFunc = func(ctx context.Context, cfg *config.Config) error {
		return nil
	}

	var exitCode int
	exitPanicked := false
	osExitFunc = func(code int) {
		exitCode = code
		exitPanicked = true
		panic(exitPanic(code))
	}

	cfg := &config.Config{}
	cfg.Healthz.Port = 0
	cfg.Backup.Cron = "* * * * *"
	cfg.ShutdownTimeout = 100 * time.Millisecond

	ctx, cancel := context.WithCancel(context.Background())

	errCh := make(chan error, 1)
	go func() {
		defer func() {
			if r := recover(); r != nil {
				if _, ok := r.(exitPanic); ok {
					errCh <- nil
					return
				}
				errCh <- fmt.Errorf("unexpected panic: %v", r)
				return
			}
		}()
		errCh <- runWithConfig(ctx, cfg)
	}()

	time.Sleep(50 * time.Millisecond)
	if !fake.wasStarted() {
		t.Fatal("scheduler.Start was not called")
	}

	cancel()
	go func() {
		time.Sleep(50 * time.Millisecond)
		fake.stopCancel()
	}()

	select {
	case err := <-errCh:
		if err != nil {
			t.Fatalf("runWithConfig returned error: %v", err)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("timeout waiting for runWithConfig to exit")
	}

	if !fake.wasStopped() {
		t.Error("scheduler.Stop was not called")
	}

	if exitPanicked {
		t.Errorf("unexpected os.Exit call with code %d", exitCode)
	}
}

func TestRunCmd_SIGTERM_forcesExitAfterTimeout(t *testing.T) {
	origNewScheduler := newScheduler
	origOsExit := osExitFunc
	origNewHealthz := newHealthz
	origRunOnce := runOnceFunc
	defer func() {
		newScheduler = origNewScheduler
		osExitFunc = origOsExit
		newHealthz = origNewHealthz
		runOnceFunc = origRunOnce
	}()

	fake := &fakeScheduler{}
	var cancelFunc context.CancelFunc
	fake.stoppedCtx, cancelFunc = context.WithCancel(context.Background())
	defer cancelFunc()

	newScheduler = func(expr string, job func()) (domain.Scheduler, error) {
		return fake, nil
	}

	newHealthz = func(port int) (healthz.Server, error) {
		return healthz.New(port)
	}

	runOnceFunc = func(ctx context.Context, cfg *config.Config) error {
		return nil
	}

	var exitCode int
	exitPanicked := false
	osExitFunc = func(code int) {
		exitCode = code
		exitPanicked = true
		panic(exitPanic(code))
	}

	cfg := &config.Config{}
	cfg.Healthz.Port = 0
	cfg.Backup.Cron = "* * * * * *"
	cfg.ShutdownTimeout = 100 * time.Millisecond

	ctx, cancel := context.WithCancel(context.Background())

	errCh := make(chan error, 1)
	go func() {
		defer func() {
			if r := recover(); r != nil {
				if _, ok := r.(exitPanic); ok {
					errCh <- nil
					return
				}
				errCh <- fmt.Errorf("unexpected panic: %v", r)
				return
			}
		}()
		errCh <- runWithConfig(ctx, cfg)
	}()

	time.Sleep(50 * time.Millisecond)
	cancel()

	select {
	case err := <-errCh:
		if err != nil {
			t.Fatalf("runWithConfig returned error: %v", err)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("timeout waiting for runWithConfig to exit")
	}

	if !fake.wasStopped() {
		t.Error("scheduler.Stop was not called")
	}

	if !exitPanicked {
		t.Fatal("expected os.Exit to be called")
	}

	if exitCode != 0 {
		t.Errorf("os.Exit code = %d, want 0", exitCode)
	}
}

func TestRunCmd_Healthz503DuringShutdown(t *testing.T) {
	origNewScheduler := newScheduler
	origOsExit := osExitFunc
	origNewHealthz := newHealthz
	origRunOnce := runOnceFunc
	defer func() {
		newScheduler = origNewScheduler
		osExitFunc = origOsExit
		newHealthz = origNewHealthz
		runOnceFunc = origRunOnce
	}()

	fake := &fakeScheduler{}
	fake.stoppedCtx, fake.stopCancel = context.WithCancel(context.Background())

	newScheduler = func(expr string, job func()) (domain.Scheduler, error) {
		return fake, nil
	}

	srv, err := healthz.New(0)
	if err != nil {
		t.Fatalf("failed to create healthz server: %v", err)
	}

	newHealthz = func(port int) (healthz.Server, error) {
		return srv, nil
	}

	runOnceFunc = func(ctx context.Context, cfg *config.Config) error {
		return nil
	}

	osExitFunc = func(code int) { panic(exitPanic(code)) }

	cfg := &config.Config{}
	cfg.Healthz.Port = 0
	cfg.Backup.Cron = "* * * * * *"
	cfg.ShutdownTimeout = 5 * time.Second

	ctx, cancel := context.WithCancel(context.Background())

	errCh := make(chan error, 1)
	go func() {
		defer func() {
			if r := recover(); r != nil {
				if _, ok := r.(exitPanic); ok {
					errCh <- nil
					return
				}
				errCh <- fmt.Errorf("unexpected panic: %v", r)
				return
			}
		}()
		errCh <- runWithConfig(ctx, cfg)
	}()

	time.Sleep(100 * time.Millisecond)

	addr := srv.Addr()
	resp, err := http.Get("http://" + addr + "/healthz")
	if err != nil {
		t.Fatalf("healthz request failed: %v", err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("healthz status before shutdown = %d, want %d", resp.StatusCode, http.StatusOK)
	}

	cancel()

	var status503 bool
	for iteration := 0; iteration < 20; iteration++ {
		resp, err = http.Get("http://" + addr + "/healthz")
		if err == nil {
			resp.Body.Close()
			if resp.StatusCode == http.StatusServiceUnavailable {
				status503 = true
				break
			}
		}
		time.Sleep(10 * time.Millisecond)
	}
	if !status503 {
		t.Error("did not observe /healthz returning 503 during shutdown")
	}

	fake.stopCancel()

	select {
	case err := <-errCh:
		if err != nil {
			t.Fatalf("runWithConfig returned error: %v", err)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("timeout waiting for runWithConfig to exit")
	}
}

func TestRunCmd_HealthzBindFailure(t *testing.T) {
	origNewScheduler := newScheduler
	origNewHealthz := newHealthz
	origOsExit := osExitFunc
	origRunOnce := runOnceFunc
	defer func() {
		newScheduler = origNewScheduler
		newHealthz = origNewHealthz
		osExitFunc = origOsExit
		runOnceFunc = origRunOnce
	}()

	fake := &fakeScheduler{}
	var cancelFunc context.CancelFunc
	fake.stoppedCtx, cancelFunc = context.WithCancel(context.Background())
	defer cancelFunc()

	newScheduler = func(expr string, job func()) (domain.Scheduler, error) {
		return fake, nil
	}

	newHealthz = func(port int) (healthz.Server, error) {
		return nil, errors.New("bind failed")
	}

	runOnceFunc = func(ctx context.Context, cfg *config.Config) error {
		return nil
	}

	var exitCode int
	exitPanicked := false
	osExitFunc = func(code int) {
		exitCode = code
		exitPanicked = true
		panic(exitPanic(code))
	}

	cfg := &config.Config{}
	cfg.Backup.Cron = "* * * * * *"

	errCh := make(chan error, 1)
	go func() {
		defer func() {
			if r := recover(); r != nil {
				if _, ok := r.(exitPanic); ok {
					errCh <- nil
					return
				}
				errCh <- fmt.Errorf("unexpected panic: %v", r)
				return
			}
		}()
		errCh <- runWithConfig(context.Background(), cfg)
	}()

	select {
	case err := <-errCh:
		if err != nil {
			t.Fatalf("runWithConfig returned error: %v", err)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("timeout waiting for runWithConfig to exit")
	}

	if !exitPanicked {
		t.Fatal("expected os.Exit to be called on healthz bind failure")
	}

	if exitCode != 1 {
		t.Errorf("os.Exit code = %d, want 1", exitCode)
	}
}
