package handlers

import (
	"archive/zip"
	"bytes"
	"context"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
	"time"

	filemanager "wucher/internal/domain/file_manager"
	"wucher/internal/shared/pkg/uuidv7"
)

type officeSanitizerStorageMock struct {
	objects        map[string][]byte
	contentTypes   map[string]string
	putObjectCalls int
}

type officeSanitizerServiceMock struct {
	filemanager.Service
	updateFileFn    func(context.Context, *filemanager.File) error
	updateFileCalls int
}

func (m *officeSanitizerStorageMock) EnsureBucket(context.Context) error {
	return nil
}

func (m *officeSanitizerStorageMock) PutObject(_ context.Context, input filemanager.PutObjectInput) (filemanager.ObjectMetadata, error) {
	if m.objects == nil {
		m.objects = map[string][]byte{}
	}
	if m.contentTypes == nil {
		m.contentTypes = map[string]string{}
	}

	payload, err := io.ReadAll(input.Body)
	if err != nil {
		return filemanager.ObjectMetadata{}, err
	}
	m.putObjectCalls++
	key := strings.TrimSpace(input.ObjectKey)
	m.objects[key] = append([]byte(nil), payload...)
	m.contentTypes[key] = strings.TrimSpace(input.ContentType)
	now := time.Now().UTC()
	return filemanager.ObjectMetadata{
		Bucket:       "bucket",
		ObjectKey:    key,
		ETag:         "etag-" + key,
		VersionID:    "version-" + key,
		SizeBytes:    int64(len(payload)),
		LastModified: &now,
		ContentType:  strings.TrimSpace(input.ContentType),
	}, nil
}

func (m *officeSanitizerStorageMock) DeleteObject(_ context.Context, objectKey string) error {
	delete(m.objects, strings.TrimSpace(objectKey))
	delete(m.contentTypes, strings.TrimSpace(objectKey))
	return nil
}

func (m *officeSanitizerStorageMock) HeadObject(_ context.Context, objectKey string) (filemanager.ObjectMetadata, error) {
	key := strings.TrimSpace(objectKey)
	payload, ok := m.objects[key]
	if !ok {
		return filemanager.ObjectMetadata{}, filemanager.ErrUploadObjectNotFound
	}
	now := time.Now().UTC()
	return filemanager.ObjectMetadata{
		Bucket:       "bucket",
		ObjectKey:    key,
		ETag:         "etag-" + key,
		VersionID:    "version-" + key,
		SizeBytes:    int64(len(payload)),
		LastModified: &now,
		ContentType:  m.contentTypes[key],
	}, nil
}

func (m *officeSanitizerStorageMock) PresignPutObject(context.Context, filemanager.PresignPutObjectInput) (filemanager.PresignedPutObject, error) {
	return filemanager.PresignedPutObject{}, nil
}

func (m *officeSanitizerStorageMock) PresignGetObject(context.Context, string, time.Duration) (string, error) {
	return "https://example.com/object", nil
}

func (m *officeSanitizerStorageMock) GetObject(_ context.Context, objectKey string) (io.ReadCloser, filemanager.ObjectMetadata, error) {
	key := strings.TrimSpace(objectKey)
	payload, ok := m.objects[key]
	if !ok {
		return nil, filemanager.ObjectMetadata{}, filemanager.ErrUploadObjectNotFound
	}
	now := time.Now().UTC()
	return io.NopCloser(bytes.NewReader(payload)), filemanager.ObjectMetadata{
		Bucket:       "bucket",
		ObjectKey:    key,
		ETag:         "etag-" + key,
		VersionID:    "version-" + key,
		SizeBytes:    int64(len(payload)),
		LastModified: &now,
		ContentType:  m.contentTypes[key],
	}, nil
}

func (m *officeSanitizerServiceMock) UpdateFile(ctx context.Context, row *filemanager.File) error {
	m.updateFileCalls++
	if m.updateFileFn != nil {
		return m.updateFileFn(ctx, row)
	}
	return nil
}

func TestSanitizeXLSXPayload_FixesMalformedVMLStyleAndPreservesEntries(t *testing.T) {
	original := testMalformedXLSXZip(t)

	sanitized, fixCount, changed, err := sanitizeXLSXPayload(original)
	if err != nil {
		t.Fatalf("sanitize payload: %v", err)
	}
	if !changed {
		t.Fatal("expected payload to be sanitized")
	}
	if fixCount == 0 {
		t.Fatal("expected at least one style fix")
	}

	originalNames := zipEntryNames(t, original)
	sanitizedNames := zipEntryNames(t, sanitized)
	if len(originalNames) != len(sanitizedNames) {
		t.Fatalf("zip entry count changed: %d -> %d", len(originalNames), len(sanitizedNames))
	}
	for i := range originalNames {
		if originalNames[i] != sanitizedNames[i] {
			t.Fatalf("zip entry order changed at %d: %q -> %q", i, originalNames[i], sanitizedNames[i])
		}
	}

	vmlPayload := zipEntryPayload(t, sanitized, "xl/drawings/vmlDrawing1.vml")
	if !bytes.Contains(vmlPayload, []byte(`margin-left:73.5pt`)) {
		t.Fatalf("sanitized VML is missing margin-left fix: %s", string(vmlPayload))
	}
	if !bytes.Contains(vmlPayload, []byte(`margin-top:0pt`)) {
		t.Fatalf("sanitized VML is missing margin-top fix: %s", string(vmlPayload))
	}
	if bytes.Contains(vmlPayload, []byte(`position:absolute;73.5pt;`)) {
		t.Fatalf("sanitized VML still contains malformed style: %s", string(vmlPayload))
	}
}

func TestSanitizeXLSXPayload_InjectsMissingVMLNoteShapeType(t *testing.T) {
	original := testMalformedXLSXZip(t)

	sanitized, _, changed, err := sanitizeXLSXPayload(original)
	if err != nil {
		t.Fatalf("sanitize payload: %v", err)
	}
	if !changed {
		t.Fatal("expected payload to be sanitized")
	}

	vmlPayload := zipEntryPayload(t, sanitized, "xl/drawings/vmlDrawing1.vml")
	if !bytes.Contains(vmlPayload, []byte(`<v:shapetype id="_x0000_t202"`)) {
		t.Fatalf("sanitized VML is missing injected shapetype: %s", string(vmlPayload))
	}
	if !bytes.Contains(vmlPayload, []byte(`<o:shapelayout`)) {
		t.Fatalf("sanitized VML is missing injected shapelayout: %s", string(vmlPayload))
	}
	// The injected shapetype must precede the shape that references it.
	if bytes.Index(vmlPayload, []byte(`<v:shapetype`)) > bytes.Index(vmlPayload, []byte(`<v:shape id=`)) {
		t.Fatalf("shapetype must be declared before the shape: %s", string(vmlPayload))
	}
}

func TestEnsureVMLNoteShapeType_NoChangeWhenShapeTypePresent(t *testing.T) {
	payload := []byte(`<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">` +
		`<v:shapetype id="_x0000_t202"></v:shapetype>` +
		`<v:shape id="_x0000_s1025" type="#_x0000_t202"></v:shape></xml>`)

	out, fixes, changed := ensureVMLNoteShapeType(payload)
	if changed || fixes != 0 {
		t.Fatalf("expected no change when shapetype already present, got changed=%v fixes=%d", changed, fixes)
	}
	if !bytes.Equal(out, payload) {
		t.Fatal("payload should be returned unchanged")
	}
}

func TestSanitizeStoredXLSXObject_RewritesStorageAndUpdatesRow(t *testing.T) {
	original := testMalformedXLSXZip(t)
	objectKey := "files/sample.xlsx"

	storage := &officeSanitizerStorageMock{
		objects: map[string][]byte{
			objectKey: append([]byte(nil), original...),
		},
		contentTypes: map[string]string{
			objectKey: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
		},
	}

	rowID := uuidv7.MustBytes()
	updatedCalls := 0
	var updatedRow *filemanager.File
	svc := &officeSanitizerServiceMock{
		updateFileFn: func(_ context.Context, row *filemanager.File) error {
			updatedCalls++
			if row == nil {
				t.Fatal("expected row")
			}
			copyRow := *row
			updatedRow = &copyRow
			return nil
		},
	}
	handler := NewFileManagerFileHandler(svc, storage)

	row := &filemanager.File{
		ID:            rowID,
		Name:          "sample.xlsx",
		ObjectKey:     objectKey,
		MimeType:      "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
		SizeBytes:     int64(len(original)),
		Bucket:        "bucket",
		ObjectVersion: "before-version",
		ETag:          "before-etag",
	}

	changed, sizeBytes, err := handler.sanitizeStoredXLSXObject(context.Background(), row, row.ObjectKey, row.Name, row.MimeType, "test-upload")
	if err != nil {
		t.Fatalf("sanitize stored object: %v", err)
	}
	if !changed {
		t.Fatal("expected storage object to be rewritten")
	}
	if sizeBytes != int64(len(storage.objects[objectKey])) {
		t.Fatalf("expected sanitized size to match rewritten object, got %d want %d", sizeBytes, len(storage.objects[objectKey]))
	}
	if storage.putObjectCalls != 1 {
		t.Fatalf("expected one PutObject call, got %d", storage.putObjectCalls)
	}
	if updatedCalls != 1 {
		t.Fatalf("expected one UpdateFile call, got %d", updatedCalls)
	}
	if row.SizeBytes == int64(len(original)) {
		t.Fatal("expected row size to change after sanitization")
	}
	if row.ObjectVersion == "before-version" || row.ETag == "before-etag" {
		t.Fatal("expected row storage metadata to be refreshed")
	}
	if updatedRow == nil || updatedRow.SizeBytes != row.SizeBytes {
		t.Fatal("expected updated row to reflect sanitized size")
	}
	if !bytes.Contains(zipEntryPayload(t, storage.objects[objectKey], "xl/drawings/vmlDrawing1.vml"), []byte(`margin-left:73.5pt`)) {
		t.Fatal("stored object was not sanitized")
	}
}

func TestSanitizeXLSXPayload_LibreOfficeRoundTrip_NoRepairWarning(t *testing.T) {
	sofficePath, err := exec.LookPath("soffice")
	if err != nil {
		t.Skip("soffice not available")
	}

	original := testMalformedXLSXZip(t)
	sanitized, _, changed, err := sanitizeXLSXPayload(original)
	if err != nil {
		t.Fatalf("sanitize payload: %v", err)
	}
	if !changed {
		t.Fatal("expected payload to be sanitized")
	}

	tempDir := t.TempDir()
	inputPath := filepath.Join(tempDir, "sample.xlsx")
	if err := os.WriteFile(inputPath, sanitized, 0o600); err != nil {
		t.Fatalf("write sanitized file: %v", err)
	}

	outDir := filepath.Join(tempDir, "out")
	if err := os.Mkdir(outDir, 0o755); err != nil {
		t.Fatalf("mkdir out dir: %v", err)
	}

	cmd := exec.Command(sofficePath, "--headless", "--convert-to", "xlsx", "--outdir", outDir, inputPath)
	output, runErr := cmd.CombinedOutput()
	if runErr != nil {
		t.Fatalf("soffice convert failed: %v\n%s", runErr, string(output))
	}
	lowerOutput := strings.ToLower(string(output))
	if strings.Contains(lowerOutput, "repair") || strings.Contains(lowerOutput, "corrupt") {
		t.Fatalf("unexpected repair warning from soffice: %s", string(output))
	}

	outputPath := filepath.Join(outDir, "sample.xlsx")
	if _, err := os.Stat(outputPath); err != nil {
		t.Fatalf("expected converted file: %v", err)
	}

	wantComments := zipPartCountByPrefixBytes(t, sanitized, "xl/comments")
	if got := zipPartCountByPrefix(t, outputPath, "xl/comments"); got != wantComments {
		t.Fatalf("unexpected comments part count: got %d want %d", got, wantComments)
	}
	wantVML := zipPartCountByPrefixBytes(t, sanitized, "xl/drawings/vmlDrawing")
	if got := zipPartCountByPrefix(t, outputPath, "xl/drawings/vmlDrawing"); got != wantVML {
		t.Fatalf("unexpected vml drawing part count: got %d want %d", got, wantVML)
	}
}

func testMalformedXLSXZip(t *testing.T) []byte {
	t.Helper()

	return orderedOfficeZip(t, []zipEntry{
		{
			name: "[Content_Types].xml",
			body: `<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
  <Default Extension="xml" ContentType="application/xml"/>
  <Default Extension="vml" ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing"/>
  <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
  <Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
  <Override PartName="/xl/comments1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"/>
</Types>`,
		},
		{
			name: "_rels/.rels",
			body: `<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`,
		},
		{
			name: "xl/workbook.xml",
			body: `<?xml version="1.0" encoding="UTF-8"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
  xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
  <sheets>
    <sheet name="Sheet1" sheetId="1" r:id="rId1"/>
  </sheets>
</workbook>`,
		},
		{
			name: "xl/_rels/workbook.xml.rels",
			body: `<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
</Relationships>`,
		},
		{
			name: "xl/worksheets/sheet1.xml",
			body: `<?xml version="1.0" encoding="UTF-8"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
  xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
  <sheetData/>
  <legacyDrawing r:id="rId1"/>
</worksheet>`,
		},
		{
			name: "xl/worksheets/_rels/sheet1.xml.rels",
			body: `<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="../comments1.xml"/>
  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/>
</Relationships>`,
		},
		{
			name: "xl/comments1.xml",
			body: `<?xml version="1.0" encoding="UTF-8"?>
<comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
  <authors><author>OpenAI</author></authors>
  <commentList>
    <comment ref="A1" authorId="0">
      <text><t>Example note</t></text>
    </comment>
  </commentList>
</comments>`,
		},
		{
			name: "xl/drawings/vmlDrawing1.vml",
			body: `<?xml version="1.0" encoding="UTF-8"?>
<xml xmlns:v="urn:schemas-microsoft-com:vml"
     xmlns:o="urn:schemas-microsoft-com:office:office">
  <v:shape id="_x0000_s1025" type="#_x0000_t202"
    style="position:absolute;73.5pt;width:108pt;height:59.25pt;z-index:1;visibility:hidden">
    <v:textbox>
      <div>Example note</div>
    </v:textbox>
  </v:shape>
</xml>`,
		},
		{
			name: "xl/styles.xml",
			body: `<?xml version="1.0" encoding="UTF-8"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
  <fonts count="1"><font/></fonts>
  <fills count="1"><fill/></fills>
  <borders count="1"><border/></borders>
  <cellStyleXfs count="1"><xf/></cellStyleXfs>
  <cellXfs count="1"><xf xfId="0"/></cellXfs>
</styleSheet>`,
		},
	})
}

func orderedOfficeZip(t *testing.T, entries []zipEntry) []byte {
	t.Helper()

	var buf bytes.Buffer
	writer := zip.NewWriter(&buf)
	for _, entry := range entries {
		w, err := writer.Create(entry.name)
		if err != nil {
			t.Fatalf("create zip entry %q: %v", entry.name, err)
		}
		if _, err := w.Write([]byte(entry.body)); err != nil {
			t.Fatalf("write zip entry %q: %v", entry.name, err)
		}
	}
	if err := writer.Close(); err != nil {
		t.Fatalf("close zip: %v", err)
	}
	return buf.Bytes()
}

type zipEntry struct {
	name string
	body string
}

func zipEntryNames(t *testing.T, payload []byte) []string {
	t.Helper()

	reader := mustOpenZipReader(t, payload)
	names := make([]string, 0, len(reader.File))
	for _, file := range reader.File {
		names = append(names, file.Name)
	}
	return names
}

func zipEntryPayload(t *testing.T, payload []byte, name string) []byte {
	t.Helper()

	reader := mustOpenZipReader(t, payload)
	for _, file := range reader.File {
		if file.Name != name {
			continue
		}
		rc, err := file.Open()
		if err != nil {
			t.Fatalf("open zip entry %q: %v", name, err)
		}
		defer rc.Close()
		data, err := io.ReadAll(rc)
		if err != nil {
			t.Fatalf("read zip entry %q: %v", name, err)
		}
		return data
	}
	t.Fatalf("missing zip entry %q", name)
	return nil
}

func zipPartCountByPrefix(t *testing.T, path string, prefix string) int {
	t.Helper()

	data, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read zip file: %v", err)
	}
	reader := mustOpenZipReader(t, data)
	count := 0
	for _, file := range reader.File {
		if strings.HasPrefix(file.Name, prefix) {
			count++
		}
	}
	return count
}

func zipPartCountByPrefixBytes(t *testing.T, payload []byte, prefix string) int {
	t.Helper()

	reader := mustOpenZipReader(t, payload)
	count := 0
	for _, file := range reader.File {
		if strings.HasPrefix(file.Name, prefix) {
			count++
		}
	}
	return count
}

func mustOpenZipReader(t *testing.T, payload []byte) *zip.Reader {
	t.Helper()

	reader, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload)))
	if err != nil {
		t.Fatalf("open zip: %v", err)
	}
	return reader
}
