package handlers

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"image"
	"image/png"
	"mime/multipart"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"sync/atomic"
	"testing"
	"time"

	"github.com/gin-gonic/gin"
	"gorm.io/gorm"
	"wa-assistant/backend/models"
	"wa-assistant/backend/services"
)

func statusTestDB(t *testing.T) *gorm.DB {
	t.Helper()
	db := workflowTestDB(t)
	if err := db.AutoMigrate(&models.ScheduledStatus{}, &models.ScheduledMessage{}); err != nil {
		t.Fatal(err)
	}
	return db
}
func statusRequest(t *testing.T, fields map[string]string, file []byte, connected bool) *httptest.ResponseRecorder {
	t.Helper()
	var body bytes.Buffer
	writer := multipart.NewWriter(&body)
	for k, v := range fields {
		if err := writer.WriteField(k, v); err != nil {
			t.Fatal(err)
		}
	}
	if file != nil {
		part, err := writer.CreateFormFile("file", "fixture.mp4")
		if err != nil {
			t.Fatal(err)
		}
		if _, err = part.Write(file); err != nil {
			t.Fatal(err)
		}
	}
	if err := writer.Close(); err != nil {
		t.Fatal(err)
	}
	w := httptest.NewRecorder()
	c, _ := gin.CreateTestContext(w)
	c.Request = httptest.NewRequest("POST", "/api/agents/1/status", &body)
	c.Request.Header.Set("Content-Type", writer.FormDataContentType())
	c.Params = gin.Params{{Key: "id", Value: "1"}}
	c.Set("tenant_id", uint(1))
	c.Set("user_id", uint(7))
	c.Set("role", "admin")
	createStatus(c, func(uint) bool { return connected })
	return w
}
func queuedStatus(t *testing.T, db *gorm.DB, agent uint, runAt time.Time) models.ScheduledStatus {
	t.Helper()
	s := models.ScheduledStatus{TenantID: 1, AgentID: agent, RunAt: runAt, Text: "Promo\nhttps://example.com/beli?a=1&b=2", Status: "scheduled"}
	if err := db.Create(&s).Error; err != nil {
		t.Fatal(err)
	}
	return s
}
func reloadStatus(t *testing.T, db *gorm.DB, s models.ScheduledStatus) models.ScheduledStatus {
	t.Helper()
	if err := db.First(&s, s.ID).Error; err != nil {
		t.Fatal(err)
	}
	return s
}

func TestStatusCreatePersistsVideoCaptionTimezoneAndAsyncNow(t *testing.T) {
	video, err := os.ReadFile("../services/testdata/status/video.mp4")
	if err != nil {
		t.Fatal(err)
	}
	db := statusTestDB(t)
	t.Chdir(t.TempDir())
	caption := "Promo 👋\n\nhttps://example.com/beli?paket=1&ref=wa#harga"
	zone := time.FixedZone("WIB", 7*3600)
	future := time.Now().In(zone).Add(time.Hour).Truncate(time.Second)
	w := statusRequest(t, map[string]string{"text": caption, "run_at": future.Format(time.RFC3339)}, video, false)
	if w.Code != 202 {
		t.Fatalf("create: %d %s", w.Code, w.Body.String())
	}
	var response struct {
		Data models.ScheduledStatus `json:"data"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
		t.Fatal(err)
	}
	s := reloadStatus(t, db, response.Data)
	if s.Text != caption || s.MediaType != "video" || s.Mimetype != "video/mp4" || s.Status != "scheduled" || !s.RunAt.Equal(future) {
		t.Fatalf("lost payload/timezone: %+v", s)
	}
	stored, err := readScheduledStatusMedia(s)
	if err != nil || !bytes.Equal(stored, video) {
		t.Fatalf("stored media changed: %v", err)
	}
	if bytes.Contains(w.Body.Bytes(), []byte("media_path")) {
		t.Fatal("private media path leaked")
	}
	var sent int
	dispatchDueStatuses(context.Background(), db, future.Add(time.Second), func(uint) bool { return false }, func(context.Context, models.ScheduledStatus) error { sent++; return nil })
	if sent != 0 || reloadStatus(t, db, s).Status != "scheduled" {
		t.Fatal("offline status was consumed")
	}
	dispatchDueStatuses(context.Background(), db, future.Add(time.Minute), func(uint) bool { return true }, func(ctx context.Context, got models.ScheduledStatus) error {
		if _, ok := ctx.Deadline(); !ok {
			t.Fatal("send has no timeout")
		}
		if got.Text != caption {
			t.Fatal("caption/link was changed by scheduler")
		}
		data, e := readScheduledStatusMedia(got)
		if e != nil || !bytes.Equal(data, video) {
			t.Fatal("video lost on schedule")
		}
		sent++
		return nil
	})
	if sent != 1 || reloadStatus(t, db, s).Status != "done" {
		t.Fatal("successful status not recorded")
	}
	w = statusRequest(t, map[string]string{"text": "https://example.com"}, nil, true)
	if w.Code != 202 || !strings.Contains(w.Body.String(), `"status":"scheduled"`) {
		t.Fatalf("immediate response falsely claims sent: %d %s", w.Code, w.Body.String())
	}
}

func TestStatusCreateRejectsBadTimeMediaAndStorageFailure(t *testing.T) {
	db := statusTestDB(t)
	t.Chdir(t.TempDir())
	future := time.Now().Add(time.Hour).Format(time.RFC3339)
	for _, tc := range []struct {
		name      string
		fields    map[string]string
		file      []byte
		connected bool
		code      int
	}{
		{"empty", map[string]string{"run_at": future}, nil, true, 400},
		{"past", map[string]string{"text": "test", "run_at": time.Now().Add(-time.Second).Format(time.RFC3339)}, nil, true, 400},
		{"invalid-date", map[string]string{"text": "test", "run_at": "invalid"}, nil, true, 400},
		{"spoofed-file", map[string]string{"text": "test", "run_at": future}, []byte("<html>fake video</html>"), true, 400},
		{"too-long", map[string]string{"text": strings.Repeat("a", services.StatusTextMaxChars+1), "run_at": future}, nil, true, 400},
		{"offline-now", map[string]string{"text": "test"}, nil, false, 409},
	} {
		t.Run(tc.name, func(t *testing.T) {
			w := statusRequest(t, tc.fields, tc.file, tc.connected)
			if w.Code != tc.code {
				t.Fatalf("got %d: %s", w.Code, w.Body.String())
			}
		})
	}
	var count int64
	db.Model(&models.ScheduledStatus{}).Count(&count)
	if count != 0 {
		t.Fatal("invalid status persisted")
	}
	if err := os.WriteFile("data", []byte("block media directory"), 0600); err != nil {
		t.Fatal(err)
	}
	// A valid image that cannot be saved must fail instead of silently becoming text.
	var imageBytes bytes.Buffer
	if err := png.Encode(&imageBytes, image.NewRGBA(image.Rect(0, 0, 80, 60))); err != nil {
		t.Fatal(err)
	}
	w := statusRequest(t, map[string]string{"text": "caption", "run_at": future}, imageBytes.Bytes(), true)
	if w.Code != 500 || !strings.Contains(w.Body.String(), "Media belum berhasil disimpan") {
		t.Fatalf("silent media loss: %d %s", w.Code, w.Body.String())
	}
	db.Model(&models.ScheduledStatus{}).Count(&count)
	if count != 0 {
		t.Fatal("failed media save created status")
	}
}

func TestStatusCancelWinsAfterWorkerReadAndRunningCancelReturnsConflict(t *testing.T) {
	db := statusTestDB(t)
	now := time.Now()
	s := queuedStatus(t, db, 1, now.Add(-time.Minute))
	checks, sent := 0, 0
	dispatchDueStatuses(context.Background(), db, now, func(uint) bool {
		checks++
		if checks == 2 {
			w := workflowRequest(t, CancelStatus, nil, gin.Params{{Key: "id", Value: "1"}, {Key: "sid", Value: strconv.Itoa(int(s.ID))}})
			if w.Code != 200 {
				t.Fatalf("cancel failed: %s", w.Body.String())
			}
		}
		return true
	}, func(context.Context, models.ScheduledStatus) error { sent++; return nil })
	if sent != 0 || reloadStatus(t, db, s).Status != "cancelled" {
		t.Fatal("cancelled story was sent")
	}
	s = queuedStatus(t, db, 1, now.Add(-time.Minute))
	dispatchDueStatuses(context.Background(), db, now, func(uint) bool { return true }, func(context.Context, models.ScheduledStatus) error {
		w := workflowRequest(t, CancelStatus, nil, gin.Params{{Key: "id", Value: "1"}, {Key: "sid", Value: strconv.Itoa(int(s.ID))}})
		if w.Code != 409 {
			t.Fatalf("running cancellation falsely succeeded: %d", w.Code)
		}
		return nil
	})
	if reloadStatus(t, db, s).Status != "done" {
		t.Fatal("running status corrupted by cancellation")
	}
}

func TestStatusConcurrentWorkersDoNotDuplicateAndOfflineBacklogDoesNotStarve(t *testing.T) {
	db := statusTestDB(t)
	now := time.Now()
	s := queuedStatus(t, db, 1, now.Add(-time.Minute))
	started, release, finished := make(chan struct{}), make(chan struct{}), make(chan struct{})
	var sends atomic.Int32
	send := func(context.Context, models.ScheduledStatus) error {
		sends.Add(1)
		close(started)
		<-release
		return nil
	}
	go func() {
		defer close(finished)
		dispatchDueStatuses(context.Background(), db, now, func(uint) bool { return true }, send)
	}()
	select {
	case <-started:
	case <-time.After(5 * time.Second):
		t.Fatal("worker did not start")
	}
	dispatchDueStatuses(context.Background(), db, now, func(uint) bool { return true }, send)
	close(release)
	select {
	case <-finished:
	case <-time.After(5 * time.Second):
		t.Fatal("worker did not finish")
	}
	if sends.Load() != 1 || reloadStatus(t, db, s).Status != "done" {
		t.Fatal("duplicate send")
	}
	for i := 0; i < 101; i++ {
		queuedStatus(t, db, 1, now.Add(-time.Hour))
	}
	healthy := queuedStatus(t, db, 2, now.Add(-time.Minute))
	dispatchDueStatuses(context.Background(), db, now, func(id uint) bool { return id == 2 }, func(context.Context, models.ScheduledStatus) error { return nil })
	if reloadStatus(t, db, healthy).Status != "done" {
		t.Fatal("offline backlog starved healthy sender")
	}
}

func TestStatusFailureInterruptedRestartAndMissingMedia(t *testing.T) {
	db := statusTestDB(t)
	now := time.Now()
	for _, tc := range []struct {
		name, state string
		fail        error
		panic       bool
	}{
		{name: "upload", state: "failed", fail: errors.New("unggahan gagal")},
		{name: "uncertain", state: "interrupted", fail: &services.StatusSendUncertainError{Err: context.DeadlineExceeded}},
		{name: "panic", state: "interrupted", panic: true},
	} {
		s := queuedStatus(t, db, 1, now.Add(-time.Minute))
		calls := 0
		send := func(context.Context, models.ScheduledStatus) error {
			calls++
			if tc.panic {
				panic("fixture")
			}
			return tc.fail
		}
		dispatchDueStatuses(context.Background(), db, now, func(uint) bool { return true }, send)
		dispatchDueStatuses(context.Background(), db, now, func(uint) bool { return true }, send)
		got := reloadStatus(t, db, s)
		if calls != 1 || got.Status != tc.state || got.Error == "" {
			t.Fatalf("%s incorrectly retried/classified: %+v", tc.name, got)
		}
	}
	s := queuedStatus(t, db, 1, now.Add(-time.Minute))
	db.Model(&s).Update("status", "running")
	CleanupStuckSchedules()
	if got := reloadStatus(t, db, s); got.Status != "interrupted" || got.Error == "" {
		t.Fatal("restart not marked for inspection")
	}
	for _, s := range []models.ScheduledStatus{{MediaType: "image", Mimetype: "image/png"}, {MediaType: "video", MediaPath: filepath.Join(t.TempDir(), "missing.mp4")}, {MediaPath: "bad"}} {
		if _, err := readScheduledStatusMedia(s); err == nil {
			t.Fatalf("missing media became text: %+v", s)
		}
	}
}

func TestStatusDisconnectAfterClaimKeepsQueueAndFutureIsUntouched(t *testing.T) {
	db := statusTestDB(t)
	now := time.Now()
	s := queuedStatus(t, db, 1, now.Add(-time.Minute))
	future := queuedStatus(t, db, 1, now.Add(time.Hour))
	checks := 0
	dispatchDueStatuses(context.Background(), db, now, func(uint) bool { checks++; return checks != 3 }, func(context.Context, models.ScheduledStatus) error { return fmt.Errorf("must not send") })
	if reloadStatus(t, db, s).Status != "scheduled" || reloadStatus(t, db, future).Status != "scheduled" {
		t.Fatal("offline/future status consumed")
	}
}

func TestStatusScheduledImageAndLinkOnlyKeepTheirPayload(t *testing.T) {
	db := statusTestDB(t)
	t.Chdir(t.TempDir())
	var photo bytes.Buffer
	if err := png.Encode(&photo, image.NewRGBA(image.Rect(0, 0, 80, 60))); err != nil {
		t.Fatal(err)
	}
	for _, tc := range []struct {
		name, text, kind string
		file             []byte
	}{
		{name: "photo-caption-link", text: "Foto promo\nhttps://example.com/promo?ref=wa", kind: "image", file: photo.Bytes()},
		{name: "link-only", text: "https://example.com/promo?a=1&b=2#beli"},
	} {
		future := time.Now().Add(time.Hour).Truncate(time.Second)
		w := statusRequest(t, map[string]string{"text": tc.text, "run_at": future.Format(time.RFC3339)}, tc.file, true)
		if w.Code != 202 {
			t.Fatalf("%s: %d %s", tc.name, w.Code, w.Body.String())
		}
		var response struct {
			Data models.ScheduledStatus `json:"data"`
		}
		if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
			t.Fatal(err)
		}
		dispatchDueStatuses(context.Background(), db, future.Add(time.Second), func(uint) bool { return true }, func(_ context.Context, s models.ScheduledStatus) error {
			if s.ID != response.Data.ID || s.Text != tc.text || s.MediaType != tc.kind {
				t.Fatalf("payload changed: %+v", s)
			}
			data, err := readScheduledStatusMedia(s)
			if err != nil || !bytes.Equal(data, tc.file) {
				t.Fatalf("media changed: %v", err)
			}
			return nil
		})
		if reloadStatus(t, db, response.Data).Status != "done" {
			t.Fatal("not marked done")
		}
	}
}

func TestStatusMultipartNewlinesUseEditorCharacterCount(t *testing.T) {
	db := statusTestDB(t)
	text := strings.Repeat("a\r\n", 349) + "b"
	w := statusRequest(t, map[string]string{"text": text, "run_at": time.Now().Add(time.Hour).Format(time.RFC3339)}, nil, true)
	if w.Code != 202 {
		t.Fatalf("browser newlines changed the limit: %d %s", w.Code, w.Body.String())
	}
	var s models.ScheduledStatus
	if err := db.First(&s).Error; err != nil {
		t.Fatal(err)
	}
	if strings.Contains(s.Text, "\r") || s.Text != strings.ReplaceAll(text, "\r\n", "\n") {
		t.Fatal("newlines were not normalized")
	}
}
