package services

// Unit tests for SubmissionComposer's pure splice logic — no DB
// dependency. The end-to-end Compose path is exercised by the live
// integration test in submission_section_service_live_test.go (Slice
// A) once anchors land in the seeded .docx; this file covers the
// anchor-splicing primitives and the section rendering glue.

import (
	"archive/zip"
	"bytes"
	"context"
	"strings"
	"testing"

	"github.com/google/uuid"
)

// minimalBaseBytes builds a tiny .docx zip with one document.xml body
// for the composer tests. The body content is provided by the caller
// so different splice scenarios can be exercised in-process.
func minimalBaseBytes(t *testing.T, body string) []byte {
	t.Helper()
	var buf bytes.Buffer
	zw := zip.NewWriter(&buf)

	parts := map[string]string{
		"[Content_Types].xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<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"/>
  <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>`,
		"_rels/.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`,
		"word/document.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>` + body + `</w:body>
</w:document>`,
	}
	for name, contents := range parts {
		w, err := zw.Create(name)
		if err != nil {
			t.Fatalf("zip create %s: %v", name, err)
		}
		if _, err := w.Write([]byte(contents)); err != nil {
			t.Fatalf("zip write %s: %v", name, err)
		}
	}
	if err := zw.Close(); err != nil {
		t.Fatalf("zip close: %v", err)
	}
	return buf.Bytes()
}

// extractDocumentXML pulls word/document.xml out of a .docx zip for
// assertions.
func extractDocumentXML(t *testing.T, data []byte) string {
	return extractZipEntry(t, data, "word/document.xml")
}

// extractZipEntry pulls any named entry out of a .docx zip.
func extractZipEntry(t *testing.T, data []byte, name string) string {
	t.Helper()
	zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
	if err != nil {
		t.Fatalf("open zip: %v", err)
	}
	for _, f := range zr.File {
		if f.Name != name {
			continue
		}
		rc, err := f.Open()
		if err != nil {
			t.Fatalf("open %s: %v", name, err)
		}
		defer rc.Close()
		var buf bytes.Buffer
		if _, err := buf.ReadFrom(rc); err != nil {
			t.Fatalf("read %s: %v", name, err)
		}
		return buf.String()
	}
	t.Fatalf("%s not found in zip", name)
	return ""
}

// composerBase returns a SubmissionBase wired with the neutral
// stylemap for composer tests.
func composerBase() *SubmissionBase {
	return &SubmissionBase{
		ID:   uuid.New(),
		Slug: "test-base",
		SectionSpec: BaseSectionSpec{
			Version: 1,
			Stylemap: map[string]string{
				"paragraph": "Normal",
			},
		},
	}
}

func TestComposer_AppendMode_NoAnchors(t *testing.T) {
	// Base has no anchors → composer appends sections before sectPr.
	base := composerBase()
	body := `<w:p><w:r><w:t>Static chrome</w:t></w:r></w:p><w:sectPr/>`
	baseBytes := minimalBaseBytes(t, body)
	composer := NewSubmissionComposer(NewSubmissionRenderer())

	sections := []SubmissionSection{
		{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "Section text"},
	}
	out, err := composer.Compose(context.Background(), ComposeOptions{
		Sections:  sections,
		Base:      base,
		BaseBytes: baseBytes,
		Lang:      "de",
		Vars:      PlaceholderMap{},
	})
	if err != nil {
		t.Fatalf("Compose: %v", err)
	}
	docXML := extractDocumentXML(t, out)
	if !strings.Contains(docXML, "Static chrome") {
		t.Errorf("base chrome dropped: %q", docXML)
	}
	if !strings.Contains(docXML, "Section text") {
		t.Errorf("section content missing: %q", docXML)
	}
	// Section must land before sectPr (rule of thumb: it's an end-of-body element).
	staticIdx := strings.Index(docXML, "Section text")
	sectPrIdx := strings.Index(docXML, "<w:sectPr")
	if staticIdx < 0 || sectPrIdx < 0 || staticIdx > sectPrIdx {
		t.Errorf("section landed after sectPr: section=%d sectPr=%d", staticIdx, sectPrIdx)
	}
}

func TestComposer_AnchorMode_SpliceContent(t *testing.T) {
	base := composerBase()
	body := `<w:p><w:r><w:t>Header</w:t></w:r></w:p>` +
		`<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p>` +
		`<w:p><w:r><w:t>(seed)</w:t></w:r></w:p>` +
		`<w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>` +
		`<w:p><w:r><w:t>Footer</w:t></w:r></w:p>`
	baseBytes := minimalBaseBytes(t, body)
	composer := NewSubmissionComposer(NewSubmissionRenderer())

	sections := []SubmissionSection{
		{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "Real prose"},
	}
	out, err := composer.Compose(context.Background(), ComposeOptions{
		Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
	})
	if err != nil {
		t.Fatalf("Compose: %v", err)
	}
	docXML := extractDocumentXML(t, out)
	if !strings.Contains(docXML, "Header") || !strings.Contains(docXML, "Footer") {
		t.Errorf("base chrome dropped: %q", docXML)
	}
	if !strings.Contains(docXML, "Real prose") {
		t.Errorf("section content missing: %q", docXML)
	}
	// Anchor paragraphs themselves must be gone.
	if strings.Contains(docXML, "{{#section:facts}}") || strings.Contains(docXML, "{{/section:facts}}") {
		t.Errorf("anchor markers survived: %q", docXML)
	}
	// Seed content between anchors must be gone (replaced by the
	// composed section).
	if strings.Contains(docXML, "(seed)") {
		t.Errorf("anchor-spanned seed survived: %q", docXML)
	}
}

func TestComposer_ExcludedSection_DropsAnchorPair(t *testing.T) {
	base := composerBase()
	body := `<w:p><w:r><w:t>Header</w:t></w:r></w:p>` +
		`<w:p><w:r><w:t>{{#section:exhibits}}</w:t></w:r></w:p>` +
		`<w:p><w:r><w:t>(default)</w:t></w:r></w:p>` +
		`<w:p><w:r><w:t>{{/section:exhibits}}</w:t></w:r></w:p>` +
		`<w:p><w:r><w:t>Footer</w:t></w:r></w:p>`
	baseBytes := minimalBaseBytes(t, body)
	composer := NewSubmissionComposer(NewSubmissionRenderer())

	sections := []SubmissionSection{
		{ID: uuid.New(), SectionKey: "exhibits", OrderIndex: 8, Kind: "prose", Included: false, ContentMDDE: "ignored"},
	}
	out, err := composer.Compose(context.Background(), ComposeOptions{
		Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
	})
	if err != nil {
		t.Fatalf("Compose: %v", err)
	}
	docXML := extractDocumentXML(t, out)
	if strings.Contains(docXML, "{{#section:exhibits}}") || strings.Contains(docXML, "{{/section:exhibits}}") {
		t.Errorf("anchors for excluded section survived: %q", docXML)
	}
	if strings.Contains(docXML, "ignored") {
		t.Errorf("excluded section content rendered: %q", docXML)
	}
}

func TestComposer_PlaceholdersResolve(t *testing.T) {
	base := composerBase()
	body := `<w:p><w:r><w:t>{{#section:greeting}}</w:t></w:r></w:p>` +
		`<w:p><w:r><w:t>{{/section:greeting}}</w:t></w:r></w:p>`
	baseBytes := minimalBaseBytes(t, body)
	composer := NewSubmissionComposer(NewSubmissionRenderer())

	sections := []SubmissionSection{
		{ID: uuid.New(), SectionKey: "greeting", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "Hallo {{user.name}}"},
	}
	out, err := composer.Compose(context.Background(), ComposeOptions{
		Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
		Vars: PlaceholderMap{"user.name": "Maria Schmidt"},
	})
	if err != nil {
		t.Fatalf("Compose: %v", err)
	}
	docXML := extractDocumentXML(t, out)
	if !strings.Contains(docXML, "Hallo") || !strings.Contains(docXML, "Maria Schmidt") {
		t.Errorf("placeholder not substituted: %q", docXML)
	}
	if strings.Contains(docXML, "{{user.name}}") {
		t.Errorf("placeholder survived: %q", docXML)
	}
}

func TestComposer_LangPicksColumn(t *testing.T) {
	base := composerBase()
	body := `<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>`
	baseBytes := minimalBaseBytes(t, body)
	composer := NewSubmissionComposer(NewSubmissionRenderer())

	sections := []SubmissionSection{
		{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true,
			ContentMDDE: "deutscher text", ContentMDEN: "english text"},
	}
	deOut, _ := composer.Compose(context.Background(), ComposeOptions{
		Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
	})
	enOut, _ := composer.Compose(context.Background(), ComposeOptions{
		Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "en",
	})
	deXML := extractDocumentXML(t, deOut)
	enXML := extractDocumentXML(t, enOut)
	if !strings.Contains(deXML, "deutscher text") || strings.Contains(deXML, "english text") {
		t.Errorf("DE pick failed: %q", deXML)
	}
	if !strings.Contains(enXML, "english text") || strings.Contains(enXML, "deutscher text") {
		t.Errorf("EN pick failed: %q", enXML)
	}
}

// Slice D — rich-prose end-to-end through the composer.

func TestComposer_HeadingsAndLists(t *testing.T) {
	base := composerBase()
	// Extend the stylemap so the walker has named styles to apply.
	base.SectionSpec.Stylemap["heading_1"] = "Heading1"
	base.SectionSpec.Stylemap["list_bullet"] = "ListBullet"
	base.SectionSpec.Stylemap["list_numbered"] = "ListNumber"
	base.SectionSpec.Stylemap["blockquote"] = "Quote"

	body := `<w:p><w:r><w:t>{{#section:body}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:body}}</w:t></w:r></w:p>`
	baseBytes := minimalBaseBytes(t, body)
	composer := NewSubmissionComposer(NewSubmissionRenderer())

	md := "# Heading line\n\n- bullet a\n- bullet b\n\n1. first\n2. second\n\n> quoted"
	sections := []SubmissionSection{
		{ID: uuid.New(), SectionKey: "body", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: md},
	}
	out, err := composer.Compose(context.Background(), ComposeOptions{
		Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
	})
	if err != nil {
		t.Fatalf("Compose: %v", err)
	}
	docXML := extractDocumentXML(t, out)

	for _, want := range []string{
		`<w:pStyle w:val="Heading1"/>`,
		`<w:pStyle w:val="ListBullet"/>`,
		`<w:pStyle w:val="ListNumber"/>`,
		`<w:pStyle w:val="Quote"/>`,
		"Heading line",
		"bullet a",
		"bullet b",
		`<w:t xml:space="preserve">1. </w:t>`,
		`<w:t xml:space="preserve">2. </w:t>`,
		"first",
		"second",
		"quoted",
	} {
		if !strings.Contains(docXML, want) {
			t.Errorf("expected %q in composed body; got: %s", want, docXML)
		}
	}
}

func TestComposer_HyperlinkWiresRels(t *testing.T) {
	base := composerBase()
	body := `<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>`
	baseBytes := minimalBaseBytes(t, body)
	composer := NewSubmissionComposer(NewSubmissionRenderer())

	sections := []SubmissionSection{
		{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true,
			ContentMDDE: "See [BGH](https://bgh.bund.de) and [EuGH](https://curia.europa.eu)."},
	}
	out, err := composer.Compose(context.Background(), ComposeOptions{
		Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
	})
	if err != nil {
		t.Fatalf("Compose: %v", err)
	}

	// Body: hyperlink elements with composer rIds.
	docXML := extractDocumentXML(t, out)
	if !strings.Contains(docXML, `<w:hyperlink r:id="rIdComposer1">`) ||
		!strings.Contains(docXML, `<w:hyperlink r:id="rIdComposer2">`) {
		t.Errorf("hyperlink rIds missing in body: %q", docXML)
	}
	if !strings.Contains(docXML, "BGH") || !strings.Contains(docXML, "EuGH") {
		t.Errorf("hyperlink labels missing: %q", docXML)
	}

	// Rels: the matching <Relationship> rows must be in
	// word/_rels/document.xml.rels with the URL targets + External mode.
	rels := extractZipEntry(t, out, "word/_rels/document.xml.rels")
	for _, want := range []string{
		`Id="rIdComposer1"`,
		`Id="rIdComposer2"`,
		`Target="https://bgh.bund.de"`,
		`Target="https://curia.europa.eu"`,
		`TargetMode="External"`,
		"hyperlink", // the Type URL contains "hyperlink"
	} {
		if !strings.Contains(rels, want) {
			t.Errorf("expected %q in document.xml.rels: %s", want, rels)
		}
	}
}

func TestComposer_HyperlinkDedupesByURL(t *testing.T) {
	base := composerBase()
	body := `<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>`
	baseBytes := minimalBaseBytes(t, body)
	composer := NewSubmissionComposer(NewSubmissionRenderer())

	// Same URL referenced twice — should produce one rId, two
	// <w:hyperlink> elements both pointing at it.
	sections := []SubmissionSection{
		{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true,
			ContentMDDE: "First [BGH](https://bgh.bund.de) and again [Bundesgerichtshof](https://bgh.bund.de)."},
	}
	out, _ := composer.Compose(context.Background(), ComposeOptions{
		Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
	})
	docXML := extractDocumentXML(t, out)
	if strings.Count(docXML, `<w:hyperlink r:id="rIdComposer1">`) != 2 {
		t.Errorf("expected 2 hyperlinks sharing rIdComposer1; got: %s", docXML)
	}
	if strings.Contains(docXML, `<w:hyperlink r:id="rIdComposer2">`) {
		t.Errorf("dedupe failed — second rId allocated for same URL: %s", docXML)
	}
}

// Slice E — base swap preserves section content; only chrome / styles
// change. This is the design's "Markdown is base-agnostic" contract
// from Q10 + §5.3 ratification. We compose the SAME section text
// against two bases with DIFFERENT stylemaps and verify:
//   1. The section text appears in both outputs.
//   2. Each base applies its OWN paragraph style (the stylemap diff
//      is the only visible delta in the document body).

func TestComposer_BaseSwapPreservesContent(t *testing.T) {
	body := `<w:p><w:r><w:t>{{#section:facts}}</w:t></w:r></w:p><w:p><w:r><w:t>{{/section:facts}}</w:t></w:r></w:p>`
	baseBytes := minimalBaseBytes(t, body)

	// Base A: HLC-style stylemap.
	hlc := &SubmissionBase{
		ID: uuid.New(), Slug: "hlc-test",
		SectionSpec: BaseSectionSpec{
			Stylemap: map[string]string{
				"paragraph": "HLpat-Body-B0",
				"heading_1": "HLpat-Heading-H1",
			},
		},
	}
	// Base B: LG-style stylemap.
	lg := &SubmissionBase{
		ID: uuid.New(), Slug: "lg-test",
		SectionSpec: BaseSectionSpec{
			Stylemap: map[string]string{
				"paragraph": "LG-Body",
				"heading_1": "LG-Heading1",
			},
		},
	}

	// Identical Markdown content rendered against each base.
	md := "# Heading line\n\nA paragraph of substantive prose."
	sections := []SubmissionSection{
		{ID: uuid.New(), SectionKey: "facts", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: md},
	}

	composer := NewSubmissionComposer(NewSubmissionRenderer())
	hlcOut, err := composer.Compose(context.Background(), ComposeOptions{
		Sections: sections, Base: hlc, BaseBytes: baseBytes, Lang: "de",
	})
	if err != nil {
		t.Fatalf("Compose hlc: %v", err)
	}
	lgOut, err := composer.Compose(context.Background(), ComposeOptions{
		Sections: sections, Base: lg, BaseBytes: baseBytes, Lang: "de",
	})
	if err != nil {
		t.Fatalf("Compose lg: %v", err)
	}

	hlcXML := extractDocumentXML(t, hlcOut)
	lgXML := extractDocumentXML(t, lgOut)

	// Content survives both ways.
	for _, want := range []string{"Heading line", "A paragraph of substantive prose."} {
		if !strings.Contains(hlcXML, want) {
			t.Errorf("HLC output missing content %q", want)
		}
		if !strings.Contains(lgXML, want) {
			t.Errorf("LG output missing content %q", want)
		}
	}

	// Stylemap diff actually shows up in the body — HLC's headings
	// use HLpat-Heading-H1, LG's use LG-Heading1. If the composer
	// silently passed the wrong stylemap, this would fire.
	if !strings.Contains(hlcXML, `<w:pStyle w:val="HLpat-Heading-H1"/>`) {
		t.Errorf("HLC heading style missing: %s", hlcXML)
	}
	if !strings.Contains(lgXML, `<w:pStyle w:val="LG-Heading1"/>`) {
		t.Errorf("LG heading style missing: %s", lgXML)
	}
	if strings.Contains(hlcXML, `<w:pStyle w:val="LG-Heading1"/>`) {
		t.Errorf("HLC output leaked LG style: %s", hlcXML)
	}
	if strings.Contains(lgXML, `<w:pStyle w:val="HLpat-Heading-H1"/>`) {
		t.Errorf("LG output leaked HLC style: %s", lgXML)
	}
}

func TestComposer_OrderIndexAscending(t *testing.T) {
	base := composerBase()
	// No anchors → both sections append in order_index ASC order
	// before sectPr.
	body := `<w:sectPr/>`
	baseBytes := minimalBaseBytes(t, body)
	composer := NewSubmissionComposer(NewSubmissionRenderer())

	sections := []SubmissionSection{
		{ID: uuid.New(), SectionKey: "second", OrderIndex: 2, Kind: "prose", Included: true, ContentMDDE: "ZWEITER"},
		{ID: uuid.New(), SectionKey: "first", OrderIndex: 1, Kind: "prose", Included: true, ContentMDDE: "ERSTER"},
	}
	out, err := composer.Compose(context.Background(), ComposeOptions{
		Sections: sections, Base: base, BaseBytes: baseBytes, Lang: "de",
	})
	if err != nil {
		t.Fatalf("Compose: %v", err)
	}
	docXML := extractDocumentXML(t, out)
	firstIdx := strings.Index(docXML, "ERSTER")
	secondIdx := strings.Index(docXML, "ZWEITER")
	if firstIdx < 0 || secondIdx < 0 || firstIdx > secondIdx {
		t.Errorf("order_index ASC not honoured: ERSTER=%d ZWEITER=%d", firstIdx, secondIdx)
	}
}
