package services

// Regression tests for the xlsx-generator pitfalls reported by m on
// 2026-05-19:
//
//   1. Excel showed a "Repairs required" prompt on opening the .xlsx.
//      Root cause: SetPanes call passed only Freeze + YSplit; the
//      resulting <pane> XML missed topLeftCell + activePane, which
//      Excel rejects. Fix in buildXLSX: complete the Panes struct
//      (TopLeftCell="A2", ActivePane="bottomLeft", Selection on
//      bottomLeft).
//
//   2. Windows Explorer / Excel's File→Info showed Modified=2006-09-16
//      ("xuri" — excelize's first-commit defaults). Root cause:
//      SetDocProps was never called, so the canned default leaked
//      through. Fix in buildXLSX: SetDocProps({Created, Modified} =
//      meta.GeneratedAt; Creator = "Paliad (<firm>)").
//
// The tests are always-on (no env var gate) so a future writer
// regression shows up loudly in `go test`. Developer-convenience hatch
// at the bottom: set DUMP_EXPORT=1 to additionally write the bundle +
// xlsx to /tmp for opening in real Excel.

import (
	"archive/zip"
	"bytes"
	"io"
	"os"
	"strings"
	"testing"
	"time"

	"github.com/google/uuid"
	"github.com/xuri/excelize/v2"
)

// fixturePersonalExport builds a tiny in-memory bundle + the raw xlsx
// for the regression assertions and the optional /tmp dump.
func fixturePersonalExport(t *testing.T) (bundle []byte, xlsxBytes []byte, meta ExportMeta) {
	t.Helper()
	meta = ExportMeta{
		SchemaVersion:  1,
		FirmName:       "HLC",
		Scope:          ExportScopePersonal,
		GeneratedAt:    time.Date(2026, 5, 19, 14, 23, 0, 0, time.UTC),
		GeneratedByID:  uuid.MustParse("00000000-0000-0000-0000-000000000001"),
		GeneratedByEml: "m@hlc.de",
		GeneratedByLbl: "m",
		RowCounts:      map[string]int{"projects": 1, "deadlines": 0},
	}
	sheets := []collectedSheet{
		{name: "projects", columns: []string{"id", "title", "umlauts"}, rows: [][]string{{"u1", "Acme", "Müller"}}},
		{name: "deadlines", columns: []string{"id", "due_date"}, rows: nil},
	}
	bundle = assembleBundleForTest(t, sheets, meta)
	var err error
	xlsxBytes, err = buildXLSX(sheets, meta)
	if err != nil {
		t.Fatalf("buildXLSX: %v", err)
	}
	return bundle, xlsxBytes, meta
}

// TestXLSX_DocProps_NotExcelizeDefault pins fix #2.
//
// Before the fix: core.xml had Created=Modified="2006-09-16T00:00:00Z"
// (xuri's first commit). Now we expect both to equal meta.GeneratedAt
// in RFC 3339 UTC, and Creator to be "Paliad (<firm>)".
func TestXLSX_DocProps_NotExcelizeDefault(t *testing.T) {
	_, xlsxBytes, meta := fixturePersonalExport(t)
	fl, err := excelize.OpenReader(bytes.NewReader(xlsxBytes))
	if err != nil {
		t.Fatalf("excelize.OpenReader: %v", err)
	}
	defer fl.Close()

	props, err := fl.GetDocProps()
	if err != nil {
		t.Fatalf("GetDocProps: %v", err)
	}
	wantTS := meta.GeneratedAt.UTC().Format(time.RFC3339)
	if props.Created != wantTS {
		t.Errorf("Created = %q, want %q (excelize-default leak)", props.Created, wantTS)
	}
	if props.Modified != wantTS {
		t.Errorf("Modified = %q, want %q (excelize-default leak)", props.Modified, wantTS)
	}
	if props.Creator == "xuri" || props.Creator == "" {
		t.Errorf("Creator = %q, want non-empty non-xuri (e.g. \"Paliad (HLC)\")", props.Creator)
	}
	if !strings.Contains(props.Creator, "Paliad") {
		t.Errorf("Creator = %q, expected to contain \"Paliad\"", props.Creator)
	}
}

// TestXLSX_DocProps_TracksGeneratedAt pins that docProps stays bound to
// meta.GeneratedAt across different timestamps — belt-and-braces vs
// the fixed-fixture timestamp in the previous test.
func TestXLSX_DocProps_TracksGeneratedAt(t *testing.T) {
	for _, ts := range []time.Time{
		time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
		time.Date(2027, 12, 31, 23, 59, 59, 0, time.UTC),
		time.Now().UTC().Truncate(time.Second),
	} {
		meta := ExportMeta{
			SchemaVersion: 1,
			FirmName:      "HLC",
			Scope:         ExportScopePersonal,
			GeneratedAt:   ts,
			RowCounts:     map[string]int{"projects": 0},
		}
		xlsxBytes, err := buildXLSX([]collectedSheet{
			{name: "projects", columns: []string{"id"}, rows: nil},
		}, meta)
		if err != nil {
			t.Fatalf("buildXLSX: %v", err)
		}
		fl, err := excelize.OpenReader(bytes.NewReader(xlsxBytes))
		if err != nil {
			t.Fatalf("OpenReader: %v", err)
		}
		props, err := fl.GetDocProps()
		_ = fl.Close()
		if err != nil {
			t.Fatalf("GetDocProps: %v", err)
		}
		want := ts.Format(time.RFC3339)
		if props.Modified != want {
			t.Errorf("Modified = %q, want %q", props.Modified, want)
		}
	}
}

// TestXLSX_PaneXML_IsCompleteAndValid pins fix #1.
//
// excelize accepts the half-broken <pane state="frozen" ySplit="1"/>
// shape on re-read (its parser is permissive), but Excel rejects it
// with "Repairs required". To detect the regression without spinning
// up Office, we read the raw worksheet XML out of the in-memory xlsx
// zip and assert that the pane element has both topLeftCell + activePane.
func TestXLSX_PaneXML_IsCompleteAndValid(t *testing.T) {
	_, xlsxBytes, _ := fixturePersonalExport(t)

	zr, err := zip.NewReader(bytes.NewReader(xlsxBytes), int64(len(xlsxBytes)))
	if err != nil {
		t.Fatalf("xlsx is not a valid zip: %v", err)
	}

	// sheet1 = __meta (no pane). sheet2 = projects, sheet3 = deadlines —
	// both have the frozen header.
	for _, target := range []string{"xl/worksheets/sheet2.xml", "xl/worksheets/sheet3.xml"} {
		var body []byte
		for _, f := range zr.File {
			if f.Name == target {
				rc, err := f.Open()
				if err != nil {
					t.Fatalf("open %s: %v", target, err)
				}
				body, _ = io.ReadAll(rc)
				rc.Close()
				break
			}
		}
		if body == nil {
			t.Fatalf("missing %s in xlsx zip", target)
		}
		s := string(body)
		if !strings.Contains(s, `topLeftCell="A2"`) {
			t.Errorf("%s pane missing topLeftCell — Excel will prompt 'repairs required'.\nXML: %s",
				target, s)
		}
		if !strings.Contains(s, `activePane="bottomLeft"`) {
			t.Errorf("%s pane missing activePane — Excel will prompt 'repairs required'.\nXML: %s",
				target, s)
		}
		if !strings.Contains(s, `state="frozen"`) {
			t.Errorf("%s pane missing state=frozen.\nXML: %s", target, s)
		}
	}
}

// TestXLSX_NoExcelizeBuildDefaults guards against any future regression
// where a code path writes the .xlsx without first overriding excelize's
// canned defaults. Cheap byte-level assertions.
func TestXLSX_NoExcelizeBuildDefaults(t *testing.T) {
	_, xlsxBytes, _ := fixturePersonalExport(t)
	if bytes.Contains(xlsxBytes, []byte("2006-09-16T00:00:00Z")) {
		t.Errorf("xlsx leaks excelize default Created/Modified=2006-09-16 — SetDocProps not called?")
	}
	if bytes.Contains(xlsxBytes, []byte(`<dc:creator>xuri</dc:creator>`)) {
		t.Errorf("xlsx leaks excelize default Creator=xuri — SetDocProps not called?")
	}
}

// TestXLSX_OpensCleanly is the catch-all: round-trip the file through
// excelize and confirm sheet names, row counts, and GetDocProps work.
func TestXLSX_OpensCleanly(t *testing.T) {
	_, xlsxBytes, _ := fixturePersonalExport(t)
	fl, err := excelize.OpenReader(bytes.NewReader(xlsxBytes))
	if err != nil {
		t.Fatalf("OpenReader: %v", err)
	}
	defer fl.Close()

	wantSheets := []string{"__meta", "projects", "deadlines"}
	got := fl.GetSheetList()
	if len(got) != len(wantSheets) {
		t.Fatalf("sheet list length = %d, want %d (%v vs %v)", len(got), len(wantSheets), got, wantSheets)
	}
	for i, want := range wantSheets {
		if got[i] != want {
			t.Errorf("sheet[%d] = %q, want %q", i, got[i], want)
		}
	}
	rows, err := fl.GetRows("projects")
	if err != nil {
		t.Fatalf("GetRows(projects): %v", err)
	}
	if len(rows) != 2 {
		t.Fatalf("projects rows = %d, want 2 (header + 1)", len(rows))
	}
	if rows[0][0] != "id" || rows[1][0] != "u1" || rows[1][2] != "Müller" {
		t.Errorf("projects rows = %v, want header=[id title umlauts] row=[u1 Acme Müller]", rows)
	}
}

// TestBundle_ZipEntryMTime_TracksGeneratedAt pins the outer-zip side of
// fix #2. Pre-fix every entry was stamped 2000-01-01 (the deterministic
// constant) so Windows showed extracted files with a stale Modified
// column. Now they carry meta.GeneratedAt.
func TestBundle_ZipEntryMTime_TracksGeneratedAt(t *testing.T) {
	bundle, _, meta := fixturePersonalExport(t)
	zr, err := zip.NewReader(bytes.NewReader(bundle), int64(len(bundle)))
	if err != nil {
		t.Fatalf("bundle not a valid zip: %v", err)
	}
	want := meta.GeneratedAt.UTC()
	for _, f := range zr.File {
		got := f.Modified.UTC()
		// Zip stores mtime at 2-second resolution; allow ≤2s drift.
		diff := got.Sub(want)
		if diff < -2*time.Second || diff > 2*time.Second {
			t.Errorf("zip entry %q Modified = %v, want ~%v", f.Name, got, want)
		}
		// Specifically catch the old 2000-01-01 stamp.
		if got.Year() == 2000 && got.Month() == 1 && got.Day() == 1 {
			t.Errorf("zip entry %q stamped 2000-01-01 — old deterministic-constant regression", f.Name)
		}
	}
}

// TestDumpExport is the developer-convenience hatch. Skipped by default;
// set DUMP_EXPORT=1 to write artifacts to /tmp for opening in real Excel.
func TestDumpExport(t *testing.T) {
	if os.Getenv("DUMP_EXPORT") == "" {
		t.Skip("set DUMP_EXPORT=1 to dump artifacts to /tmp/paliad-export-debug.{zip,xlsx}")
	}
	bundle, xlsxBytes, _ := fixturePersonalExport(t)
	if err := os.WriteFile("/tmp/paliad-export-debug.zip", bundle, 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile("/tmp/paliad-export-debug.xlsx", xlsxBytes, 0o644); err != nil {
		t.Fatal(err)
	}
	t.Logf("wrote /tmp/paliad-export-debug.zip (%d bytes) + .xlsx (%d bytes)", len(bundle), len(xlsxBytes))
}
