package main

import (
	"context"
	"errors"
	"fmt"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/xuri/excelize/v2"
	entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)

func TestParseManualInputFile_ValidSingleRow(t *testing.T) {
	filePath := createManualInputWorkbook(
		t,
		"manual_inputs",
		[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
		[][]string{
			{"101", "12345.678", "2026-06-01", "manual seed"},
		},
	)

	location := mustJakartaLocation(t)
	sheet, rows, issues, err := parseManualInputFile(filePath, "", location)
	if err != nil {
		t.Fatalf("expected no error, got %v", err)
	}
	if sheet != "manual_inputs" {
		t.Fatalf("expected selected sheet manual_inputs, got %q", sheet)
	}
	if len(issues) != 0 {
		t.Fatalf("expected no issues, got %+v", issues)
	}
	if len(rows) != 1 {
		t.Fatalf("expected 1 row, got %d", len(rows))
	}
	if rows[0].ProjectFlockID != 101 {
		t.Fatalf("expected project_flock_id 101, got %d", rows[0].ProjectFlockID)
	}
	if rows[0].TotalCost != 12345.678 {
		t.Fatalf("expected total_cost 12345.678, got %v", rows[0].TotalCost)
	}
	if rows[0].CutoverDate.Format(dateLayout) != "2026-06-01" {
		t.Fatalf("expected cutover_date 2026-06-01, got %s", rows[0].CutoverDate.Format(dateLayout))
	}
	if rows[0].Note == nil || *rows[0].Note != "manual seed" {
		t.Fatalf("expected note manual seed, got %+v", rows[0].Note)
	}
}

func TestParseManualInputFile_ValidMultiRow(t *testing.T) {
	filePath := createManualInputWorkbook(
		t,
		"manual_inputs",
		[]string{" Project_Flock_ID ", "TOTAL_COST", "cutover_date", "NOTE"},
		[][]string{
			{"101", "1200", "2026-06-01", ""},
			{"102", "1300.5", "2026-06-02", "second"},
		},
	)

	location := mustJakartaLocation(t)
	_, rows, issues, err := parseManualInputFile(filePath, "manual_inputs", location)
	if err != nil {
		t.Fatalf("expected no error, got %v", err)
	}
	if len(issues) != 0 {
		t.Fatalf("expected no issues, got %+v", issues)
	}
	if len(rows) != 2 {
		t.Fatalf("expected 2 rows, got %d", len(rows))
	}
	if rows[0].Note != nil {
		t.Fatalf("expected first row note nil, got %+v", rows[0].Note)
	}
	if rows[1].Note == nil || *rows[1].Note != "second" {
		t.Fatalf("expected second row note second, got %+v", rows[1].Note)
	}
}

func TestParseManualInputFile_MissingRequiredHeader(t *testing.T) {
	filePath := createManualInputWorkbook(
		t,
		"manual_inputs",
		[]string{"project_flock_id", "totalcost", "cutover_date", "note"},
		[][]string{
			{"101", "1200", "2026-06-01", ""},
		},
	)

	location := mustJakartaLocation(t)
	_, rows, issues, err := parseManualInputFile(filePath, "", location)
	if err != nil {
		t.Fatalf("expected no error, got %v", err)
	}
	if len(rows) != 0 {
		t.Fatalf("expected 0 parsed rows when header invalid, got %d", len(rows))
	}
	if !hasIssue(issues, 0, "total_cost", "required header is missing") {
		t.Fatalf("expected missing total_cost header issue, got %+v", issues)
	}
}

func TestParseManualInputFile_InvalidProjectFlockID(t *testing.T) {
	filePath := createManualInputWorkbook(
		t,
		"manual_inputs",
		[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
		[][]string{
			{"abc", "1200", "2026-06-01", ""},
			{"0", "1300", "2026-06-02", ""},
		},
	)

	location := mustJakartaLocation(t)
	_, rows, issues, err := parseManualInputFile(filePath, "", location)
	if err != nil {
		t.Fatalf("expected no error, got %v", err)
	}
	if len(rows) != 0 {
		t.Fatalf("expected no valid rows, got %d", len(rows))
	}
	if !hasIssue(issues, 2, "project_flock_id", "must be a positive integer") {
		t.Fatalf("expected non numeric project_flock_id issue, got %+v", issues)
	}
	if !hasIssue(issues, 3, "project_flock_id", "must be greater than 0") {
		t.Fatalf("expected project_flock_id >0 issue, got %+v", issues)
	}
}

func TestParseManualInputFile_InvalidTotalCost(t *testing.T) {
	filePath := createManualInputWorkbook(
		t,
		"manual_inputs",
		[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
		[][]string{
			{"101", "abc", "2026-06-01", ""},
			{"102", "-1", "2026-06-02", ""},
		},
	)

	location := mustJakartaLocation(t)
	_, rows, issues, err := parseManualInputFile(filePath, "", location)
	if err != nil {
		t.Fatalf("expected no error, got %v", err)
	}
	if len(rows) != 0 {
		t.Fatalf("expected no valid rows, got %d", len(rows))
	}
	if !hasIssue(issues, 2, "total_cost", "must be numeric") {
		t.Fatalf("expected total_cost numeric issue, got %+v", issues)
	}
	if !hasIssue(issues, 3, "total_cost", "must be greater than or equal to 0") {
		t.Fatalf("expected total_cost >=0 issue, got %+v", issues)
	}
}

func TestParseManualInputFile_InvalidCutoverDate(t *testing.T) {
	filePath := createManualInputWorkbook(
		t,
		"manual_inputs",
		[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
		[][]string{
			{"101", "1200", "06-01-2026", ""},
		},
	)

	location := mustJakartaLocation(t)
	_, rows, issues, err := parseManualInputFile(filePath, "", location)
	if err != nil {
		t.Fatalf("expected no error, got %v", err)
	}
	if len(rows) != 0 {
		t.Fatalf("expected no valid rows, got %d", len(rows))
	}
	if !hasIssue(issues, 2, "cutover_date", "must follow format YYYY-MM-DD") {
		t.Fatalf("expected cutover_date format issue, got %+v", issues)
	}
}

func TestParseManualInputFile_DuplicateProjectFlockID(t *testing.T) {
	filePath := createManualInputWorkbook(
		t,
		"manual_inputs",
		[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
		[][]string{
			{"101", "1200", "2026-06-01", ""},
			{"101", "1300", "2026-06-02", ""},
		},
	)

	location := mustJakartaLocation(t)
	_, rows, issues, err := parseManualInputFile(filePath, "", location)
	if err != nil {
		t.Fatalf("expected no error, got %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("expected first row valid and second row invalid, got %d rows", len(rows))
	}
	if !hasIssue(issues, 3, "project_flock_id", "duplicate value 101") {
		t.Fatalf("expected duplicate project_flock_id issue, got %+v", issues)
	}
}

func TestParseManualInputFile_NoteValidation(t *testing.T) {
	longNote := strings.Repeat("a", 1001)
	filePath := createManualInputWorkbook(
		t,
		"manual_inputs",
		[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
		[][]string{
			{"101", "1200", "2026-06-01", ""},
			{"102", "1300", "2026-06-02", longNote},
		},
	)

	location := mustJakartaLocation(t)
	_, rows, issues, err := parseManualInputFile(filePath, "", location)
	if err != nil {
		t.Fatalf("expected no error, got %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("expected only first row valid, got %d", len(rows))
	}
	if rows[0].Note != nil {
		t.Fatalf("expected first row note nil, got %+v", rows[0].Note)
	}
	if !hasIssue(issues, 3, "note", "at most 1000 characters") {
		t.Fatalf("expected note length issue, got %+v", issues)
	}
}

func TestApplyImportRows_Success(t *testing.T) {
	location := mustJakartaLocation(t)
	runner := &fakeTransactionRunner{}
	rows := []manualInputImportRow{
		{
			RowNumber:      2,
			ProjectFlockID: 101,
			TotalCost:      1000,
			CutoverDate:    mustDateInLocation(t, "2026-06-01", location),
		},
		{
			RowNumber:      3,
			ProjectFlockID: 102,
			TotalCost:      2000,
			CutoverDate:    mustDateInLocation(t, "2026-06-02", location),
		},
	}

	err := applyImportRows(context.Background(), runner, rows)
	if err != nil {
		t.Fatalf("expected no error, got %v", err)
	}
	if runner.txCalls != 1 {
		t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
	}
	if len(runner.committedUpserts) != 2 {
		t.Fatalf("expected 2 committed upserts, got %d", len(runner.committedUpserts))
	}
	if len(runner.committedInvalidations) != 2 {
		t.Fatalf("expected 2 committed invalidations, got %d", len(runner.committedInvalidations))
	}
	if runner.committedInvalidations[0].farmIDs[0] != 101 || runner.committedInvalidations[1].farmIDs[0] != 102 {
		t.Fatalf("unexpected invalidation farm IDs: %+v", runner.committedInvalidations)
	}
}

func TestApplyImportRows_RollbackOnError(t *testing.T) {
	location := mustJakartaLocation(t)
	runner := &fakeTransactionRunner{
		failUpsertOnProjectFlockID: 102,
	}
	rows := []manualInputImportRow{
		{
			RowNumber:      2,
			ProjectFlockID: 101,
			TotalCost:      1000,
			CutoverDate:    mustDateInLocation(t, "2026-06-01", location),
		},
		{
			RowNumber:      3,
			ProjectFlockID: 102,
			TotalCost:      2000,
			CutoverDate:    mustDateInLocation(t, "2026-06-02", location),
		},
	}

	err := applyImportRows(context.Background(), runner, rows)
	if err == nil {
		t.Fatal("expected error due to upsert failure")
	}
	if runner.txCalls != 1 {
		t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
	}
	if len(runner.committedUpserts) != 0 {
		t.Fatalf("expected no committed upserts on rollback, got %d", len(runner.committedUpserts))
	}
	if len(runner.committedInvalidations) != 0 {
		t.Fatalf("expected no committed invalidations on rollback, got %d", len(runner.committedInvalidations))
	}
}

func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) {
	location := mustJakartaLocation(t)
	runner := &fakeTransactionRunner{}
	rows := []manualInputImportRow{
		{
			RowNumber:      2,
			ProjectFlockID: 101,
			TotalCost:      1000,
			CutoverDate:    mustDateInLocation(t, "2026-06-01", location),
		},
	}

	err := applyIfRequested(context.Background(), false, runner, rows)
	if err != nil {
		t.Fatalf("expected no error, got %v", err)
	}
	if runner.txCalls != 0 {
		t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls)
	}
}

func createManualInputWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
	t.Helper()

	f := excelize.NewFile()
	defaultSheet := f.GetSheetName(f.GetActiveSheetIndex())
	if sheetName == "" {
		sheetName = defaultSheet
	} else if sheetName != defaultSheet {
		f.SetSheetName(defaultSheet, sheetName)
	}

	for idx, header := range headers {
		cell, err := excelize.CoordinatesToCellName(idx+1, 1)
		if err != nil {
			t.Fatalf("failed resolving header cell: %v", err)
		}
		if err := f.SetCellValue(sheetName, cell, header); err != nil {
			t.Fatalf("failed setting header cell: %v", err)
		}
	}

	for rowIdx, row := range rows {
		for colIdx, value := range row {
			cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
			if err != nil {
				t.Fatalf("failed resolving data cell: %v", err)
			}
			if err := f.SetCellValue(sheetName, cell, value); err != nil {
				t.Fatalf("failed setting data cell: %v", err)
			}
		}
	}

	path := filepath.Join(t.TempDir(), "manual_inputs.xlsx")
	if err := f.SaveAs(path); err != nil {
		t.Fatalf("failed saving workbook: %v", err)
	}
	if err := f.Close(); err != nil {
		t.Fatalf("failed closing workbook: %v", err)
	}

	return path
}

func mustJakartaLocation(t *testing.T) *time.Location {
	t.Helper()
	location, err := time.LoadLocation("Asia/Jakarta")
	if err != nil {
		t.Fatalf("failed loading Asia/Jakarta location: %v", err)
	}
	return location
}

func mustDateInLocation(t *testing.T, raw string, location *time.Location) time.Time {
	t.Helper()
	value, err := time.ParseInLocation(dateLayout, raw, location)
	if err != nil {
		t.Fatalf("failed parsing date %q: %v", raw, err)
	}
	return value
}

func hasIssue(issues []validationIssue, row int, field, messageContains string) bool {
	for _, issue := range issues {
		if issue.Row != row {
			continue
		}
		if issue.Field != field {
			continue
		}
		if strings.Contains(issue.Message, messageContains) {
			return true
		}
	}
	return false
}

type fakeInvalidation struct {
	fromDate time.Time
	farmIDs  []uint
}

type fakeManualInputStore struct {
	failUpsertOnProjectFlockID uint
	failDeleteOnProjectFlockID uint
	upserts                    []entity.FarmDepreciationManualInput
	invalidations              []fakeInvalidation
}

func (s *fakeManualInputStore) UpsertManualInput(_ context.Context, row *entity.FarmDepreciationManualInput) error {
	if row == nil {
		return nil
	}
	if s.failUpsertOnProjectFlockID > 0 && row.ProjectFlockId == s.failUpsertOnProjectFlockID {
		return fmt.Errorf("forced upsert failure for project_flock_id=%d", row.ProjectFlockId)
	}
	cloned := *row
	s.upserts = append(s.upserts, cloned)
	return nil
}

func (s *fakeManualInputStore) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error {
	if s.failDeleteOnProjectFlockID > 0 {
		for _, farmID := range farmIDs {
			if farmID == s.failDeleteOnProjectFlockID {
				return fmt.Errorf("forced delete failure for project_flock_id=%d", farmID)
			}
		}
	}
	copiedFarmIDs := append([]uint{}, farmIDs...)
	s.invalidations = append(s.invalidations, fakeInvalidation{
		fromDate: fromDate,
		farmIDs:  copiedFarmIDs,
	})
	return nil
}

type fakeTransactionRunner struct {
	txCalls                    int
	failUpsertOnProjectFlockID uint
	failDeleteOnProjectFlockID uint
	committedUpserts           []entity.FarmDepreciationManualInput
	committedInvalidations     []fakeInvalidation
}

func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error {
	r.txCalls++

	txStore := &fakeManualInputStore{
		failUpsertOnProjectFlockID: r.failUpsertOnProjectFlockID,
		failDeleteOnProjectFlockID: r.failDeleteOnProjectFlockID,
	}

	if err := fn(txStore); err != nil {
		return err
	}

	r.committedUpserts = append(r.committedUpserts, txStore.upserts...)
	r.committedInvalidations = append(r.committedInvalidations, txStore.invalidations...)
	return nil
}

var _ txRunner = (*fakeTransactionRunner)(nil)
var _ manualInputStore = (*fakeManualInputStore)(nil)

func TestBuildMissingFarmIssues(t *testing.T) {
	location := mustJakartaLocation(t)
	rows := []manualInputImportRow{
		{
			RowNumber:      2,
			ProjectFlockID: 101,
			TotalCost:      1000,
			CutoverDate:    mustDateInLocation(t, "2026-06-01", location),
		},
		{
			RowNumber:      3,
			ProjectFlockID: 102,
			TotalCost:      1000,
			CutoverDate:    mustDateInLocation(t, "2026-06-01", location),
		},
	}

	issues := buildMissingFarmIssues(rows, map[uint]string{
		101: "Farm A",
	})
	if len(issues) != 1 {
		t.Fatalf("expected 1 issue, got %+v", issues)
	}
	if issues[0].Row != 3 || issues[0].Field != "project_flock_id" {
		t.Fatalf("unexpected issue: %+v", issues[0])
	}
}

func TestApplyImportRows_PropagatesDeleteError(t *testing.T) {
	location := mustJakartaLocation(t)
	runner := &fakeTransactionRunner{
		failDeleteOnProjectFlockID: 101,
	}
	rows := []manualInputImportRow{
		{
			RowNumber:      2,
			ProjectFlockID: 101,
			TotalCost:      1000,
			CutoverDate:    mustDateInLocation(t, "2026-06-01", location),
		},
	}

	err := applyImportRows(context.Background(), runner, rows)
	if err == nil {
		t.Fatal("expected delete failure")
	}
	if !strings.Contains(err.Error(), "snapshot invalidation failed") {
		t.Fatalf("expected snapshot invalidation error message, got %v", err)
	}
}

func TestResolveSheetName_ErrorWhenSheetNotFound(t *testing.T) {
	workbook := excelize.NewFile()
	defer func() {
		_ = workbook.Close()
	}()

	_, err := resolveSheetName(workbook, "unknown")
	if err == nil {
		t.Fatal("expected error when sheet is missing")
	}
}

func TestApplyIfRequested_ApplyUsesRunnerError(t *testing.T) {
	location := mustJakartaLocation(t)
	rows := []manualInputImportRow{
		{
			RowNumber:      2,
			ProjectFlockID: 101,
			TotalCost:      1000,
			CutoverDate:    mustDateInLocation(t, "2026-06-01", location),
		},
	}
	runner := &errorTxRunner{err: errors.New("tx failed")}

	err := applyIfRequested(context.Background(), true, runner, rows)
	if err == nil {
		t.Fatal("expected transaction error")
	}
	if err.Error() != "tx failed" {
		t.Fatalf("unexpected error: %v", err)
	}
}

type errorTxRunner struct {
	err error
}

func (r *errorTxRunner) InTx(_ context.Context, _ func(store manualInputStore) error) error {
	return r.err
}
