package main

import (
	"bytes"
	"context"
	"encoding/json"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"

	"evening_detective_server/internal/modules/scenario_archive"
	proto "evening_detective_server/proto"

	"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
	"google.golang.org/genproto/googleapis/api/httpbody"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/status"
)

// testStub — минимальный gRPC-сервер, реализующий архивные и файловые RPC.
type testStub struct {
	proto.UnimplementedEveningDetectiveServerServer

	mu          sync.Mutex
	uploaded    []byte
	uploadErr   error
	downloadErr error
}

func (s *testStub) UploadScenarioArchive(_ context.Context, req *httpbody.HttpBody) (*proto.UploadScenarioArchiveRsp, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.uploaded = append([]byte(nil), req.Data...)
	return &proto.UploadScenarioArchiveRsp{Id: 42}, nil
}

func (s *testStub) DownloadScenarioArchive(ctx context.Context, _ *proto.DownloadScenarioArchiveReq) (*httpbody.HttpBody, error) {
	_ = grpc.SetHeader(ctx, metadata.Pairs("Content-Disposition", `attachment; filename="test.zip"`))
	return &httpbody.HttpBody{
		Data:        []byte("zip-data"),
		ContentType: "application/zip",
	}, nil
}

func (s *testStub) UploadFile(_ context.Context, req *proto.UploadFileReq) (*proto.UploadFileRsp, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.uploadErr != nil {
		return nil, s.uploadErr
	}
	s.uploaded = append([]byte(nil), req.Data...)
	return &proto.UploadFileRsp{Filename: "stored.bin", FileType: "pdf"}, nil
}

func (s *testStub) DownloadFile(ctx context.Context, _ *proto.DownloadFileReq) (*httpbody.HttpBody, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.downloadErr != nil {
		return nil, s.downloadErr
	}
	_ = grpc.SetHeader(ctx, metadata.Pairs("X-Content-Type-Options", "nosniff"))
	return &httpbody.HttpBody{
		Data:        []byte("file-data"),
		ContentType: "application/pdf",
	}, nil
}

func (s *testStub) getUploaded() []byte {
	s.mu.Lock()
	defer s.mu.Unlock()
	return append([]byte(nil), s.uploaded...)
}

// newTestGateway поднимает gRPC-сервер со stub и grpc-gateway с теми же
// опциями, что и в main.go (outgoing matcher, error handler, лимиты,
// rawBodyMarshaler).
func newTestGateway(t *testing.T, stub proto.EveningDetectiveServerServer) *httptest.Server {
	return newTestGatewayWithLimit(t, stub, maxFileUploadBody)
}

func newTestGatewayWithLimit(t *testing.T, stub proto.EveningDetectiveServerServer, uploadLimit int64) *httptest.Server {
	t.Helper()

	lis, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("listen: %v", err)
	}
	gs := grpc.NewServer(grpc.MaxRecvMsgSize(grpcMsgLimit))
	proto.RegisterEveningDetectiveServerServer(gs, stub)
	go func() { _ = gs.Serve(lis) }()
	t.Cleanup(gs.Stop)

	conn, err := grpc.NewClient(
		lis.Addr().String(),
		grpc.WithTransportCredentials(insecure.NewCredentials()),
		grpc.WithDefaultCallOptions(
			grpc.MaxCallSendMsgSize(grpcMsgLimit),
			grpc.MaxCallRecvMsgSize(grpcMsgLimit),
		),
	)
	if err != nil {
		t.Fatalf("dial: %v", err)
	}
	t.Cleanup(func() { _ = conn.Close() })

	mux := runtime.NewServeMux(
		runtime.WithOutgoingHeaderMatcher(func(key string) (string, bool) {
			switch {
			case strings.EqualFold(key, "Content-Disposition"):
				return "Content-Disposition", true
			case strings.EqualFold(key, "X-Content-Type-Options"):
				return "X-Content-Type-Options", true
			}
			return runtime.DefaultHeaderMatcher(key)
		}),
		runtime.WithErrorHandler(customErrorHandler),
		runtime.WithMarshalerOption("application/zip", &rawBodyMarshaler{
			Marshaler: &runtime.HTTPBodyMarshaler{Marshaler: &runtime.JSONPb{}},
		}),
	)
	if err := proto.RegisterEveningDetectiveServerHandler(context.Background(), mux, conn); err != nil {
		t.Fatalf("register gateway: %v", err)
	}
	ts := httptest.NewServer(limitUploadBody(
		"/api/files/upload",
		uploadLimit,
		limitUploadBody("/api/scenarios/archive", int64(scenario_archive.MaxArchiveSize()), mux),
	))
	t.Cleanup(ts.Close)
	return ts
}

func TestGatewayUploadRawZip(t *testing.T) {
	stub := &testStub{}
	ts := newTestGateway(t, stub)

	raw := []byte("PK\x03\x04raw-zip-bytes")
	resp, err := http.Post(ts.URL+"/api/scenarios/archive", "application/zip", bytes.NewReader(raw))
	if err != nil {
		t.Fatalf("POST: %v", err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)

	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
	}
	if !strings.Contains(string(body), `"id":42`) {
		t.Errorf("ответ = %s, want id 42", body)
	}
	if got := stub.getUploaded(); !bytes.Equal(got, raw) {
		t.Errorf("сервер получил %q, want %q", got, raw)
	}
}

func TestGatewayDownloadZipWithContentDisposition(t *testing.T) {
	stub := &testStub{}
	ts := newTestGateway(t, stub)

	resp, err := http.Get(ts.URL + "/api/scenarios/1/archive")
	if err != nil {
		t.Fatalf("GET: %v", err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)

	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d", resp.StatusCode)
	}
	if ct := resp.Header.Get("Content-Type"); ct != "application/zip" {
		t.Errorf("Content-Type = %q, want application/zip", ct)
	}
	if cd := resp.Header.Get("Content-Disposition"); cd != `attachment; filename="test.zip"` {
		t.Errorf("Content-Disposition = %q", cd)
	}
	if string(body) != "zip-data" {
		t.Errorf("body = %q, want zip-data", body)
	}
}

// TestGatewayDownloadFileNosniff — скачивание файла отдаёт содержимое,
// Content-Type и X-Content-Type-Options: nosniff.
func TestGatewayDownloadFileNosniff(t *testing.T) {
	stub := &testStub{}
	ts := newTestGateway(t, stub)

	resp, err := http.Get(ts.URL + "/api/files/clue.pdf")
	if err != nil {
		t.Fatalf("GET: %v", err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)

	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d", resp.StatusCode)
	}
	if ct := resp.Header.Get("Content-Type"); ct != "application/pdf" {
		t.Errorf("Content-Type = %q, want application/pdf", ct)
	}
	if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
		t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
	}
	if string(body) != "file-data" {
		t.Errorf("body = %q, want file-data", body)
	}
}

// uploadJSONBody собирает JSON-тело загрузки файла (base64 data).
func uploadJSONBody(filename string, data []byte) []byte {
	body, _ := json.Marshal(proto.UploadFileReq{
		Filename: filename,
		Data:     data,
	})
	return body
}

// TestGatewayUploadArchiveOverLimit — тело архива больше HTTP-лимита
// (MaxBytesReader) → 413 с понятным текстом (связка middleware → декодер →
// customErrorHandler).
func TestGatewayUploadArchiveOverLimit(t *testing.T) {
	stub := &testStub{}
	ts := newTestGateway(t, stub)

	big := bytes.Repeat([]byte("x"), scenario_archive.MaxArchiveSize()+1)
	resp, err := http.Post(ts.URL+"/api/scenarios/archive", "application/zip", bytes.NewReader(big))
	if err != nil {
		t.Fatalf("POST: %v", err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)

	if resp.StatusCode != http.StatusRequestEntityTooLarge {
		t.Fatalf("status = %d, body = %s; want 413", resp.StatusCode, body)
	}
	if !strings.Contains(string(body), "слишком большой") {
		t.Errorf("body = %s, want понятный текст об ограничении размера", body)
	}
}

// TestGatewayUploadFileOverHTTPLimit — тело загрузки больше HTTP-лимита
// (MaxBytesReader) → 413 с понятным текстом.
func TestGatewayUploadFileOverHTTPLimit(t *testing.T) {
	stub := &testStub{}
	const limit = 1 << 20 // 1 MiB
	ts := newTestGatewayWithLimit(t, stub, limit)

	// 1 MiB данных в base64 ~1.33 MiB > лимит 1 MiB.
	data := bytes.Repeat([]byte("x"), limit)
	resp, err := http.Post(ts.URL+"/api/files/upload", "application/json", bytes.NewReader(uploadJSONBody("clue.pdf", data)))
	if err != nil {
		t.Fatalf("POST: %v", err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)

	if resp.StatusCode != http.StatusRequestEntityTooLarge {
		t.Fatalf("status = %d, body = %s; want 413", resp.StatusCode, body)
	}
	if !strings.Contains(string(body), "слишком большой") {
		t.Errorf("body = %s, want понятный текст об ограничении размера", body)
	}
}

// TestGatewayUploadFileExactLimit — тело ровно в пределах лимита проходит
// через HTTP-слой и gRPC-транспорт → 200.
func TestGatewayUploadFileExactLimit(t *testing.T) {
	stub := &testStub{}
	const limit = 2 << 20 // 2 MiB — лимит HTTP-тела
	ts := newTestGatewayWithLimit(t, stub, limit)

	data := bytes.Repeat([]byte("x"), 1<<20) // 1 MiB данных → тело ~1.33 MiB < 2 MiB
	resp, err := http.Post(ts.URL+"/api/files/upload", "application/json", bytes.NewReader(uploadJSONBody("clue.pdf", data)))
	if err != nil {
		t.Fatalf("POST: %v", err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)

	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, body = %s; want 200", resp.StatusCode, body)
	}
	if got := stub.getUploaded(); !bytes.Equal(got, data) {
		t.Errorf("сервер получил %d байт, want %d", len(got), len(data))
	}
}

// TestGatewayUploadFileResourceExhausted — бизнес-ветка: сервис отклоняет
// файл больше лимита (ResourceExhausted) → 413.
func TestGatewayUploadFileResourceExhausted(t *testing.T) {
	stub := &testStub{}
	stub.uploadErr = status.Error(codes.ResourceExhausted, "файл слишком большой (лимит 64 МБ)")
	ts := newTestGateway(t, stub)

	resp, err := http.Post(ts.URL+"/api/files/upload", "application/json", bytes.NewReader(uploadJSONBody("clue.pdf", []byte("small"))))
	if err != nil {
		t.Fatalf("POST: %v", err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)

	if resp.StatusCode != http.StatusRequestEntityTooLarge {
		t.Fatalf("status = %d, body = %s; want 413", resp.StatusCode, body)
	}
	if !strings.Contains(string(body), "слишком большой") {
		t.Errorf("body = %s, want понятный текст об ограничении размера", body)
	}
}

// TestCustomErrorHandler — маппинг ошибок превышения размера в 413.
func TestCustomErrorHandler(t *testing.T) {
	mux := runtime.NewServeMux()
	marshaler := &runtime.JSONPb{}

	do := func(t *testing.T, req *http.Request, err error) *httptest.ResponseRecorder {
		t.Helper()
		rec := httptest.NewRecorder()
		customErrorHandler(context.Background(), mux, marshaler, rec, req, err)
		return rec
	}

	t.Run("MaxBytesError raw", func(t *testing.T) {
		rec := do(t, httptest.NewRequest(http.MethodPost, "/api/files/upload", nil), &http.MaxBytesError{Limit: 10})
		if rec.Code != http.StatusRequestEntityTooLarge {
			t.Errorf("status = %d, want 413", rec.Code)
		}
	})

	t.Run("ResourceExhausted on upload route", func(t *testing.T) {
		rec := do(t, httptest.NewRequest(http.MethodPost, "/api/files/upload", nil),
			status.Error(codes.ResourceExhausted, "файл слишком большой"))
		if rec.Code != http.StatusRequestEntityTooLarge {
			t.Errorf("status = %d, want 413", rec.Code)
		}
	})

	t.Run("InvalidArgument with MaxBytesError text", func(t *testing.T) {
		rec := do(t, httptest.NewRequest(http.MethodPost, "/api/files/upload", nil),
			status.Error(codes.InvalidArgument, "http: request body too large"))
		if rec.Code != http.StatusRequestEntityTooLarge {
			t.Errorf("status = %d, want 413", rec.Code)
		}
	})

	t.Run("ResourceExhausted on other route delegates", func(t *testing.T) {
		rec := do(t, httptest.NewRequest(http.MethodPost, "/api/test/echo", nil),
			status.Error(codes.ResourceExhausted, "rate limit"))
		// DefaultHTTPErrorHandler мапит ResourceExhausted в 429.
		if rec.Code != http.StatusTooManyRequests {
			t.Errorf("status = %d, want 429 (дефолтный маппинг)", rec.Code)
		}
	})
}

// TestLimitUploadBody — тело загрузки ограничено на HTTP-слое
// (защита от OOM), прочие маршруты не затронуты.
func TestLimitUploadBody(t *testing.T) {
	limited := func(route string, limit int64) http.Handler {
		return limitUploadBody(route, limit, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			_, err := io.ReadAll(r.Body)
			if err != nil {
				w.WriteHeader(http.StatusRequestEntityTooLarge)
				return
			}
			w.WriteHeader(http.StatusOK)
		}))
	}

	t.Run("files upload rejected when over limit", func(t *testing.T) {
		h := limited("/api/files/upload", 10)
		req := httptest.NewRequest(http.MethodPost, "/api/files/upload", bytes.NewReader(bytes.Repeat([]byte("x"), 11)))
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, req)
		if rec.Code != http.StatusRequestEntityTooLarge {
			t.Errorf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
		}
	})

	t.Run("archive rejected when over limit", func(t *testing.T) {
		h := limited("/api/scenarios/archive", int64(scenario_archive.MaxArchiveSize()))
		req := httptest.NewRequest(http.MethodPost, "/api/scenarios/archive", bytes.NewReader(bytes.Repeat([]byte("x"), scenario_archive.MaxArchiveSize()+1)))
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, req)
		if rec.Code != http.StatusRequestEntityTooLarge {
			t.Errorf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
		}
	})

	t.Run("small body passes on limited routes", func(t *testing.T) {
		for _, route := range []string{"/api/files/upload", "/api/scenarios/archive"} {
			h := limited(route, 1024)
			req := httptest.NewRequest(http.MethodPost, route, bytes.NewReader([]byte("small")))
			rec := httptest.NewRecorder()
			h.ServeHTTP(rec, req)
			if rec.Code != http.StatusOK {
				t.Errorf("%s: status = %d, want %d", route, rec.Code, http.StatusOK)
			}
		}
	})

	t.Run("other routes not limited", func(t *testing.T) {
		h := limited("/api/files/upload", 10)
		req := httptest.NewRequest(http.MethodPost, "/api/test/echo", bytes.NewReader(bytes.Repeat([]byte("x"), 100)))
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, req)
		if rec.Code != http.StatusOK {
			t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
		}
	})
}

// TestCORSHeaders — фронт через fetch должен читать Content-Disposition
// (имя файла архива) из ответа, а браузерный MCP-клиент — заголовки
// streamable HTTP /mcp (Mcp-Session-Id, Mcp-Protocol-Version) в allow/expose.
func TestCORSHeaders(t *testing.T) {
	h := cors(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
	}))
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/scenarios/1/archive", nil))
	if got := rec.Header().Get("Access-Control-Expose-Headers"); got != "Content-Disposition, Mcp-Session-Id, Mcp-Protocol-Version" {
		t.Errorf("Access-Control-Expose-Headers = %q, want Content-Disposition, Mcp-Session-Id, Mcp-Protocol-Version", got)
	}
	allow := rec.Header().Get("Access-Control-Allow-Headers")
	for _, want := range []string{"Mcp-Session-Id", "Mcp-Protocol-Version", "Last-Event-ID"} {
		if !strings.Contains(allow, want) {
			t.Errorf("Access-Control-Allow-Headers = %q, не содержит %q", allow, want)
		}
	}
}
