package services

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"encoding/xml"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"slices"
	"strings"
	"time"
)

// ErrCalendarNameTaken is returned by MakeCalendar when the server
// rejects MKCALENDAR with 405 — name already in use.
var ErrCalendarNameTaken = errors.New("calendar name already taken on server")

// ErrMKCalendarUnsupported is returned by MakeCalendar when the server
// outright rejects MKCALENDAR (403/501) — should never fire after a
// successful probe, but kept as a defence so we don't loop.
var ErrMKCalendarUnsupported = errors.New("server does not support MKCALENDAR")

// Tiny CalDAV HTTP client — only the verbs Paliad needs:
//   - PUT  (create / replace event)
//   - GET  (fetch event by path)
//   - DELETE (remove event)
//   - PROPFIND with depth=1 (list calendar contents + ETags)
//   - PROPFIND on the calendar URL (used by the "test connection" button)
//
// We don't use REPORT because PROPFIND-depth-1 returns enough for our needs
// (href + getetag + calendar-data via a follow-up GET) and is supported
// by every server Paliad has been tested against (Nextcloud, Radicale,
// Baikal, mailcow SOGo).

type calDAVClient struct {
	baseURL  string // server root or calendar URL
	username string
	password string
	hc       *http.Client
}

func newCalDAVClient(baseURL, username, password string) *calDAVClient {
	return &calDAVClient{
		baseURL:  strings.TrimRight(baseURL, "/"),
		username: username,
		password: password,
		hc: &http.Client{
			Timeout: 30 * time.Second,
		},
	}
}

func (c *calDAVClient) do(ctx context.Context, method, path, body, contentType string) (*http.Response, error) {
	u := c.absURL(path)
	req, err := http.NewRequestWithContext(ctx, method, u, strings.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.SetBasicAuth(c.username, c.password)
	if contentType != "" {
		req.Header.Set("Content-Type", contentType)
	}
	return c.hc.Do(req)
}

// absURL resolves path against baseURL. If path is already absolute (starts
// with http:// or https://), it's used as-is.
func (c *calDAVClient) absURL(path string) string {
	if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
		return path
	}
	if path == "" {
		return c.baseURL
	}
	if strings.HasPrefix(path, "/") {
		// Same scheme + host as base.
		base, err := url.Parse(c.baseURL)
		if err == nil && base.Scheme != "" && base.Host != "" {
			return base.Scheme + "://" + base.Host + path
		}
	}
	return c.baseURL + "/" + strings.TrimLeft(path, "/")
}

// PutEvent uploads body to <calendarPath>/<uid>.ics. Returns the new ETag.
func (c *calDAVClient) PutEvent(ctx context.Context, calendarPath, uid, body string) (string, error) {
	path := joinPath(calendarPath, uid+".ics")
	resp, err := c.do(ctx, "PUT", path, body, "text/calendar; charset=utf-8")
	if err != nil {
		return "", fmt.Errorf("PUT: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 300 {
		raw, _ := io.ReadAll(resp.Body)
		return "", fmt.Errorf("PUT %s: %d %s — %s", path, resp.StatusCode, resp.Status, string(raw))
	}
	etag := resp.Header.Get("ETag")
	if etag == "" {
		// Some servers don't return ETag on PUT — re-PROPFIND to fetch it.
		entries, ferr := c.PropfindCalendar(ctx, calendarPath)
		if ferr == nil {
			for _, e := range entries {
				if strings.HasSuffix(e.Href, uid+".ics") {
					etag = e.ETag
					break
				}
			}
		}
	}
	return etag, nil
}

// DeleteEvent removes a previously-PUT event. 404 is treated as success.
func (c *calDAVClient) DeleteEvent(ctx context.Context, calendarPath, uid string) error {
	path := joinPath(calendarPath, uid+".ics")
	resp, err := c.do(ctx, "DELETE", path, "", "")
	if err != nil {
		return fmt.Errorf("DELETE: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
		return nil
	}
	if resp.StatusCode >= 300 {
		return fmt.Errorf("DELETE %s: %d %s", path, resp.StatusCode, resp.Status)
	}
	return nil
}

// GetEvent fetches the raw iCalendar body of one event.
func (c *calDAVClient) GetEvent(ctx context.Context, href string) (string, error) {
	resp, err := c.do(ctx, "GET", href, "", "")
	if err != nil {
		return "", fmt.Errorf("GET: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("GET %s: %d", href, resp.StatusCode)
	}
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", fmt.Errorf("read body: %w", err)
	}
	return string(body), nil
}

// CalDAVEntry is one row from a depth-1 PROPFIND on a calendar.
type CalDAVEntry struct {
	Href string
	ETag string
}

// PropfindCalendar lists all event hrefs + ETags inside calendarPath.
func (c *calDAVClient) PropfindCalendar(ctx context.Context, calendarPath string) ([]CalDAVEntry, error) {
	body := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
  <d:prop>
    <d:getetag/>
    <d:resourcetype/>
  </d:prop>
</d:propfind>`
	req, err := http.NewRequestWithContext(ctx, "PROPFIND", c.absURL(calendarPath), strings.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.SetBasicAuth(c.username, c.password)
	req.Header.Set("Depth", "1")
	req.Header.Set("Content-Type", "application/xml; charset=utf-8")
	resp, err := c.hc.Do(req)
	if err != nil {
		return nil, fmt.Errorf("PROPFIND: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 207 {
		raw, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("PROPFIND %s: %d %s — %s", calendarPath, resp.StatusCode, resp.Status, string(raw))
	}
	return parseMultiStatus(resp.Body)
}

// multigetMaxHrefs caps the number of hrefs in one REPORT request to keep
// us well within Google's documented limit (~200) and iCloud's
// rate-shaping. Callers chunk larger lists into multiple requests.
const multigetMaxHrefs = 100

// MultigetEvent is one (href, etag, calendar-data) result returned by
// ReportMultiget. CalendarData is the raw iCalendar body and is fed
// straight into parseICalendar; ETag matches the value that would have
// been returned by PROPFIND for the same href.
type MultigetEvent struct {
	Href         string
	ETag         string
	CalendarData string
}

// ReportMultiget runs a `REPORT calendar-multiget` (RFC 4791 §7.9)
// against calendarPath and returns one MultigetEvent per requested href.
// Hrefs missing from the response (404 inside the multistatus) are
// omitted from the returned slice — callers should treat that as a
// remote deletion. Hrefs are auto-chunked at multigetMaxHrefs.
func (c *calDAVClient) ReportMultiget(ctx context.Context, calendarPath string, hrefs []string) ([]MultigetEvent, error) {
	if len(hrefs) == 0 {
		return nil, nil
	}
	out := []MultigetEvent{}
	for start := 0; start < len(hrefs); start += multigetMaxHrefs {
		end := min(start+multigetMaxHrefs, len(hrefs))
		chunk, err := c.reportMultigetChunk(ctx, calendarPath, hrefs[start:end])
		if err != nil {
			return nil, err
		}
		out = append(out, chunk...)
	}
	return out, nil
}

func (c *calDAVClient) reportMultigetChunk(ctx context.Context, calendarPath string, hrefs []string) ([]MultigetEvent, error) {
	var b strings.Builder
	b.WriteString(`<?xml version="1.0" encoding="utf-8"?>
<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  <D:prop>
    <D:getetag/>
    <C:calendar-data/>
  </D:prop>
`)
	for _, h := range hrefs {
		b.WriteString("  <D:href>")
		_ = xml.EscapeText(&b, []byte(h))
		b.WriteString("</D:href>\n")
	}
	b.WriteString(`</C:calendar-multiget>`)

	req, err := http.NewRequestWithContext(ctx, "REPORT", c.absURL(calendarPath), strings.NewReader(b.String()))
	if err != nil {
		return nil, err
	}
	req.SetBasicAuth(c.username, c.password)
	req.Header.Set("Depth", "1")
	req.Header.Set("Content-Type", "application/xml; charset=utf-8")
	resp, err := c.hc.Do(req)
	if err != nil {
		return nil, fmt.Errorf("REPORT: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 207 {
		raw, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("REPORT %s: %d %s — %s", calendarPath, resp.StatusCode, resp.Status, string(raw))
	}
	return parseMultigetResponse(resp.Body)
}

// PropfindRoot performs a Depth:0 PROPFIND on the calendar URL — used by
// the "Test connection" button to verify auth + URL without storing creds.
func (c *calDAVClient) PropfindRoot(ctx context.Context, path string) error {
	body := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
  <d:prop>
    <d:resourcetype/>
    <d:displayname/>
  </d:prop>
</d:propfind>`
	req, err := http.NewRequestWithContext(ctx, "PROPFIND", c.absURL(path), strings.NewReader(body))
	if err != nil {
		return err
	}
	req.SetBasicAuth(c.username, c.password)
	req.Header.Set("Depth", "0")
	req.Header.Set("Content-Type", "application/xml; charset=utf-8")
	resp, err := c.hc.Do(req)
	if err != nil {
		return fmt.Errorf("PROPFIND: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 207 && resp.StatusCode != 200 {
		raw, _ := io.ReadAll(resp.Body)
		return fmt.Errorf("PROPFIND %s: %d %s — %s", path, resp.StatusCode, resp.Status, string(raw))
	}
	return nil
}

// DiscoveredCalendar is one calendar collection enumerated by
// DiscoverCalendars. supportedComponents lists the iCal component types
// the server advertises (VEVENT, VTODO, …); the picker filters to ones
// supporting VEVENT.
type DiscoveredCalendar struct {
	Href                string
	DisplayName         string
	SupportedComponents []string
}

// DiscoverCalendars walks the CalDAV discovery chain (RFC 6764 §6 /
// RFC 6638 §10): server root → current-user-principal → calendar-home-set
// → enumeration of child calendar collections.
//
// Returns the discovered calendars + the calendar-home-set URL so the
// caller can issue MKCALENDAR against it in Slice 2c. Hrefs are
// returned as-is (absolute or path-rooted) per server response; the
// client's absURL handles both at PUT time.
func (c *calDAVClient) DiscoverCalendars(ctx context.Context, serverURL string) ([]DiscoveredCalendar, string, error) {
	principal, err := c.findCurrentUserPrincipal(ctx, serverURL)
	if err != nil {
		return nil, "", fmt.Errorf("current-user-principal: %w", err)
	}
	home, err := c.findCalendarHomeSet(ctx, principal)
	if err != nil {
		return nil, "", fmt.Errorf("calendar-home-set: %w", err)
	}
	calendars, err := c.listCalendars(ctx, home)
	if err != nil {
		return nil, home, fmt.Errorf("list calendars: %w", err)
	}
	return calendars, home, nil
}

func (c *calDAVClient) findCurrentUserPrincipal(ctx context.Context, urlPath string) (string, error) {
	body := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
  <d:prop><d:current-user-principal/></d:prop>
</d:propfind>`
	hrefs, err := c.propfindHrefs(ctx, urlPath, "0", body, "current-user-principal")
	if err != nil {
		return "", err
	}
	if len(hrefs) == 0 {
		return "", fmt.Errorf("server returned no current-user-principal")
	}
	return hrefs[0], nil
}

func (c *calDAVClient) findCalendarHomeSet(ctx context.Context, principalPath string) (string, error) {
	body := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
  <d:prop><c:calendar-home-set/></d:prop>
</d:propfind>`
	hrefs, err := c.propfindHrefs(ctx, principalPath, "0", body, "calendar-home-set")
	if err != nil {
		return "", err
	}
	if len(hrefs) == 0 {
		return "", fmt.Errorf("server returned no calendar-home-set")
	}
	return hrefs[0], nil
}

func (c *calDAVClient) listCalendars(ctx context.Context, homePath string) ([]DiscoveredCalendar, error) {
	body := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
  <d:prop>
    <d:resourcetype/>
    <d:displayname/>
    <c:supported-calendar-component-set/>
  </d:prop>
</d:propfind>`
	req, err := http.NewRequestWithContext(ctx, "PROPFIND", c.absURL(homePath), strings.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.SetBasicAuth(c.username, c.password)
	req.Header.Set("Depth", "1")
	req.Header.Set("Content-Type", "application/xml; charset=utf-8")
	resp, err := c.hc.Do(req)
	if err != nil {
		return nil, fmt.Errorf("PROPFIND: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 207 {
		raw, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("PROPFIND %s: %d %s — %s", homePath, resp.StatusCode, resp.Status, string(raw))
	}
	var ms calendarHomeMultiStatus
	if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
		return nil, fmt.Errorf("decode home-set multistatus: %w", err)
	}
	out := []DiscoveredCalendar{}
	for _, r := range ms.Responses {
		var displayname string
		isCalendar := false
		comps := []string{}
		for _, ps := range r.Propstat {
			if !strings.Contains(ps.Status, "200") {
				continue
			}
			if ps.Prop.ResourceType.Calendar != nil {
				isCalendar = true
			}
			if ps.Prop.DisplayName != "" {
				displayname = ps.Prop.DisplayName
			}
			for _, comp := range ps.Prop.SupportedCalendarComponentSet.Comp {
				if comp.Name != "" {
					comps = append(comps, comp.Name)
				}
			}
		}
		if !isCalendar {
			continue
		}
		// Filter to calendars that advertise VEVENT support — task / address
		// books slip into the home-set on Apple iCloud and we don't want
		// those in the picker.
		if len(comps) > 0 && !slices.Contains(comps, "VEVENT") {
			continue
		}
		out = append(out, DiscoveredCalendar{
			Href:                r.Href,
			DisplayName:         displayname,
			SupportedComponents: comps,
		})
	}
	return out, nil
}

// propfindHrefs runs a PROPFIND and returns the hrefs nested under the
// named property's value. Used for current-user-principal +
// calendar-home-set extraction where the property body is a single href.
func (c *calDAVClient) propfindHrefs(ctx context.Context, urlPath, depth, body, propName string) ([]string, error) {
	req, err := http.NewRequestWithContext(ctx, "PROPFIND", c.absURL(urlPath), strings.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.SetBasicAuth(c.username, c.password)
	req.Header.Set("Depth", depth)
	req.Header.Set("Content-Type", "application/xml; charset=utf-8")
	resp, err := c.hc.Do(req)
	if err != nil {
		return nil, fmt.Errorf("PROPFIND: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != 207 && resp.StatusCode != 200 {
		raw, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("PROPFIND %s: %d %s — %s", urlPath, resp.StatusCode, resp.Status, string(raw))
	}
	var ms propHrefMultiStatus
	if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil {
		return nil, fmt.Errorf("decode multistatus for %s: %w", propName, err)
	}
	out := []string{}
	for _, r := range ms.Responses {
		for _, ps := range r.Propstat {
			if !strings.Contains(ps.Status, "200") {
				continue
			}
			for _, h := range ps.Prop.CurrentUserPrincipal.Hrefs {
				out = append(out, h)
			}
			for _, h := range ps.Prop.CalendarHomeSet.Hrefs {
				out = append(out, h)
			}
		}
	}
	return out, nil
}

// --- MKCALENDAR capability probe + provisioning (Slice 2c) ---

// ProbeMKCalendar reports whether the CalDAV server accepts MKCALENDAR
// against the calendar-home-set. Two-step per design §4.2:
//
//  1. OPTIONS on the home URL — if the server returns `Allow:` listing
//     MKCALENDAR, we're done.
//  2. Synthetic probe — issue MKCALENDAR against a random
//     `.paliad-probe-<short>/` path and DELETE it. Catches legacy SOGo
//     and misconfigured Radicales that don't list MKCALENDAR in Allow
//     but still accept it. Servers that 405/501 the synthetic probe
//     are recorded as no-MKCALENDAR; further attempts skip the probe.
//
// The probe never persists state — that's the service-layer's job via
// CalDAVService.MakeCalendar.
func (c *calDAVClient) ProbeMKCalendar(ctx context.Context, homePath string) (bool, error) {
	if allows, err := c.optionsAllows(ctx, homePath); err == nil {
		if slices.Contains(allows, "MKCALENDAR") {
			return true, nil
		}
		// OPTIONS responded but doesn't list MKCALENDAR — fall through to
		// synthetic probe; some servers omit MKCALENDAR from Allow even
		// when they accept it. OPTIONS-returns-no-MKCALENDAR is not a
		// hard negative.
	}
	// Synthetic probe — a single MKCALENDAR against a randomised name
	// that the server is overwhelmingly unlikely to already have.
	probePath := joinPath(homePath, ".paliad-probe-"+randomToken(6)+"/")
	mkBody := `<?xml version="1.0" encoding="utf-8"?>
<C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  <D:set><D:prop><D:displayname>paliad-probe</D:displayname></D:prop></D:set>
</C:mkcalendar>`
	req, err := http.NewRequestWithContext(ctx, "MKCALENDAR", c.absURL(probePath), strings.NewReader(mkBody))
	if err != nil {
		return false, err
	}
	req.SetBasicAuth(c.username, c.password)
	req.Header.Set("Content-Type", "application/xml; charset=utf-8")
	resp, err := c.hc.Do(req)
	if err != nil {
		return false, fmt.Errorf("MKCALENDAR probe: %w", err)
	}
	resp.Body.Close()
	switch resp.StatusCode {
	case http.StatusCreated, http.StatusOK:
		// Server accepted the probe. Tear down the probe collection so
		// we don't leak a junk calendar; if the DELETE fails we shrug
		// (best effort — the user's calendar list will have one
		// .paliad-probe-* entry; not the end of the world).
		_ = c.deleteCollection(ctx, probePath)
		return true, nil
	case http.StatusMethodNotAllowed, http.StatusNotImplemented, http.StatusForbidden:
		return false, nil
	default:
		// Unknown — treat as no-MKCALENDAR to be safe; the user can
		// still bind by URL.
		return false, nil
	}
}

// MakeCalendar issues MKCALENDAR against home/<calendarName>/ and
// returns the absolute path that was created. The caller is
// responsible for picking a free slug; 405 from the server means
// "name already taken — pick another".
func (c *calDAVClient) MakeCalendar(ctx context.Context, homePath, calendarName, displayName string) (string, error) {
	path := joinPath(homePath, calendarName+"/")
	body := mkcalendarBody(displayName)
	req, err := http.NewRequestWithContext(ctx, "MKCALENDAR", c.absURL(path), strings.NewReader(body))
	if err != nil {
		return "", err
	}
	req.SetBasicAuth(c.username, c.password)
	req.Header.Set("Content-Type", "application/xml; charset=utf-8")
	resp, err := c.hc.Do(req)
	if err != nil {
		return "", fmt.Errorf("MKCALENDAR: %w", err)
	}
	defer resp.Body.Close()
	switch resp.StatusCode {
	case http.StatusCreated, http.StatusOK:
		return path, nil
	case http.StatusMethodNotAllowed:
		return "", ErrCalendarNameTaken
	case http.StatusForbidden, http.StatusNotImplemented:
		return "", ErrMKCalendarUnsupported
	default:
		raw, _ := io.ReadAll(resp.Body)
		return "", fmt.Errorf("MKCALENDAR %s: %d %s — %s", path, resp.StatusCode, resp.Status, string(raw))
	}
}

func mkcalendarBody(displayName string) string {
	var b strings.Builder
	b.WriteString(`<?xml version="1.0" encoding="utf-8"?>
<C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  <D:set>
    <D:prop>
      <D:displayname>`)
	_ = xml.EscapeText(&b, []byte(displayName))
	b.WriteString(`</D:displayname>
      <C:supported-calendar-component-set>
        <C:comp name="VEVENT"/>
      </C:supported-calendar-component-set>
    </D:prop>
  </D:set>
</C:mkcalendar>`)
	return b.String()
}

// optionsAllows returns the methods listed in the Allow header of an
// OPTIONS response. Caseless match per RFC 7231 §7.4.1.
func (c *calDAVClient) optionsAllows(ctx context.Context, path string) ([]string, error) {
	req, err := http.NewRequestWithContext(ctx, "OPTIONS", c.absURL(path), nil)
	if err != nil {
		return nil, err
	}
	req.SetBasicAuth(c.username, c.password)
	resp, err := c.hc.Do(req)
	if err != nil {
		return nil, fmt.Errorf("OPTIONS: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 400 {
		return nil, fmt.Errorf("OPTIONS %s: %d", path, resp.StatusCode)
	}
	out := []string{}
	for _, h := range resp.Header.Values("Allow") {
		for _, m := range strings.Split(h, ",") {
			out = append(out, strings.ToUpper(strings.TrimSpace(m)))
		}
	}
	return out, nil
}

// deleteCollection sends a DELETE that doesn't care about 404.
func (c *calDAVClient) deleteCollection(ctx context.Context, path string) error {
	req, err := http.NewRequestWithContext(ctx, "DELETE", c.absURL(path), nil)
	if err != nil {
		return err
	}
	req.SetBasicAuth(c.username, c.password)
	resp, err := c.hc.Do(req)
	if err != nil {
		return err
	}
	resp.Body.Close()
	return nil
}

// randomToken returns a short hex string of `n` bytes. Used for the
// synthetic MKCALENDAR probe path; doesn't need to be cryptographically
// strong (the worst-case is a collision with an existing calendar of
// the same name, which we catch as ErrCalendarNameTaken upstream).
func randomToken(n int) string {
	buf := make([]byte, n)
	_, _ = rand.Read(buf)
	return hex.EncodeToString(buf)
}

// joinPath cleans up double slashes between calendar path and uid.
func joinPath(base, name string) string {
	base = strings.TrimRight(base, "/")
	name = strings.TrimLeft(name, "/")
	if base == "" {
		return "/" + name
	}
	return base + "/" + name
}

// --- Multistatus parsing ---

type msResponse struct {
	XMLName  xml.Name   `xml:"DAV: response"`
	Href     string     `xml:"DAV: href"`
	Propstat []propStat `xml:"DAV: propstat"`
}

type propStat struct {
	XMLName xml.Name `xml:"DAV: propstat"`
	Status  string   `xml:"DAV: status"`
	Prop    struct {
		ETag         string `xml:"DAV: getetag"`
		CalendarData string `xml:"urn:ietf:params:xml:ns:caldav calendar-data"`
		ResourceType struct {
			Collection *struct{} `xml:"DAV: collection"`
		} `xml:"DAV: resourcetype"`
	} `xml:"DAV: prop"`
}

type multiStatus struct {
	XMLName   xml.Name     `xml:"DAV: multistatus"`
	Responses []msResponse `xml:"DAV: response"`
}

// propHrefMultiStatus is used to extract <DAV:href> children out of the
// <D:current-user-principal/> and <C:calendar-home-set/> properties.
// Both render as: <prop><name><href>…</href></name></prop>.
type propHrefMultiStatus struct {
	XMLName   xml.Name              `xml:"DAV: multistatus"`
	Responses []propHrefResponse    `xml:"DAV: response"`
}

type propHrefResponse struct {
	XMLName  xml.Name           `xml:"DAV: response"`
	Href     string             `xml:"DAV: href"`
	Propstat []propHrefPropstat `xml:"DAV: propstat"`
}

type propHrefPropstat struct {
	XMLName xml.Name `xml:"DAV: propstat"`
	Status  string   `xml:"DAV: status"`
	Prop    struct {
		CurrentUserPrincipal struct {
			Hrefs []string `xml:"DAV: href"`
		} `xml:"DAV: current-user-principal"`
		CalendarHomeSet struct {
			Hrefs []string `xml:"DAV: href"`
		} `xml:"urn:ietf:params:xml:ns:caldav calendar-home-set"`
	} `xml:"DAV: prop"`
}

// calendarHomeMultiStatus parses the response to a Depth:1 PROPFIND on
// calendar-home-set asking for resourcetype + displayname +
// supported-calendar-component-set.
type calendarHomeMultiStatus struct {
	XMLName   xml.Name                  `xml:"DAV: multistatus"`
	Responses []calendarHomeResponse    `xml:"DAV: response"`
}

type calendarHomeResponse struct {
	XMLName  xml.Name                  `xml:"DAV: response"`
	Href     string                    `xml:"DAV: href"`
	Propstat []calendarHomePropstat    `xml:"DAV: propstat"`
}

type calendarHomePropstat struct {
	XMLName xml.Name `xml:"DAV: propstat"`
	Status  string   `xml:"DAV: status"`
	Prop    struct {
		DisplayName  string `xml:"DAV: displayname"`
		ResourceType struct {
			Calendar *struct{} `xml:"urn:ietf:params:xml:ns:caldav calendar"`
		} `xml:"DAV: resourcetype"`
		SupportedCalendarComponentSet struct {
			Comp []struct {
				Name string `xml:"name,attr"`
			} `xml:"urn:ietf:params:xml:ns:caldav comp"`
		} `xml:"urn:ietf:params:xml:ns:caldav supported-calendar-component-set"`
	} `xml:"DAV: prop"`
}

func parseMultigetResponse(r io.Reader) ([]MultigetEvent, error) {
	var ms multiStatus
	dec := xml.NewDecoder(r)
	if err := dec.Decode(&ms); err != nil {
		return nil, fmt.Errorf("decode multistatus: %w", err)
	}
	out := []MultigetEvent{}
	for _, resp := range ms.Responses {
		var etag, data string
		ok := false
		for _, ps := range resp.Propstat {
			if !strings.Contains(ps.Status, "200") {
				continue
			}
			etag = strings.Trim(ps.Prop.ETag, `"`)
			data = ps.Prop.CalendarData
			if data != "" {
				ok = true
			}
		}
		if !ok {
			// 404 / 403 on this specific href — treat as missing, skip.
			continue
		}
		out = append(out, MultigetEvent{Href: resp.Href, ETag: etag, CalendarData: data})
	}
	return out, nil
}

func parseMultiStatus(r io.Reader) ([]CalDAVEntry, error) {
	var ms multiStatus
	dec := xml.NewDecoder(r)
	if err := dec.Decode(&ms); err != nil {
		return nil, fmt.Errorf("decode multistatus: %w", err)
	}
	out := []CalDAVEntry{}
	for _, resp := range ms.Responses {
		var etag string
		isCollection := false
		for _, ps := range resp.Propstat {
			if !strings.Contains(ps.Status, "200") {
				continue
			}
			etag = strings.Trim(ps.Prop.ETag, `"`)
			if ps.Prop.ResourceType.Collection != nil {
				isCollection = true
			}
		}
		if isCollection {
			continue // skip the calendar itself
		}
		if !strings.HasSuffix(strings.ToLower(resp.Href), ".ics") {
			continue
		}
		out = append(out, CalDAVEntry{Href: resp.Href, ETag: etag})
	}
	return out, nil
}
