package handlers

import (
	"bytes"
	"context"
	"errors"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/gofiber/fiber/v2"
	"github.com/valyala/fasthttp"

	"wucher/internal/domain/base"
	filemanager "wucher/internal/domain/file_manager"
	filemanagerrealtime "wucher/internal/realtime/filemanager"
	"wucher/internal/service"
	"wucher/internal/shared/pkg/apperrors"
	"wucher/internal/shared/pkg/attachmentresolver"
	"wucher/internal/shared/pkg/uuidv7"
	"wucher/internal/transport/http/jsonapi"
)

var (
	testBaseShiftStart = "06:00:00"
	testBaseShiftEnd   = "21:00:00"
)

type memBaseRepo struct {
	rows map[string]map[string]*base.Base

	createBaseErr  error
	updateBaseErr  error
	deleteBaseErr  error
	getBaseByIDErr error
	listBasesErr   error

	listRows  []base.Base
	listTotal int64

	lastCreateBase     *base.Base
	lastCreateCategory string
	lastUpdateBase     *base.Base
	lastUpdateCategory string
	lastDeleteID       []byte
	lastDeleteCategory string
	lastGetID          []byte
	lastGetCategory    string
	lastListFilter     string
	lastListSort       string
	lastListLimit      int
	lastListOffset     int
	lastListCategory   string
	lastListDUL        *bool
}

type basePresignStorageMock struct {
	url string
	err error
}

func (m *basePresignStorageMock) PresignGetObject(_ context.Context, _ string, _ time.Duration) (string, error) {
	if m.err != nil {
		return "", m.err
	}
	return m.url, nil
}

func newMemBaseRepo() *memBaseRepo {
	return &memBaseRepo{
		rows: map[string]map[string]*base.Base{
			base.CategoryKeyRegular: {},
			base.CategoryKeyHEMS:    {},
		},
	}
}

func (r *memBaseRepo) CreateBase(_ context.Context, row *base.Base, categoryType string) error {
	if r.createBaseErr != nil {
		return r.createBaseErr
	}
	if len(row.ID) == 0 {
		row.ID = uuidv7.MustBytes()
	}
	r.lastCreateBase = row
	r.lastCreateCategory = categoryType
	if _, ok := r.rows[categoryType]; !ok {
		r.rows[categoryType] = map[string]*base.Base{}
	}
	r.rows[categoryType][string(row.ID)] = row
	return nil
}

func (r *memBaseRepo) UpdateBase(_ context.Context, row *base.Base, categoryType string) error {
	if r.updateBaseErr != nil {
		return r.updateBaseErr
	}
	r.lastUpdateBase = row
	r.lastUpdateCategory = categoryType
	if _, ok := r.rows[categoryType]; !ok {
		r.rows[categoryType] = map[string]*base.Base{}
	}
	r.rows[categoryType][string(row.ID)] = row
	return nil
}

func (r *memBaseRepo) DeleteBase(_ context.Context, id []byte, categoryType string) error {
	if r.deleteBaseErr != nil {
		return r.deleteBaseErr
	}
	r.lastDeleteID = append([]byte(nil), id...)
	r.lastDeleteCategory = categoryType
	if m, ok := r.rows[categoryType]; ok {
		delete(m, string(id))
	}
	return nil
}

func (r *memBaseRepo) GetBaseByID(_ context.Context, id []byte, categoryType string) (*base.Base, error) {
	if r.getBaseByIDErr != nil {
		return nil, r.getBaseByIDErr
	}
	r.lastGetID = append([]byte(nil), id...)
	r.lastGetCategory = categoryType
	if m, ok := r.rows[categoryType]; ok {
		return m[string(id)], nil
	}
	return nil, nil
}

func (r *memBaseRepo) ListBases(_ context.Context, filter, sort string, limit, offset int, categoryType string, dul *bool) ([]base.Base, int64, error) {
	if r.listBasesErr != nil {
		return nil, 0, r.listBasesErr
	}
	r.lastListFilter = filter
	r.lastListSort = sort
	r.lastListLimit = limit
	r.lastListOffset = offset
	r.lastListCategory = categoryType
	r.lastListDUL = dul
	if r.listRows != nil {
		return r.listRows, r.listTotal, nil
	}
	out := make([]base.Base, 0)
	for _, row := range r.rows[categoryType] {
		out = append(out, *row)
	}
	return out, int64(len(out)), nil
}

func newBaseTestApp(h *BaseHandler) *fiber.App {
	app := fiber.New()
	app.Post("/api/v1/bases/create", h.CreateBase)
	app.Patch("/api/v1/bases/update/:uuid", h.UpdateBase)
	app.Delete("/api/v1/bases/delete/:uuid", h.DeleteBase)
	app.Get("/api/v1/bases/get/:uuid", h.GetBase)
	app.Get("/api/v1/bases/get-all", h.ListBases)
	app.Get("/api/v1/bases/get-all/dt", h.ListBasesDatatable)
	return app
}

func newBaseEventsTestApp(h *FileManagerEventsHandler, actor []byte) *fiber.App {
	app := fiber.New()
	app.Use(func(c *fiber.Ctx) error {
		if actor != nil {
			c.Locals("user_id", append([]byte(nil), actor...))
		}
		return c.Next()
	})
	app.Get("/api/v1/bases/events", h.Stream)
	return app
}

func doJSONRequestBase(t *testing.T, app *fiber.App, method, path string, body []byte) (*http.Response, []byte) {
	t.Helper()
	req, err := http.NewRequest(method, path, bytes.NewReader(body))
	if err != nil {
		t.Fatalf("new request: %v", err)
	}
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		t.Fatalf("read body: %v", err)
	}
	return resp, respBody
}

func mustUUIDStringBase(t *testing.T, id []byte) string {
	t.Helper()
	s, err := uuidv7.BytesToString(id)
	if err != nil {
		t.Fatalf("uuid to string: %v", err)
	}
	return s
}

func TestNewBaseHandler(t *testing.T) {
	h := NewBaseHandler(service.NewBaseService(newMemBaseRepo()))
	if h == nil || h.validate == nil {
		t.Fatalf("expected handler and validator initialized")
	}
	h = h.WithFileManagerService(nil)
	h = h.WithFileResolver(nil, nil)
	if h == nil {
		t.Fatalf("expected chain methods return handler")
	}
}

func TestBaseHandlerCreateBase(t *testing.T) {
	t.Run("invalid category", func(t *testing.T) {
		repo := newMemBaseRepo()
		h := NewBaseHandler(service.NewBaseService(repo))
		app := newBaseTestApp(h)

		resp, _ := doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create?category_type=oops", []byte(`{"data":{"type":"base","attributes":{"base":"A","latitude":47.3769,"longitude":8.5417}}}`))
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("missing coordinates", func(t *testing.T) {
		repo := newMemBaseRepo()
		h := NewBaseHandler(service.NewBaseService(repo))
		app := newBaseTestApp(h)

		resp, _ := doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create", []byte(`{"data":{"type":"base","attributes":{"base":"No coords"}}}`))
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid coordinates", func(t *testing.T) {
		repo := newMemBaseRepo()
		h := NewBaseHandler(service.NewBaseService(repo))
		app := newBaseTestApp(h)

		resp, _ := doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create", []byte(`{"data":{"type":"base","attributes":{"base":"Bad coords","latitude":91,"longitude":181}}}`))
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("regular success", func(t *testing.T) {
		repo := newMemBaseRepo()
		h := NewBaseHandler(service.NewBaseService(repo))
		app := newBaseTestApp(h)

		resp, body := doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create", []byte(`{"data":{"type":"base","attributes":{"base":"Regular A","latitude":47.3769,"longitude":8.5417,"default_shift_start":"`+testBaseShiftStart+`","default_shift_end":"`+testBaseShiftEnd+`","sortkey":3,"notes":"Primary note","is_active":false}}}`))
		if resp.StatusCode != http.StatusCreated {
			t.Fatalf("expected 201, got %d", resp.StatusCode)
		}
		if repo.lastCreateCategory != base.CategoryKeyRegular {
			t.Fatalf("expected regular category, got %q", repo.lastCreateCategory)
		}
		if repo.lastCreateBase == nil || repo.lastCreateBase.SortKey == nil || *repo.lastCreateBase.SortKey != 3 {
			t.Fatalf("expected sortkey mapped on create")
		}
		if repo.lastCreateBase.Notes != "Primary note" || repo.lastCreateBase.IsActive {
			t.Fatalf("expected notes/is_active mapped on create")
		}
		if !bytes.Contains(body, []byte(`"type":"base"`)) {
			t.Fatalf("expected base resource type, body=%s", string(body))
		}
	})

	t.Run("operational shift times success", func(t *testing.T) {
		repo := newMemBaseRepo()
		h := NewBaseHandler(service.NewBaseService(repo))
		app := newBaseTestApp(h)

		resp, body := doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create", []byte(`{"data":{"type":"base","attributes":{"base":"Base with ops","latitude":47.3769,"longitude":8.5417,"default_shift_start":"`+testBaseShiftStart+`","default_shift_end":"`+testBaseShiftEnd+`","operational_shift_times":[{"date_start":"2026-04-12","date_end":"2026-04-12","shift_time":{"start":"09:00","end":"18:00"}},{"date_start":"2026-04-13","date_end":"2026-04-13","shift_time":{"start":"09:00","end":"17:00"}}]}}}`))
		if resp.StatusCode != http.StatusCreated {
			t.Fatalf("expected 201, got %d", resp.StatusCode)
		}
		if repo.lastCreateBase == nil || len(repo.lastCreateBase.OperationalShiftTimes) != 2 {
			t.Fatalf("expected operational shift times mapped on create")
		}
		if got := repo.lastCreateBase.OperationalShiftTimes[1].DateStart.Format("2006-01-02"); got != "2026-04-13" {
			t.Fatalf("unexpected operational shift date start: %s", got)
		}
		if got := repo.lastCreateBase.OperationalShiftTimes[1].DateEnd.Format("2006-01-02"); got != "2026-04-13" {
			t.Fatalf("unexpected operational shift date end: %s", got)
		}
		if repo.lastCreateBase.OperationalShiftTimes[0].ShiftStart != "09:00:00" || repo.lastCreateBase.OperationalShiftTimes[1].ShiftEnd != "17:00:00" {
			t.Fatalf("expected both operational shift times mapped on create, got %#v", repo.lastCreateBase.OperationalShiftTimes)
		}
		if !bytes.Contains(body, []byte(`"date_start":"2026-04-12"`)) || !bytes.Contains(body, []byte(`"date_end":"2026-04-12"`)) || !bytes.Contains(body, []byte(`"date_start":"2026-04-13"`)) || !bytes.Contains(body, []byte(`"date_end":"2026-04-13"`)) {
			t.Fatalf("expected operational shift dates in response, body=%s", string(body))
		}
		if !bytes.Contains(body, []byte(`"operational_shift_times":[`)) {
			t.Fatalf("expected operational_shift_times in response, body=%s", string(body))
		}
	})

	t.Run("operational shift times fixed response includes values", func(t *testing.T) {
		repo := newMemBaseRepo()
		h := NewBaseHandler(service.NewBaseService(repo))
		app := newBaseTestApp(h)

		resp, body := doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create", []byte(`{"data":{"type":"base","attributes":{"base":"Base with fixed ops","latitude":47.3769,"longitude":8.5417,"default_shift_start":"`+testBaseShiftStart+`","default_shift_end":"`+testBaseShiftEnd+`","operational_shift_times":[{"date_start":"2026-06-03","date_end":"2026-06-04","start_time_type":"FIXED","end_time_type":"FIXED","shift_time":{"start":"03:00","end":"05:00"}}]}}}`))
		if resp.StatusCode != http.StatusCreated {
			t.Fatalf("expected 201, got %d", resp.StatusCode)
		}
		if !bytes.Contains(body, []byte(`"shift_time":{"start":"03:00","end":"05:00"}`)) {
			t.Fatalf("expected fixed operational shift_time values in response, body=%s", string(body))
		}
	})

	t.Run("operational shift times invalid order", func(t *testing.T) {
		repo := newMemBaseRepo()
		h := NewBaseHandler(service.NewBaseService(repo))
		app := newBaseTestApp(h)

		resp, body := doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create", []byte(`{"data":{"type":"base","attributes":{"base":"Base with bad ops","latitude":47.3769,"longitude":8.5417,"default_shift_start":"`+testBaseShiftStart+`","default_shift_end":"`+testBaseShiftEnd+`","operational_shift_times":[{"date_start":"2026-04-12","date_end":"2026-04-12","shift_time":{"start":"18:00","end":"09:00"}}]}}}`))
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
		if !bytes.Contains(body, []byte(`shift_time.start must be earlier than shift_time.end`)) {
			t.Fatalf("expected shift order validation error, body=%s", string(body))
		}
		if repo.lastCreateBase != nil {
			t.Fatalf("expected create not to reach repo on invalid shift order")
		}
	})

	t.Run("hems success", func(t *testing.T) {
		repo := newMemBaseRepo()
		h := NewBaseHandler(service.NewBaseService(repo))
		app := newBaseTestApp(h)

		resp, body := doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create?category_type=hems", []byte(`{"data":{"type":"hems_base","attributes":{"base":"HEMS A","latitude":47.3769,"longitude":8.5417,"default_shift_start":"`+testBaseShiftStart+`","default_shift_end":"`+testBaseShiftEnd+`"}}}`))
		if resp.StatusCode != http.StatusCreated {
			t.Fatalf("expected 201, got %d", resp.StatusCode)
		}
		if repo.lastCreateCategory != base.CategoryKeyHEMS {
			t.Fatalf("expected hems category, got %q", repo.lastCreateCategory)
		}
		if !bytes.Contains(body, []byte(`"type":"hems_base"`)) {
			t.Fatalf("expected hems_base resource type, body=%s", string(body))
		}
	})

	t.Run("hems success from body base_category without query", func(t *testing.T) {
		repo := newMemBaseRepo()
		h := NewBaseHandler(service.NewBaseService(repo))
		app := newBaseTestApp(h)

		resp, body := doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create", []byte(`{"data":{"type":"base","attributes":{"base":"HEMS from body","base_category":"hems","latitude":47.3769,"longitude":8.5417,"default_shift_start":"`+testBaseShiftStart+`","default_shift_end":"`+testBaseShiftEnd+`"}}}`))
		if resp.StatusCode != http.StatusCreated {
			t.Fatalf("expected 201, got %d", resp.StatusCode)
		}
		if repo.lastCreateCategory != base.CategoryKeyHEMS {
			t.Fatalf("expected hems category, got %q", repo.lastCreateCategory)
		}
		if !bytes.Contains(body, []byte(`"base_category":"hems"`)) {
			t.Fatalf("expected base_category hems, body=%s", string(body))
		}
	})

}

func TestBaseHandlerGetBase(t *testing.T) {
	repo := newMemBaseRepo()
	hemsID := uuidv7.MustBytes()
	fotoID := uuidv7.MustBytes()
	repo.rows[base.CategoryKeyHEMS][string(hemsID)] = &base.Base{ID: hemsID, BaseName: "HEMS 1", FotoAttachmentID: fotoID, CreatedAt: time.Now(), UpdatedAt: time.Now()}
	h := NewBaseHandler(service.NewBaseService(repo))
	app := newBaseTestApp(h)

	resp, body := doJSONRequestBase(t, app, http.MethodGet, "/api/v1/bases/get/"+mustUUIDStringBase(t, hemsID)+"?category_type=hems", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if repo.lastGetCategory != base.CategoryKeyHEMS {
		t.Fatalf("expected hems category forwarded")
	}
	if !bytes.Contains(body, []byte(`"type":"hems_base"`)) {
		t.Fatalf("expected hems_base type")
	}
	if !bytes.Contains(body, []byte(`"base_category":"hems"`)) {
		t.Fatalf("expected base_category hems")
	}
	fotoIDStr, _ := uuidv7.BytesToString(fotoID)
	bodyStr := string(body)
	if !strings.Contains(bodyStr, `"uuid":"`+fotoIDStr+`"`) {
		t.Fatalf("expected foto uuid when storage unavailable, body=%s", bodyStr)
	}
	if !strings.Contains(bodyStr, `"download_url":""`) {
		t.Fatalf("expected empty download_url when storage unavailable, body=%s", bodyStr)
	}
}

func TestBaseHelperFunctions(t *testing.T) {
	t.Run("cloneIntPointerBase and preferStringPtr", func(t *testing.T) {
		v := 7
		cp := cloneIntPointerBase(&v)
		if cp == nil || *cp != 7 || cp == &v {
			t.Fatalf("cloneIntPointerBase invalid clone")
		}
		if cloneIntPointerBase(nil) != nil {
			t.Fatalf("expected nil clone for nil input")
		}
		a, b := "a", "b"
		if got := preferStringPtr(&a, &b); got == nil || *got != "a" {
			t.Fatalf("preferStringPtr should prioritize primary")
		}
		if got := preferStringPtr(nil, &b); got == nil || *got != "b" {
			t.Fatalf("preferStringPtr should use fallback")
		}
	})

	t.Run("parseUUIDList and uuidListToStrings", func(t *testing.T) {
		id1 := mustUUIDStringBase(t, uuidv7.MustBytes())
		id2 := mustUUIDStringBase(t, uuidv7.MustBytes())
		got, errObj := parseUUIDList([]string{" ", id1, id1, id2}, "/x")
		if errObj != nil || len(got) != 2 {
			t.Fatalf("expected deduped parsed ids, got len=%d err=%v", len(got), errObj)
		}
		if _, errObj = parseUUIDList([]string{"not-uuid"}, "/x"); errObj == nil {
			t.Fatalf("expected validation error for invalid uuid")
		}
		strs := uuidListToStrings(got)
		if len(strs) != 2 {
			t.Fatalf("uuidListToStrings expected 2 got %d", len(strs))
		}
		if uuidListToStrings(nil) != nil {
			t.Fatalf("uuidListToStrings nil input should be nil")
		}
	})

	t.Run("baseContactResources", func(t *testing.T) {
		id := uuidv7.MustBytes()
		rows := []base.BaseContactPerson{
			{ContactID: id, FirstName: "A", LastName: "B"},
			{ContactID: []byte{1, 2}, FirstName: "X"},
		}
		out := baseContactResources(rows)
		if len(out) != 1 || out[0].FirstName != "A" {
			t.Fatalf("expected single valid contact resource")
		}
		if baseContactResources(nil) != nil {
			t.Fatalf("nil input should return nil")
		}
	})

	t.Run("shift parsing helpers", func(t *testing.T) {
		startRaw := "07:30"
		endRaw := "16:45:30"
		start, end, errObj := parseBaseShiftRange(&startRaw, &endRaw, nil, "/s", "/e", "/l")
		if errObj != nil || start == nil || end == nil {
			t.Fatalf("expected valid shift parse")
		}
		legacy := "09:00-18:00"
		start, end, errObj = parseBaseShiftRange(nil, nil, &legacy, "/s", "/e", "/l")
		if errObj != nil || start == nil || end == nil || start.Format("15:04") != "09:00" {
			t.Fatalf("expected legacy shift parse")
		}
		bad := "invalid"
		_, _, errObj = parseBaseShiftRange(nil, nil, &bad, "/s", "/e", "/l")
		if errObj == nil {
			t.Fatalf("expected error for invalid legacy shift")
		}
		if tokens := extractBaseShiftClockTokens("x 01:02:03 y 14:15"); len(tokens) != 2 {
			t.Fatalf("expected two extracted tokens")
		}
		a, b := extractBaseShiftRangeTokens("10:10")
		if a != "10:10" || b != "10:10" {
			t.Fatalf("single token should duplicate")
		}
	})

	t.Run("query and category helpers", func(t *testing.T) {
		app := fiber.New()
		ctx := app.AcquireCtx(&fasthttp.RequestCtx{})
		defer app.ReleaseCtx(ctx)
		ctx.Request().URI().SetQueryString("dul=true&category_type=hems")
		dul, errObj := parseOptionalBoolQueryParam(ctx, "dul")
		if errObj != nil || dul == nil || !*dul {
			t.Fatalf("expected dul=true parsed")
		}
		ctx.Request().URI().SetQueryString("dul=oops")
		if _, errObj = parseOptionalBoolQueryParam(ctx, "dul"); errObj == nil {
			t.Fatalf("expected bool validation error")
		}
		ctx.Request().URI().SetQueryString("category=hems")
		if got := baseCategoryQueryValue(ctx); got != "hems" {
			t.Fatalf("expected category fallback value")
		}
		if categoryNotFoundDetail(base.CategoryKeyHEMS) != "hems base not found" {
			t.Fatalf("unexpected category detail for hems")
		}
	})

	t.Run("uuid parser and converter helpers", func(t *testing.T) {
		idStr := mustUUIDStringBase(t, uuidv7.MustBytes())
		if got, errObj := parseUUIDOrClear(idStr, "/p"); errObj != nil || len(got) != 16 {
			t.Fatalf("parseUUIDOrClear should parse valid uuid")
		}
		if got, errObj := parseUUIDOrClear(" ", "/p"); errObj != nil || got != nil {
			t.Fatalf("parseUUIDOrClear should clear blank")
		}
		if _, errObj := parseUUIDOrClear("bad", "/p"); errObj == nil {
			t.Fatalf("expected parseUUIDOrClear error")
		}
		if bytesUUIDToString(nil) != "" {
			t.Fatalf("empty bytes should return empty")
		}
		if bytesUUIDToString([]byte{1, 2}) != "" {
			t.Fatalf("invalid bytes should return empty")
		}
	})

	t.Run("inferBaseErrorCode branches", func(t *testing.T) {
		cases := []struct {
			status int
			title  string
			detail string
		}{
			{fiber.StatusNotFound, "", ""},
			{fiber.StatusConflict, "", ""},
			{fiber.StatusUnprocessableEntity, "Validation error", "invalid uuid"},
			{fiber.StatusUnprocessableEntity, "Validation error", "category_type/base_category must be one of"},
			{fiber.StatusUnprocessableEntity, "Validation error", "time must be HH:MM"},
			{fiber.StatusBadRequest, "Invalid JSON", ""},
			{fiber.StatusBadRequest, "Create failed", ""},
			{fiber.StatusBadRequest, "Update failed", ""},
			{fiber.StatusBadRequest, "Delete failed", ""},
			{fiber.StatusBadRequest, "List failed", ""},
			{fiber.StatusInternalServerError, "", ""},
		}
		for _, tc := range cases {
			code, errCode := inferBaseErrorCode(tc.status, tc.title, tc.detail)
			if strings.TrimSpace(code) == "" || strings.TrimSpace(errCode) == "" {
				t.Fatalf("expected non-empty codes for status=%d title=%q", tc.status, tc.title)
			}
		}
	})

	t.Run("writeBaseErrors populates status and code", func(t *testing.T) {
		app := fiber.New()
		app.Get("/", func(c *fiber.Ctx) error {
			return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Title: "Invalid JSON", Detail: "oops"}})
		})
		req := httptest.NewRequest(http.MethodGet, "/", nil)
		resp, err := app.Test(req)
		if err != nil || resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("writeBaseErrors failed err=%v status=%d", err, resp.StatusCode)
		}
	})

	t.Run("base attachment resolver branches", func(t *testing.T) {
		handler := NewBaseHandler(service.NewBaseService(newMemBaseRepo())).WithFileManagerService(&fileManagerAttachmentHandlerServiceMock{})
		baseID := uuidv7.MustBytes()
		app := fiber.New()
		app.Get("/invalid-attachment", func(c *fiber.Ctx) error {
			raw := "bad"
			_, handled := handler.resolveAttachmentIDFromInput(c, attachmentresolver.Params{
				RawAttachmentID: &raw,
				AttachmentField: "attachment_id",
				FileField:       "file_uuid",
			}, "Update failed")
			if !handled {
				t.Fatalf("expected handled for invalid attachment")
			}
			return nil
		})
		app.Get("/invalid-file", func(c *fiber.Ctx) error {
			raw := "bad"
			_, handled := handler.resolveAttachmentIDFromInput(c, attachmentresolver.Params{
				RawFileID:       &raw,
				AttachmentField: "attachment_id",
				FileField:       "file_uuid",
				FileManager:     nil,
			}, "Update failed")
			if !handled {
				t.Fatalf("expected handled for invalid file path")
			}
			return nil
		})
		app.Get("/require", func(c *fiber.Ctx) error {
			_, handled := handler.resolveAttachmentIDFromInput(c, attachmentresolver.Params{
				RequireValue:    true,
				AttachmentField: "attachment_id",
				FileField:       "file_uuid",
			}, "Update failed")
			if !handled {
				t.Fatalf("expected handled for require value")
			}
			return nil
		})
		app.Get("/not-found", func(c *fiber.Ctx) error {
			id := mustUUIDStringBase(t, uuidv7.MustBytes())
			_, handled := handler.resolveAttachmentIDFromInput(c, attachmentresolver.Params{
				RawAttachmentID:          &id,
				AttachmentField:          "attachment_id",
				FileField:                "file_uuid",
				ValidateAttachmentExists: true,
				AttachmentExists: func(context.Context, []byte) (bool, error) {
					return false, nil
				},
			}, "Update failed")
			if !handled {
				t.Fatalf("expected handled for attachment not found")
			}
			return nil
		})
		app.Get("/existing-or-file", func(c *fiber.Ctx) error {
			raw := mustUUIDStringBase(t, uuidv7.MustBytes())
			mock := &fileManagerAttachmentHandlerServiceMock{
				getAttachmentFn: func(context.Context, []byte) (*filemanager.Attachment, error) {
					return &filemanager.Attachment{ID: uuidv7.MustBytes()}, nil
				},
			}
			h2 := NewBaseHandler(service.NewBaseService(newMemBaseRepo())).WithFileManagerService(mock)
			id, handled := h2.resolveExistingBaseFotoAttachmentOrFileID(c, baseID, &raw)
			if handled || len(id) != 16 {
				t.Fatalf("expected existing attachment id")
			}
			return nil
		})
		app.Get("/existing-error", func(c *fiber.Ctx) error {
			raw := mustUUIDStringBase(t, uuidv7.MustBytes())
			mock := &fileManagerAttachmentHandlerServiceMock{
				getAttachmentFn: func(context.Context, []byte) (*filemanager.Attachment, error) {
					return nil, errors.New("lookup failed")
				},
			}
			h2 := NewBaseHandler(service.NewBaseService(newMemBaseRepo())).WithFileManagerService(mock)
			_, handled := h2.resolveExistingBaseFotoAttachmentOrFileID(c, baseID, &raw)
			if !handled {
				t.Fatalf("expected handled error for existing attachment lookup")
			}
			return nil
		})
		app.Get("/resolve-base-foto", func(c *fiber.Ctx) error {
			_, handled := handler.resolveBaseFotoAttachmentID(c, baseID, nil)
			if handled {
				t.Fatalf("nil raw should not be handled as error")
			}
			return nil
		})

		for _, path := range []string{"/invalid-attachment", "/invalid-file", "/require", "/not-found", "/existing-or-file", "/existing-error", "/resolve-base-foto"} {
			req := httptest.NewRequest(http.MethodGet, path, nil)
			resp, err := app.Test(req)
			if err != nil {
				t.Fatalf("request %s err=%v", path, err)
			}
			if path != "/existing-or-file" && path != "/resolve-base-foto" && resp.StatusCode == http.StatusOK {
				t.Fatalf("expected error status for %s", path)
			}
		}
	})
}

func TestBaseHandlerGetBase_UsesDirectPresignURLWhenStorageAvailable(t *testing.T) {
	repo := newMemBaseRepo()
	hemsID := uuidv7.MustBytes()
	fotoID := uuidv7.MustBytes()
	repo.rows[base.CategoryKeyHEMS][string(hemsID)] = &base.Base{
		ID:               hemsID,
		BaseName:         "HEMS 1",
		FotoAttachmentID: fotoID,
		FotoAttachment: &filemanager.Attachment{
			ID:     fotoID,
			FileID: uuidv7.MustBytes(),
			File:   &filemanager.File{ID: uuidv7.MustBytes(), ObjectKey: "base/hems-1.jpg"},
		},
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
	}
	h := NewBaseHandler(service.NewBaseService(repo)).WithFileStorage(
		&basePresignStorageMock{url: "https://signed.example/base/hems-1.jpg?sig=abc"},
	)
	app := newBaseTestApp(h)

	resp, body := doJSONRequestBase(t, app, http.MethodGet, "/api/v1/bases/get/"+mustUUIDStringBase(t, hemsID)+"?category_type=hems", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if !bytes.Contains(body, []byte(`"download_url":"https://signed.example/base/hems-1.jpg?sig=abc"`)) {
		t.Fatalf("expected direct presigned URL, body=%s", string(body))
	}
}

func TestBaseHandlerUpdateDelete(t *testing.T) {
	repo := newMemBaseRepo()
	id := uuidv7.MustBytes()
	repo.rows[base.CategoryKeyHEMS][string(id)] = &base.Base{ID: id, BaseName: "Old", DefaultShiftStart: "06:00:00", DefaultShiftEnd: "21:00:00", CreatedAt: time.Now(), UpdatedAt: time.Now()}
	h := NewBaseHandler(service.NewBaseService(repo))
	app := newBaseTestApp(h)
	idStr := mustUUIDStringBase(t, id)

	resp, _ := doJSONRequestBase(t, app, http.MethodPatch, "/api/v1/bases/update/"+idStr+"?category_type=hems", []byte(`{"data":{"type":"hems_base","id":"`+idStr+`","attributes":{"base":"New","latitude":47.3769,"longitude":8.5417}}}`))
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if repo.lastUpdateCategory != base.CategoryKeyHEMS {
		t.Fatalf("expected hems category update")
	}

	fotoID := uuidv7.MustBytes()
	repo.rows[base.CategoryKeyHEMS][string(id)].FotoAttachmentID = append([]byte(nil), fotoID...)
	resp, _ = doJSONRequestBase(t, app, http.MethodPatch, "/api/v1/bases/update/"+idStr+"?category_type=hems", []byte(`{"data":{"type":"hems_base","id":"`+idStr+`","attributes":{"base":"Still New","latitude":47.3769,"longitude":8.5417,"file_uuid":""}}}`))
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if repo.rows[base.CategoryKeyHEMS][string(id)].FotoAttachmentID != nil {
		t.Fatalf("expected foto attachment to be cleared")
	}

	resp, updateBody := doJSONRequestBase(t, app, http.MethodPatch, "/api/v1/bases/update/"+idStr+"?category_type=hems", []byte(`{"data":{"type":"hems_base","id":"`+idStr+`","attributes":{"base":"With Ops","latitude":47.3769,"longitude":8.5417,"operational_shift_times":[{"date_start":"2026-04-12","date_end":"2026-04-12","shift_time":{"start":"09:00","end":"18:00"}},{"date_start":"2026-04-13","date_end":"2026-04-13","shift_time":{"start":"09:00","end":"17:00"}}]}}}`))
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if repo.lastUpdateBase == nil || len(repo.lastUpdateBase.OperationalShiftTimes) != 2 {
		t.Fatalf("expected operational shift times mapped on update")
	}
	if repo.lastUpdateBase.OperationalShiftTimes[0].ShiftStart != "09:00:00" || repo.lastUpdateBase.OperationalShiftTimes[1].ShiftEnd != "17:00:00" {
		t.Fatalf("expected operational shift mapping on update, got %#v", repo.lastUpdateBase.OperationalShiftTimes)
	}
	if !bytes.Contains(updateBody, []byte(`"date_start":"2026-04-12"`)) || !bytes.Contains(updateBody, []byte(`"date_end":"2026-04-12"`)) || !bytes.Contains(updateBody, []byte(`"date_start":"2026-04-13"`)) || !bytes.Contains(updateBody, []byte(`"date_end":"2026-04-13"`)) {
		t.Fatalf("expected operational shift dates in update response, body=%s", string(updateBody))
	}

	resp, fixedBody := doJSONRequestBase(t, app, http.MethodPatch, "/api/v1/bases/update/"+idStr+"?category_type=hems", []byte(`{"data":{"type":"hems_base","id":"`+idStr+`","attributes":{"base":"With Ops Fixed","latitude":47.3769,"longitude":8.5417,"operational_shift_times":[{"date_start":"2026-06-03","date_end":"2026-06-04","start_time_type":"FIXED","end_time_type":"FIXED","shift_time":{"start":"03:00","end":"05:00"}}]}}}`))
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if !bytes.Contains(fixedBody, []byte(`"shift_time":{"start":"03:00","end":"05:00"}`)) {
		t.Fatalf("expected fixed operational shift_time values in update response, body=%s", string(fixedBody))
	}

	resp, _ = doJSONRequestBase(t, app, http.MethodPatch, "/api/v1/bases/update/"+idStr+"?category_type=hems", []byte(`{"data":{"type":"hems_base","id":"`+idStr+`","attributes":{"latitude":47.3769,"longitude":8.5417,"operational_shift_times":[]}}}`))
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if repo.lastUpdateBase == nil || repo.lastUpdateBase.OperationalShiftTimes == nil || len(repo.lastUpdateBase.OperationalShiftTimes) != 0 {
		t.Fatalf("expected explicit empty operational_shift_times to clear rows")
	}

	resp, body := doJSONRequestBase(t, app, http.MethodDelete, "/api/v1/bases/delete/"+idStr+"?category_type=hems", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if repo.lastDeleteCategory != base.CategoryKeyHEMS {
		t.Fatalf("expected hems category delete")
	}
	if !strings.Contains(string(body), "hems_base_delete") {
		t.Fatalf("expected hems delete resource type")
	}

	resp, body = doJSONRequestBase(t, app, http.MethodDelete, "/api/v1/bases/delete/"+mustUUIDStringBase(t, uuidv7.MustBytes())+"?category_type=hems", nil)
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("expected 404 for missing base delete, got %d body=%s", resp.StatusCode, string(body))
	}
}

func TestBaseHandlerUpdateShiftTimeOnly(t *testing.T) {
	repo := newMemBaseRepo()
	id := uuidv7.MustBytes()
	repo.rows[base.CategoryKeyRegular][string(id)] = &base.Base{
		ID:                id,
		BaseName:          "Old",
		DefaultShiftStart: "06:00:00",
		DefaultShiftEnd:   "21:00:00",
		CreatedAt:         time.Now(),
		UpdatedAt:         time.Now(),
	}
	h := NewBaseHandler(service.NewBaseService(repo))
	app := newBaseTestApp(h)
	idStr := mustUUIDStringBase(t, id)

	resp, body := doJSONRequestBase(t, app, http.MethodPatch, "/api/v1/bases/update/"+idStr, []byte(`{"data":{"type":"base","id":"`+idStr+`","attributes":{"shift_time":{"start":"07:00","end":"20:00"}}}}`))
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if repo.lastUpdateBase == nil || repo.lastUpdateBase.DefaultShiftStart == "" || repo.lastUpdateBase.DefaultShiftEnd == "" {
		t.Fatalf("expected shift_time applied to repo update row")
	}
	if got := repo.lastUpdateBase.DefaultShiftStart; got != "07:00:00" {
		t.Fatalf("expected updated start 07:00:00, got %s", got)
	}
	if got := repo.lastUpdateBase.DefaultShiftEnd; got != "20:00:00" {
		t.Fatalf("expected updated end 20:00:00, got %s", got)
	}
	if !bytes.Contains(body, []byte(`"shift_time":{"start":"07:00","end":"20:00"}`)) {
		t.Fatalf("expected response shift_time to update, body=%s", string(body))
	}
}

func TestBaseHandlerDelete_NotFoundAndConflict(t *testing.T) {
	t.Run("not found returns 404", func(t *testing.T) {
		repo := newMemBaseRepo()
		h := NewBaseHandler(service.NewBaseService(repo))
		app := newBaseTestApp(h)

		resp, body := doJSONRequestBase(t, app, http.MethodDelete, "/api/v1/bases/delete/"+mustUUIDStringBase(t, uuidv7.MustBytes())+"?category_type=hems", nil)
		if resp.StatusCode != http.StatusNotFound {
			t.Fatalf("expected 404, got %d", resp.StatusCode)
		}
		if !bytes.Contains(body, []byte(`"BASE_NOT_FOUND"`)) {
			t.Fatalf("expected base not found code, body=%s", string(body))
		}
	})

	t.Run("relation conflict returns 409", func(t *testing.T) {
		repo := newMemBaseRepo()
		id := uuidv7.MustBytes()
		repo.rows[base.CategoryKeyHEMS][string(id)] = &base.Base{ID: id, BaseName: "Has refs", DefaultShiftStart: "06:00:00", DefaultShiftEnd: "21:00:00", CreatedAt: time.Now(), UpdatedAt: time.Now()}
		repo.deleteBaseErr = apperrors.NewDeleteConflictError("takeover")
		h := NewBaseHandler(service.NewBaseService(repo))
		app := newBaseTestApp(h)

		resp, body := doJSONRequestBase(t, app, http.MethodDelete, "/api/v1/bases/delete/"+mustUUIDStringBase(t, id)+"?category_type=hems", nil)
		if resp.StatusCode != http.StatusConflict {
			t.Fatalf("expected 409, got %d", resp.StatusCode)
		}
		if !bytes.Contains(body, []byte(`"code":"BASE_DELETE_CONFLICT"`)) {
			t.Fatalf("expected specific base delete conflict code, body=%s", string(body))
		}
		if !bytes.Contains(body, []byte(`"error_code":"4091615"`)) {
			t.Fatalf("expected specific base delete conflict error_code, body=%s", string(body))
		}
		if !bytes.Contains(body, []byte(`takeover`)) {
			t.Fatalf("expected relation detail to mention takeover, body=%s", string(body))
		}
	})
}

func TestBaseHandlerListBases(t *testing.T) {
	repo := newMemBaseRepo()
	repo.listRows = []base.Base{{
		ID:                uuidv7.MustBytes(),
		BaseName:          "Lude",
		DefaultShiftStart: "06:00:00",
		DefaultShiftEnd:   "21:00:00",
		CreatedAt:         time.Now(),
		UpdatedAt:         time.Now(),
	}}
	repo.listTotal = 1
	h := NewBaseHandler(service.NewBaseService(repo))
	app := newBaseTestApp(h)

	resp, body := doJSONRequestBase(t, app, http.MethodGet, "/api/v1/bases/get-all?category_type=hems&search=abc&page=0&limit=101&sort=-base", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if repo.lastListCategory != base.CategoryKeyHEMS || repo.lastListFilter != "abc" || repo.lastListSort != "base DESC" || repo.lastListLimit != 0 || repo.lastListOffset != 0 {
		t.Fatalf("unexpected forwarded list args")
	}
	resp, _ = doJSONRequestBase(t, app, http.MethodGet, "/api/v1/bases/get-all?category_type=hems&sort=-sortkey", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if repo.lastListSort != "is_active DESC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN 0 ELSE 1 END ASC, CASE WHEN is_active = 1 AND sortkey IS NOT NULL AND sortkey >= 0 THEN sortkey END DESC, base ASC" {
		t.Fatalf("unexpected sortkey sort forwarding: %q", repo.lastListSort)
	}
	if !bytes.Contains(body, []byte(`"type":"hems_base"`)) {
		t.Fatalf("expected hems_base list resource type")
	}
	if !bytes.Contains(body, []byte(`"base_category":"hems"`)) {
		t.Fatalf("expected base_category hems")
	}
	if !bytes.Contains(body, []byte(`"shift_time":{"start":"06:00","end":"21:00"}`)) {
		t.Fatalf("expected shift_time start/end in HH:MM format, body=%s", string(body))
	}
	if bytes.Contains(body, []byte(`"default_shift_start"`)) || bytes.Contains(body, []byte(`"default_shift_end"`)) || bytes.Contains(body, []byte(`"default_shift_time"`)) {
		t.Fatalf("expected old default_shift_* fields removed, body=%s", string(body))
	}

	resp, _ = doJSONRequestBase(t, app, http.MethodGet, "/api/v1/bases/get-all?base_category=regular", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200 for base_category alias, got %d", resp.StatusCode)
	}
	if repo.lastListCategory != base.CategoryKeyRegular {
		t.Fatalf("expected regular category from base_category alias, got %q", repo.lastListCategory)
	}

	resp, _ = doJSONRequestBase(t, app, http.MethodGet, "/api/v1/bases/get-all?base_category=hems", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200 for base_category hems alias, got %d", resp.StatusCode)
	}
	if repo.lastListCategory != base.CategoryKeyHEMS {
		t.Fatalf("expected hems category from base_category alias, got %q", repo.lastListCategory)
	}
}

func TestBaseHandlerBaseResource_ShiftLabelsForNonFixedTypes(t *testing.T) {
	repo := newMemBaseRepo()
	id := uuidv7.MustBytes()
	repo.rows[base.CategoryKeyRegular][string(id)] = &base.Base{
		ID:                   id,
		BaseName:             "Twilight Base",
		DefaultStartTimeType: base.ShiftTimeTypeBMCT,
		DefaultEndTimeType:   base.ShiftTimeTypeECET,
		OperationalShiftTimes: []base.BaseOperationalShiftTime{
			{
				StartTimeType: base.ShiftTimeTypeBMCT,
				EndTimeType:   base.ShiftTimeTypeECET,
			},
		},
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
	}
	h := NewBaseHandler(service.NewBaseService(repo))
	app := newBaseTestApp(h)

	resp, body := doJSONRequestBase(t, app, http.MethodGet, "/api/v1/bases/get/"+mustUUIDStringBase(t, id)+"?category_type=regular", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if !bytes.Contains(body, []byte(`"shift_time":{"start":"BMCT","end":"ECET"}`)) {
		t.Fatalf("expected BMCT/ECET shift labels in response, body=%s", string(body))
	}
	if !bytes.Contains(body, []byte(`"operational_shift_times":[{"date_start":"","date_end":"","start_time_type":"BMCT","end_time_type":"ECET","shift_time":{"start":"BMCT","end":"ECET"}}]`)) {
		t.Fatalf("expected operational shift labels in response, body=%s", string(body))
	}
}

func TestBaseHandlerListBasesDatatable(t *testing.T) {
	repo := newMemBaseRepo()
	repo.listRows = []base.Base{{ID: uuidv7.MustBytes(), BaseName: "Reg", CreatedAt: time.Now(), UpdatedAt: time.Now()}}
	repo.listTotal = 1
	h := NewBaseHandler(service.NewBaseService(repo))
	app := newBaseTestApp(h)

	resp, _ := doJSONRequestBase(t, app, http.MethodGet, "/api/v1/bases/get-all/dt?page=0&limit=0&size=101&draw=7&search=abc", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	if repo.lastListCategory != "" || repo.lastListSort != "" || repo.lastListLimit != 100 || repo.lastListOffset != 0 {
		t.Fatalf("unexpected datatable args")
	}
}

func TestBaseHandlerListAndCreateError(t *testing.T) {
	repo := newMemBaseRepo()
	repo.listBasesErr = errors.New("list failed")
	repo.createBaseErr = errors.New("create failed")
	h := NewBaseHandler(service.NewBaseService(repo))
	app := newBaseTestApp(h)

	resp, _ := doJSONRequestBase(t, app, http.MethodGet, "/api/v1/bases/get-all", nil)
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400 list, got %d", resp.StatusCode)
	}

	resp, _ = doJSONRequestBase(t, app, http.MethodPost, "/api/v1/bases/create", []byte(`{"data":{"type":"base","attributes":{"base":"A","latitude":47.3769,"longitude":8.5417,"default_shift_start":"`+testBaseShiftStart+`","default_shift_end":"`+testBaseShiftEnd+`"}}}`))
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400 create, got %d", resp.StatusCode)
	}
}

func TestBaseEventsStream(t *testing.T) {
	hub := filemanagerrealtime.NewHub(1, nil)
	actor := uuidv7.MustBytes()
	userUUID, _ := uuidv7.BytesToString(actor)

	app := fiber.New()
	fctx := &fasthttp.RequestCtx{}
	fctx.Request.SetRequestURI("/api/v1/bases/events")
	c := app.AcquireCtx(fctx)
	c.Locals("user_id", actor)
	if err := NewFileManagerEventsHandler(hub).Stream(c); err != nil {
		t.Fatalf("unexpected stream setup error: %v", err)
	}

	hub.PublishToUsers([]string{userUUID}, filemanagerrealtime.FileStatusEvent{
		FileUUID:  mustUUIDStringBase(t, uuidv7.MustBytes()),
		Status:    "ready",
		Name:      "base.png",
		MimeType:  "image/png",
		UpdatedAt: time.Now().UTC().Format(time.RFC3339),
	})
	go func() {
		time.Sleep(10 * time.Millisecond)
		closeFirstHubSubscriberForUser(hub, userUUID)
	}()

	bodyStr := c.Context().Response.String()
	app.ReleaseCtx(c)
	if !strings.Contains(bodyStr, ": connected") {
		t.Fatalf("expected connected comment in body: %q", bodyStr)
	}
	if !strings.Contains(bodyStr, "event: file.updated") {
		t.Fatalf("expected file.updated event in body: %q", bodyStr)
	}
	if !strings.Contains(bodyStr, "\"file_id\"") || !strings.Contains(bodyStr, "\"status\":\"ready\"") {
		t.Fatalf("expected file.updated payload in body: %q", bodyStr)
	}
}
