package services

import (
	"bytes"
	"encoding/json"
	"fmt"
	"sort"
	"strconv"
	"strings"
	"time"

	userdto "astrology-api/dto/kundali"
)

//////////////////////////////////////////////////////////////
// Kundali Report PDF
//////////////////////////////////////////////////////////////

// The full kundali report is rendered here rather than fetched from
// VedicAstroAPI. Their pdf/horoscope endpoint is not enabled on every plan and
// currently answers with an internal error, so the only way to hand the apps a
// working download link is to build the document from the sections we already
// have.
//
// It is written directly, with no PDF dependency: the module set this project
// builds against has none, and adding one for a text report would put a network
// fetch between the repository and a working build. What follows is a small
// subset of PDF 1.4 — A4 pages, the two base-14 Helvetica faces, headings,
// key/value rows and wrapped paragraphs — which is all a data report needs.
//
// Text is encoded WinAnsi, so the document is Latin-1. A kundali requested in
// Hindi still returns its JSON in Hindi; only the PDF transliterates away what
// WinAnsi cannot hold. Embedding a Devanagari font is the fix when that matters.

const (
	pdfPageWidth = 595.28

	pdfPageHeight = 841.89

	pdfMarginLeft = 48.0

	pdfMarginRight = 48.0

	pdfMarginTop = 56.0

	pdfMarginBottom = 56.0

	pdfFontRegular = "F1"

	pdfFontBold = "F2"
)

// pdfContentWidth is the usable text width on a page.
const pdfContentWidth = pdfPageWidth - pdfMarginLeft - pdfMarginRight

//////////////////////////////////////////////////////////////
// Font Metrics
//////////////////////////////////////////////////////////////

// helveticaWidths holds the advance width of every printable ASCII character in
// 1/1000 em, from the Helvetica AFM. Without these, wrapping guesses and lines
// either overflow the margin or stop short of it.
var helveticaWidths = [95]int{
	278, 278, 355, 556, 556, 889, 667, 191, 333, 333, // space ! " # $ % & ' ( )
	389, 584, 278, 333, 278, 278, // * + , - . /
	556, 556, 556, 556, 556, 556, 556, 556, 556, 556, // 0-9
	278, 278, 584, 584, 584, 556, 1015, // : ; < = > ? @
	667, 667, 722, 722, 667, 611, 778, 722, 278, 500, // A-J
	667, 556, 833, 722, 778, 667, 778, 722, 667, 611, // K-T
	722, 667, 944, 667, 667, 611, // U-Z
	278, 278, 278, 469, 556, 333, // [ \ ] ^ _ `
	556, 556, 500, 556, 556, 278, 556, 556, 222, 222, // a-j
	500, 222, 833, 556, 556, 556, 556, 333, 500, 278, // k-t
	556, 500, 722, 500, 500, 500, // u-z
	334, 260, 334, 584, // { | } ~
}

// pdfTextWidth measures a line at a given size. Bold is approximated from the
// regular metrics — Helvetica-Bold runs about 7% wider — which is close enough
// for headings, the only bold text here, and none of them wrap.
func pdfTextWidth(text string, size float64, bold bool) float64 {

	total := 0

	for _, char := range text {

		index := int(char) - 32

		if index >= 0 && index < len(helveticaWidths) {
			total += helveticaWidths[index]
		} else {
			total += 556
		}
	}

	width := float64(total) * size / 1000

	if bold {
		width *= 1.07
	}

	return width
}

//////////////////////////////////////////////////////////////
// Text Sanitising
//////////////////////////////////////////////////////////////

// pdfReplacements maps the typography the vendor's copy is full of onto
// characters WinAnsi can render.
var pdfReplacements = strings.NewReplacer(
	"‘", "'",
	"’", "'",
	"“", "\"",
	"”", "\"",
	"–", "-",
	"—", "-",
	"•", "-",
	"…", "...",
	" ", " ",
	"\t", " ",
	"\r", " ",
	"\n", " ",
)

// pdfSanitise makes a string safe to place in a content stream: normalised
// punctuation, no control characters, and nothing outside Latin-1.
func pdfSanitise(text string) string {

	text = pdfReplacements.Replace(text)

	var builder strings.Builder

	for _, char := range text {

		switch {

		case char == '(' || char == ')' || char == '\\':

			builder.WriteByte('\\')
			builder.WriteByte(byte(char))

		case char >= 32 && char <= 126:

			builder.WriteByte(byte(char))

		case char >= 160 && char <= 255:

			builder.WriteByte(byte(char))

		default:

			// Outside Latin-1: dropped rather than rendered as a wrong glyph.
		}
	}

	return strings.TrimSpace(builder.String())
}

//////////////////////////////////////////////////////////////
// Document
//////////////////////////////////////////////////////////////

type pdfDocument struct {
	pages []*bytes.Buffer

	current *bytes.Buffer

	// y is the baseline for the next line, measured from the bottom.
	y float64
}

func newPDFDocument() *pdfDocument {

	document := &pdfDocument{}

	document.addPage()

	return document
}

func (d *pdfDocument) addPage() {

	d.closePage()

	d.current = &bytes.Buffer{}

	d.pages = append(d.pages, d.current)

	d.y = pdfPageHeight - pdfMarginTop
}

// closePage stamps the page number, which is only known once the page is done.
func (d *pdfDocument) closePage() {

	if d.current == nil {
		return
	}

	label := fmt.Sprintf("%d", len(d.pages))

	width := pdfTextWidth(label, 8, false)

	fmt.Fprintf(
		d.current,
		"BT /%s 8 Tf 0.55 0.55 0.6 rg 1 0 0 1 %.2f %.2f Tm (%s) Tj ET\n",
		pdfFontRegular,
		(pdfPageWidth-width)/2,
		pdfMarginBottom/2,
		label,
	)
}

// ensure starts a new page when the next block would not fit.
func (d *pdfDocument) ensure(height float64) {

	if d.y-height < pdfMarginBottom {
		d.addPage()
	}
}

// line draws one line of text at an indent and advances past it.
func (d *pdfDocument) line(text string, size float64, bold bool, indent float64, gray float64) {

	d.ensure(size * 1.35)

	font := pdfFontRegular

	if bold {
		font = pdfFontBold
	}

	fmt.Fprintf(
		d.current,
		"BT /%s %.1f Tf %.2f %.2f %.2f rg 1 0 0 1 %.2f %.2f Tm (%s) Tj ET\n",
		font,
		size,
		gray,
		gray,
		gray*1.05,
		pdfMarginLeft+indent,
		d.y,
		text,
	)

	d.y -= size * 1.35
}

func (d *pdfDocument) space(height float64) {

	d.y -= height
}

func (d *pdfDocument) rule() {

	d.ensure(8)

	fmt.Fprintf(
		d.current,
		"0.85 0.85 0.88 RG 0.7 w %.2f %.2f m %.2f %.2f l S\n",
		pdfMarginLeft,
		d.y+4,
		pdfPageWidth-pdfMarginRight,
		d.y+4,
	)

	d.y -= 8
}

// wrap splits text into lines that fit the given width.
func (d *pdfDocument) wrap(text string, size float64, bold bool, width float64) []string {

	words := strings.Fields(text)

	if len(words) == 0 {
		return nil
	}

	lines := []string{}

	current := words[0]

	for _, word := range words[1:] {

		candidate := current + " " + word

		if pdfTextWidth(candidate, size, bold) <= width {

			current = candidate

			continue
		}

		lines = append(lines, current)

		current = word
	}

	return append(lines, current)
}

//////////////////////////////////////////////////////////////
// Blocks
//////////////////////////////////////////////////////////////

func (d *pdfDocument) title(text string) {

	d.ensure(40)

	for _, line := range d.wrap(text, 22, true, pdfContentWidth) {
		d.line(line, 22, true, 0, 0.1)
	}

	d.space(6)
}

// groupHeading starts a top-level group. Each one begins on a fresh page, so
// the report can be navigated by flipping through it.
func (d *pdfDocument) groupHeading(text string) {

	d.addPage()

	for _, line := range d.wrap(text, 16, true, pdfContentWidth) {
		d.line(line, 16, true, 0, 0.15)
	}

	d.rule()
	d.space(2)
}

func (d *pdfDocument) sectionHeading(text string) {

	d.ensure(34)
	d.space(6)

	for _, line := range d.wrap(text, 12, true, pdfContentWidth) {
		d.line(line, 12, true, 0, 0.2)
	}

	d.space(1)
}

func (d *pdfDocument) subHeading(text string, indent float64) {

	d.ensure(24)
	d.space(3)

	for _, line := range d.wrap(text, 10, true, pdfContentWidth-indent) {
		d.line(line, 10, true, indent, 0.3)
	}
}

func (d *pdfDocument) paragraph(text string, indent float64) {

	for _, line := range d.wrap(text, 9.5, false, pdfContentWidth-indent) {
		d.line(line, 9.5, false, indent, 0.25)
	}
}

// keyValue lays out a label and its value, wrapping the value under the label
// when it is too long to sit beside it.
func (d *pdfDocument) keyValue(label string, value string, indent float64) {

	labelWidth := pdfTextWidth(label+": ", 9.5, true)

	valueWidth := pdfContentWidth - indent - labelWidth

	if pdfTextWidth(value, 9.5, false) <= valueWidth {

		d.ensure(13)

		fmt.Fprintf(
			d.current,
			"BT /%s 9.5 Tf 0.35 0.35 0.4 rg 1 0 0 1 %.2f %.2f Tm (%s:) Tj ET\n",
			pdfFontBold,
			pdfMarginLeft+indent,
			d.y,
			label,
		)

		fmt.Fprintf(
			d.current,
			"BT /%s 9.5 Tf 0.15 0.15 0.2 rg 1 0 0 1 %.2f %.2f Tm (%s) Tj ET\n",
			pdfFontRegular,
			pdfMarginLeft+indent+labelWidth,
			d.y,
			value,
		)

		d.y -= 13

		return
	}

	d.line(label+":", 9.5, true, indent, 0.35)

	d.paragraph(value, indent+10)
}

//////////////////////////////////////////////////////////////
// Rendering Arbitrary Vendor JSON
//////////////////////////////////////////////////////////////

// Every section is the vendor's own JSON, whose shape differs per endpoint and
// changes without notice. Rather than hand-lay-out forty shapes, the report
// walks the decoded value: scalars become key/value rows, long strings become
// paragraphs, and nested objects and arrays become indented blocks. That keeps a
// new section a one-line addition here, and keeps an unexpected shape readable
// instead of missing.

const (
	pdfMaxDepth = 4

	pdfMaxArrayItems = 40

	// Strings longer than this read as prose, not as a field value.
	pdfProseLength = 90
)

// prettyLabel turns "sign_lord" or "signLord" into "Sign Lord".
func prettyLabel(key string) string {

	key = strings.ReplaceAll(key, "_", " ")

	var spaced strings.Builder

	for index, char := range key {

		if index > 0 && char >= 'A' && char <= 'Z' {
			spaced.WriteByte(' ')
		}

		spaced.WriteRune(char)
	}

	words := strings.Fields(spaced.String())

	for index, word := range words {

		if len(word) > 3 && strings.ToUpper(word) == word {
			continue
		}

		words[index] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:])
	}

	return strings.Join(words, " ")
}

// scalarText renders a JSON scalar, or reports that the value is not one.
func scalarText(value interface{}) (string, bool) {

	switch typed := value.(type) {

	case nil:
		return "-", true

	case bool:
		if typed {
			return "Yes", true
		}
		return "No", true

	case string:
		return typed, true

	case json.Number:

		if number, err := typed.Float64(); err == nil {

			if number == float64(int64(number)) {
				return strconv.FormatInt(int64(number), 10), true
			}

			return strconv.FormatFloat(number, 'f', 2, 64), true
		}

		return typed.String(), true
	}

	return "", false
}

// renderValue writes one decoded JSON value at the given indent.
func (d *pdfDocument) renderValue(label string, value interface{}, indent float64, depth int) {

	if text, ok := scalarText(value); ok {

		text = pdfSanitise(text)

		if text == "" {
			return
		}

		switch {

		case label == "":
			d.paragraph(text, indent)

		case len(text) > pdfProseLength:
			d.subHeading(pdfSanitise(prettyLabel(label)), indent)
			d.paragraph(text, indent)

		default:
			d.keyValue(pdfSanitise(prettyLabel(label)), text, indent)
		}

		return
	}

	if depth >= pdfMaxDepth {
		return
	}

	switch typed := value.(type) {

	case map[string]interface{}:

		if label != "" {
			d.subHeading(pdfSanitise(prettyLabel(label)), indent)
		}

		// Scalars first, so the summary of a block reads before its detail.
		keys := make([]string, 0, len(typed))

		for key := range typed {
			keys = append(keys, key)
		}

		sort.Strings(keys)

		for _, pass := range []bool{true, false} {

			for _, key := range keys {

				_, isScalar := scalarText(typed[key])

				if isScalar != pass {
					continue
				}

				d.renderValue(key, typed[key], indent+10, depth+1)
			}
		}

	case []interface{}:

		if label != "" {
			d.subHeading(pdfSanitise(prettyLabel(label)), indent)
		}

		limit := len(typed)

		if limit > pdfMaxArrayItems {
			limit = pdfMaxArrayItems
		}

		for index := 0; index < limit; index++ {

			if text, ok := scalarText(typed[index]); ok {

				text = pdfSanitise(text)

				if text != "" {
					d.paragraph("- "+text, indent+10)
				}

				continue
			}

			d.space(2)

			d.renderValue("", typed[index], indent+10, depth+1)
		}

		if len(typed) > limit {

			d.paragraph(
				fmt.Sprintf("... and %d more entries, available in the API response", len(typed)-limit),
				indent+10,
			)
		}
	}
}

// renderSection decodes one vendor body and lays it out under its heading. A
// section that failed upstream is simply absent, and says so.
func (d *pdfDocument) renderSection(heading string, raw json.RawMessage) {

	d.sectionHeading(pdfSanitise(heading))

	if len(raw) == 0 {

		d.paragraph("Not available for this kundali.", 10)

		return
	}

	decoder := json.NewDecoder(bytes.NewReader(raw))

	decoder.UseNumber()

	var value interface{}

	if err := decoder.Decode(&value); err != nil {

		d.paragraph("Could not be read.", 10)

		return
	}

	d.renderValue("", value, 10, 0)
}

// renderKeyedSections lays out a per-planet or per-division map in a stable
// order, so two reports for the same person read the same way.
func (d *pdfDocument) renderKeyedSections(heading string, entries map[string]json.RawMessage, order []string) {

	if len(entries) == 0 {
		return
	}

	d.sectionHeading(pdfSanitise(heading))

	rendered := map[string]bool{}

	render := func(key string) {

		if rendered[key] {
			return
		}

		rendered[key] = true

		raw, found := entries[key]

		if !found {
			return
		}

		d.subHeading(pdfSanitise(prettyLabel(key)), 10)

		decoder := json.NewDecoder(bytes.NewReader(raw))

		decoder.UseNumber()

		var value interface{}

		if err := decoder.Decode(&value); err != nil {
			return
		}

		d.renderValue("", value, 20, 1)
	}

	for _, key := range order {
		render(key)
	}

	remaining := make([]string, 0, len(entries))

	for key := range entries {

		if !rendered[key] {
			remaining = append(remaining, key)
		}
	}

	sort.Strings(remaining)

	for _, key := range remaining {
		render(key)
	}
}

//////////////////////////////////////////////////////////////
// The Report
//////////////////////////////////////////////////////////////

// RenderKundaliReportPDF builds the full report: a cover with the birth details
// followed by every section that came back, in the same order as the response.
func RenderKundaliReportPDF(
	opts KundaliOptions,
	details *userdto.KundaliDetails,
) ([]byte, error) {

	if details == nil {
		return nil, fmt.Errorf("no kundali details to report on")
	}

	document := newPDFDocument()

	//------------------------------------------------
	// Cover
	//------------------------------------------------

	document.title("Kundali Report")

	document.line(pdfSanitise(opts.Name), 14, true, 0, 0.2)

	document.space(4)
	document.rule()

	document.keyValue("Date of Birth", pdfSanitise(opts.BirthDate), 0)
	document.keyValue("Time of Birth", pdfSanitise(opts.BirthTime), 0)
	document.keyValue("Place of Birth", pdfSanitise(opts.BirthPlace), 0)

	if opts.Gender != "" {
		document.keyValue("Gender", pdfSanitise(opts.Gender), 0)
	}

	document.keyValue(
		"Coordinates",
		fmt.Sprintf("%.4f, %.4f", opts.Latitude, opts.Longitude),
		0,
	)

	document.keyValue("Timezone", fmt.Sprintf("GMT %+.1f", opts.Timezone), 0)

	document.keyValue("Chart Style", pdfSanitise(prettyLabel(opts.ChartStyle))+" Indian", 0)

	document.keyValue("Generated", time.Now().Format("02 Jan 2006, 15:04"), 0)

	document.space(8)
	document.rule()

	document.paragraph(
		"Charts are rendered as images by the kundali API and are not embedded in this document.",
		0,
	)

	//------------------------------------------------
	// Basic details
	//------------------------------------------------

	if basic := details.BasicDetails; basic != nil {

		document.groupHeading("Basic Details")

		document.renderSection("Panchang", basic.Panchang)
		document.renderSection("Ashtakvarga", basic.Ashtakvarga)
		document.renderSection("Manglik Dosh", basic.ManglikDosh)
	}

	//------------------------------------------------
	// Chart
	//------------------------------------------------

	if chart := details.Chart; chart != nil {

		document.groupHeading("Chart")

		document.renderSection("Planet Details", chart.PlanetDetails)
		document.renderSection("Personal Characteristics", chart.PersonalCharacteristics)
	}

	//------------------------------------------------
	// KP
	//------------------------------------------------

	if kp := details.KP; kp != nil {

		document.groupHeading("KP")

		document.renderSection("KP Houses", kp.KPHouses)
		document.renderSection("KP Planets", kp.KPPlanets)
	}

	//------------------------------------------------
	// Ashtakvarga
	//------------------------------------------------

	if ashtakvarga := details.Ashtakvarga; ashtakvarga != nil {

		document.groupHeading("Ashtakvarga")

		document.renderSection("Sarvashtakvarga", ashtakvarga.Ashtakvarga)
	}

	//------------------------------------------------
	// Dasha
	//------------------------------------------------

	if dasha := details.Dasha; dasha != nil {

		document.groupHeading("Dasha")

		if dasha.Vimshottari != nil {

			document.renderSection("Vimshottari Mahadasha", dasha.Vimshottari.Mahadasha)
			document.renderSection("Mahadasha Predictions", dasha.Vimshottari.MahadashaPredictions)
		}

		if dasha.Yogini != nil {

			document.renderSection("Yogini Dasha", dasha.Yogini.YoginiDashaMain)
			document.renderSection("Yogini Sub Dasha", dasha.Yogini.YoginiDashaSub)
		}
	}

	//------------------------------------------------
	// Reports
	//------------------------------------------------

	if reports := details.Reports; reports != nil {

		document.groupHeading("Reports")

		if general := reports.General; general != nil {

			document.renderSection("Personal Characteristics", general.PersonalCharacteristics)
			document.renderSection("List of Yogas", general.ListOfYogas)
			document.renderKeyedSections("Planet Reports", general.PlanetReport, reportPlanets)
		}

		if remedies := reports.Remedies; remedies != nil {

			document.renderSection("Gem Suggestion", remedies.GemSuggestion)
			document.renderSection("Rudraksh Suggestion", remedies.RudrakshSuggestion)
			document.renderSection("Numerology", remedies.NumeroTable)
		}

		if dosha := reports.Dosha; dosha != nil {

			document.renderSection("Mangal Dosh", dosha.MangalDosh)
			document.renderSection("Kaalsarp Dosh", dosha.KaalsarpDosh)
			document.renderSection("Sade Sati Table", dosha.SadeSatiTable)
			document.renderSection("Current Sade Sati", dosha.CurrentSadeSati)
		}
	}

	//------------------------------------------------
	// Others
	//------------------------------------------------

	if others := details.Others; others != nil {

		document.groupHeading("Others")

		document.renderSection("Extended Kundli Details", others.ExtendedKundliDetails)
		document.renderSection("Ascendant Report", others.AscendantReport)
		document.renderSection("Planetary Aspects", others.PlanetaryAspects)
		document.renderSection("Planets In Houses", others.PlanetsInHouses)
		document.renderSection("12 Month Prediction", others.AI12MonthPrediction)
		document.renderSection("Antardasha", others.Antardasha)

		document.renderKeyedSections("Divisional Charts", others.DivisionalCharts, chartDivisions)
		document.renderKeyedSections("Binnashtakvarga", others.Binnashtakvarga, binnashtakvargaPlanets)
	}

	return document.build(), nil
}

//////////////////////////////////////////////////////////////
// Serialisation
//////////////////////////////////////////////////////////////

// build writes the object table, the cross-reference table and the trailer.
func (d *pdfDocument) build() []byte {

	d.closePage()

	// Object 1 catalog, 2 page tree, 3 and 4 the fonts, then a page object and
	// a content stream per page.
	pageCount := len(d.pages)

	objects := make([]string, 0, 4+pageCount*2)

	kids := make([]string, 0, pageCount)

	for index := 0; index < pageCount; index++ {
		kids = append(kids, fmt.Sprintf("%d 0 R", 5+index*2))
	}

	objects = append(objects, "<< /Type /Catalog /Pages 2 0 R >>")

	objects = append(objects, fmt.Sprintf(
		"<< /Type /Pages /Kids [%s] /Count %d >>",
		strings.Join(kids, " "),
		pageCount,
	))

	objects = append(objects,
		"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")

	objects = append(objects,
		"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>")

	for index, page := range d.pages {

		content := page.String()

		objects = append(objects, fmt.Sprintf(
			"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2f %.2f] "+
				"/Resources << /Font << /%s 3 0 R /%s 4 0 R >> >> /Contents %d 0 R >>",
			pdfPageWidth,
			pdfPageHeight,
			pdfFontRegular,
			pdfFontBold,
			6+index*2,
		))

		objects = append(objects, fmt.Sprintf(
			"<< /Length %d >>\nstream\n%s\nendstream",
			len(content)+1,
			content,
		))
	}

	var out bytes.Buffer

	out.WriteString("%PDF-1.4\n")

	offsets := make([]int, len(objects))

	for index, object := range objects {

		offsets[index] = out.Len()

		fmt.Fprintf(&out, "%d 0 obj\n%s\nendobj\n", index+1, object)
	}

	xrefOffset := out.Len()

	fmt.Fprintf(&out, "xref\n0 %d\n", len(objects)+1)

	out.WriteString("0000000000 65535 f \n")

	for _, offset := range offsets {
		fmt.Fprintf(&out, "%010d 00000 n \n", offset)
	}

	fmt.Fprintf(
		&out,
		"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n",
		len(objects)+1,
		xrefOffset,
	)

	return out.Bytes()
}
