package services

import (
	"bufio"
	"fmt"
	"strings"
	"time"

	"mgit.msbls.de/m/paliad/internal/models"
)

// Minimal RFC 5545 (iCalendar) writer + reader for VEVENT blocks.
//
// Why hand-rolled instead of github.com/emersion/go-ical?
//   - The Appointment schema is small (Title, Description, Location, Start, End,
//     plus the UID Paliad generates) so a 100-line formatter does the job.
//   - Avoids two third-party dependencies (go-ical + go-webdav) for ~6
//     iCal properties and 4 WebDAV verbs.
//
// Property escaping: per RFC 5545 §3.3.11 we escape `\` `;` `,` and `\n`
// in TEXT values; everything else passes through. Lines are not folded
// (folding is optional per §3.1).

const (
	calProductID = "-//Paliad//Paliad Appointments//EN"
	calVersion   = "2.0"
	icalDateUTC  = "20060102T150405Z"
	icalDateOnly = "20060102"
)

// terminUID is the canonical CalDAV UID for a Paliad Appointment. Paliad-owned
// events round-trip through this format; foreign events have arbitrary
// UIDs and are ignored on pull.
func terminUID(id string) string {
	return "paliad-appointment-" + id + "@paliad.de"
}

// deadlineUID is the canonical iCal UID for a Paliad Deadline exported via
// the chart's iCal feed (t-paliad-177 Slice 2). Distinct prefix from
// terminUID so subscribers can't confuse the two — and so a re-export
// updates the same calendar entry instead of duplicating it.
func deadlineUID(id string) string {
	return "paliad-deadline-" + id + "@paliad.de"
}

// extractAppointmentID returns the Paliad Appointment id (uuid string) embedded in a
// terminUID, or "" when the UID isn't ours.
func extractAppointmentID(uid string) string {
	const prefix = "paliad-appointment-"
	const suffix = "@paliad.de"
	if !strings.HasPrefix(uid, prefix) || !strings.HasSuffix(uid, suffix) {
		return ""
	}
	return uid[len(prefix) : len(uid)-len(suffix)]
}

// formatAppointment renders a single VCALENDAR + VEVENT for an Appointment.
// Output uses CRLF line endings as required by RFC 5545.
func formatAppointment(t *models.Appointment) string {
	var b strings.Builder
	w := func(line string) {
		b.WriteString(line)
		b.WriteString("\r\n")
	}
	w("BEGIN:VCALENDAR")
	w("PRODID:" + calProductID)
	w("VERSION:" + calVersion)
	w("BEGIN:VEVENT")
	w("UID:" + terminUID(t.ID.String()))
	w("DTSTAMP:" + time.Now().UTC().Format(icalDateUTC))
	w("DTSTART:" + t.StartAt.UTC().Format(icalDateUTC))
	if t.EndAt != nil {
		w("DTEND:" + t.EndAt.UTC().Format(icalDateUTC))
	}
	// Prepend "[PENDING] " on the SUMMARY when the appointment is awaiting
	// 4-eye approval (t-paliad-138). External clients (Outlook etc.) thus
	// reflect the unverified state honestly — silence on a pending change
	// would be a worse outcome than visible-but-flagged.
	summary := t.Title
	if t.ApprovalStatus == "pending" {
		summary = "[PENDING] " + t.Title
	}
	w("SUMMARY:" + escapeText(summary))
	if t.Description != nil && *t.Description != "" {
		w("DESCRIPTION:" + escapeText(*t.Description))
	}
	if t.Location != nil && *t.Location != "" {
		w("LOCATION:" + escapeText(*t.Location))
	}
	w("END:VEVENT")
	w("END:VCALENDAR")
	return b.String()
}

// FormatTimelineICS renders a single VCALENDAR with one VEVENT per
// timeline row that is a real actual (kind == "deadline" or
// "appointment"). Projected / milestone rows are deliberately skipped
// (design §7.8, faraday-Q6 / m's pick: trust-erosion otherwise — a
// calendar should never fire predicted dates the user never confirmed).
//
// Deadlines render as all-day events (DTSTART;VALUE=DATE) because the
// substrate marshals due_date as UTC-midnight; appointments render as
// timestamped UTC events. Both UIDs are stable across re-exports so an
// Outlook subscriber sees deduped entries on every refresh.
func FormatTimelineICS(events []TimelineEvent, projectTitle string) string {
	var b strings.Builder
	w := func(line string) {
		b.WriteString(line)
		b.WriteString("\r\n")
	}
	w("BEGIN:VCALENDAR")
	w("PRODID:" + calProductID)
	w("VERSION:" + calVersion)
	if projectTitle != "" {
		w("X-WR-CALNAME:" + escapeText("Paliad — "+projectTitle))
	}
	now := time.Now().UTC().Format(icalDateUTC)
	for _, ev := range events {
		if ev.Date == nil {
			continue
		}
		switch ev.Kind {
		case "deadline":
			w("BEGIN:VEVENT")
			if ev.DeadlineID != nil {
				w("UID:" + deadlineUID(ev.DeadlineID.String()))
			} else {
				// Synthetic UID — shouldn't happen for actuals, but be defensive.
				w("UID:paliad-timeline-" + now + "@paliad.de")
			}
			w("DTSTAMP:" + now)
			w("DTSTART;VALUE=DATE:" + ev.Date.UTC().Format(icalDateOnly))
			w("SUMMARY:" + escapeText(ev.Title))
			if ev.Description != "" {
				w("DESCRIPTION:" + escapeText(ev.Description))
			}
			w("END:VEVENT")
		case "appointment":
			w("BEGIN:VEVENT")
			if ev.AppointmentID != nil {
				w("UID:" + terminUID(ev.AppointmentID.String()))
			} else {
				w("UID:paliad-timeline-" + now + "@paliad.de")
			}
			w("DTSTAMP:" + now)
			w("DTSTART:" + ev.Date.UTC().Format(icalDateUTC))
			w("SUMMARY:" + escapeText(ev.Title))
			if ev.Description != "" {
				w("DESCRIPTION:" + escapeText(ev.Description))
			}
			w("END:VEVENT")
		default:
			// milestone / projected / off_script are visualisation-only —
			// never written to a calendar feed (design §7.8 + faraday-Q6).
			continue
		}
	}
	w("END:VCALENDAR")
	return b.String()
}

func escapeText(s string) string {
	r := strings.NewReplacer(
		`\`, `\\`,
		";", `\;`,
		",", `\,`,
		"\n", `\n`,
		"\r", "",
	)
	return r.Replace(s)
}

func unescapeText(s string) string {
	// Order matters: \\ → marker → final
	out := strings.Builder{}
	for i := 0; i < len(s); i++ {
		c := s[i]
		if c == '\\' && i+1 < len(s) {
			next := s[i+1]
			switch next {
			case 'n', 'N':
				out.WriteByte('\n')
			case ';', ',', '\\':
				out.WriteByte(next)
			default:
				out.WriteByte(c)
				out.WriteByte(next)
			}
			i++
			continue
		}
		out.WriteByte(c)
	}
	return out.String()
}

// parsedEvent is the subset of VEVENT fields Paliad cares about.
type parsedEvent struct {
	UID         string
	Summary     string
	Description string
	Location    string
	DTStart     *time.Time
	DTEnd       *time.Time
}

// parseICalendar walks the byte stream and returns one parsed VEVENT per
// occurrence. Robust against folded lines (RFC 5545 §3.1) — a leading
// space/tab continues the previous line.
func parseICalendar(body string) ([]parsedEvent, error) {
	out := []parsedEvent{}
	sc := bufio.NewScanner(strings.NewReader(body))
	sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)

	var unfolded []string
	for sc.Scan() {
		line := strings.TrimRight(sc.Text(), "\r")
		if len(line) == 0 {
			continue
		}
		if (line[0] == ' ' || line[0] == '\t') && len(unfolded) > 0 {
			unfolded[len(unfolded)-1] += line[1:]
			continue
		}
		unfolded = append(unfolded, line)
	}
	if err := sc.Err(); err != nil {
		return nil, fmt.Errorf("scan ical: %w", err)
	}

	var current *parsedEvent
	for _, line := range unfolded {
		switch {
		case line == "BEGIN:VEVENT":
			current = &parsedEvent{}
		case line == "END:VEVENT":
			if current != nil {
				out = append(out, *current)
			}
			current = nil
		case current != nil:
			name, value := splitProperty(line)
			switch strings.ToUpper(name) {
			case "UID":
				current.UID = value
			case "SUMMARY":
				current.Summary = unescapeText(value)
			case "DESCRIPTION":
				current.Description = unescapeText(value)
			case "LOCATION":
				current.Location = unescapeText(value)
			case "DTSTART":
				if t, ok := parseICalTime(value); ok {
					current.DTStart = &t
				}
			case "DTEND":
				if t, ok := parseICalTime(value); ok {
					current.DTEnd = &t
				}
			}
		}
	}
	return out, nil
}

// splitProperty splits "NAME[;params]:value" into ("NAME", "value"),
// ignoring all parameters (Paliad rounds-trip in UTC so TZID is unused).
func splitProperty(line string) (string, string) {
	colon := strings.IndexByte(line, ':')
	if colon < 0 {
		return line, ""
	}
	head := line[:colon]
	value := line[colon+1:]
	if semi := strings.IndexByte(head, ';'); semi >= 0 {
		head = head[:semi]
	}
	return head, value
}

// parseICalTime accepts "20240101T120000Z", "20240101T120000" (floating),
// or "20240101" (date-only) and returns UTC.
func parseICalTime(value string) (time.Time, bool) {
	formats := []string{icalDateUTC, "20060102T150405", "20060102"}
	for _, f := range formats {
		if t, err := time.Parse(f, value); err == nil {
			return t.UTC(), true
		}
	}
	return time.Time{}, false
}
