package nextcloud

import (
	"context"
	"encoding/xml"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"
)

var ErrSyncTokenInvalid = errors.New("nextcloud contacts sync token invalid")

type AddressBook struct {
	ID          string `json:"id"`
	DisplayName string `json:"display_name"`
	Path        string `json:"path"`
}

type Contact struct {
	UID      string `json:"uid"`
	FullName string `json:"full_name"`
	Email    string `json:"email"`
	Phone    string `json:"phone"`
	Org      string `json:"org"`
	Path     string `json:"path,omitempty"`
	ETag     string `json:"etag,omitempty"`
	RawVCard string `json:"raw_vcard,omitempty"`
}

// ContactSyncResult is the delta from a CardDAV sync-collection REPORT.
type ContactSyncResult struct {
	SyncToken string   `json:"sync_token"`
	Contacts  []Contact `json:"contacts"`
	Deleted   []string `json:"deleted"`
}

func (c *Client) ListAddressBooks(ctx context.Context, userID string) ([]AddressBook, error) {
	path := addressBookHomePath(userID)
	body := `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:">
  <d:prop>
    <d:displayname/>
    <d:resourcetype/>
  </d:prop>
</d:propfind>`

	resp, err := c.DoAsUser(ctx, "PROPFIND", path, strings.NewReader(body), userID, map[string]string{
		"Depth":        "1",
		"Content-Type": "application/xml",
	})
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	raw, err := readResponseBody(resp)
	if err != nil {
		return nil, err
	}
	if err := davResponseError(raw, resp.StatusCode); err != nil {
		return nil, err
	}
	return parseAddressBookList(strings.NewReader(string(raw)), path)
}

func (c *Client) ListContacts(ctx context.Context, userID, bookPath string) ([]Contact, error) {
	body := `<?xml version="1.0" encoding="UTF-8"?>
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
  <d:prop>
    <d:getetag/>
    <card:address-data/>
  </d:prop>
</card:addressbook-query>`

	resp, err := c.DoAsUser(ctx, "REPORT", bookPath, strings.NewReader(body), userID, map[string]string{
		"Depth":        "1",
		"Content-Type": "application/xml",
	})
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	raw, err := readResponseBody(resp)
	if err != nil {
		return nil, err
	}
	if err := davResponseError(raw, resp.StatusCode); err != nil {
		return nil, err
	}
	return parseContactList(strings.NewReader(string(raw)))
}

func (c *Client) SyncContacts(ctx context.Context, userID, bookPath, syncToken string) (ContactSyncResult, error) {
	body := buildSyncCollectionRequest(syncToken)
	resp, err := c.DoAsUser(ctx, "REPORT", bookPath, strings.NewReader(body), userID, map[string]string{
		"Depth":        "1",
		"Content-Type": "application/xml",
	})
	if err != nil {
		return ContactSyncResult{}, err
	}
	defer resp.Body.Close()

	switch resp.StatusCode {
	case http.StatusForbidden:
		return ContactSyncResult{}, ErrSyncTokenInvalid
	case http.StatusOK, http.StatusMultiStatus:
		return parseContactSyncResponse(resp.Body)
	default:
		return ContactSyncResult{}, &HTTPStatusError{Operation: "sync contacts", StatusCode: resp.StatusCode}
	}
}

func (c *Client) CreateContact(ctx context.Context, userID, bookPath string, contact *Contact) (*Contact, error) {
	vcard := contactVCardPayload(contact)
	uid := contact.UID
	if uid == "" {
		uid = fmt.Sprintf("%d@ulti", time.Now().UnixNano())
		contact.UID = uid
	}
	contactPath := fmt.Sprintf("%s%s.vcf", bookPath, uid)

	resp, err := c.DoAsUser(ctx, "PUT", contactPath, strings.NewReader(vcard), userID, map[string]string{
		"Content-Type": "text/vcard; charset=utf-8",
	})
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != 201 && resp.StatusCode != 204 {
		return nil, fmt.Errorf("create contact failed: %d", resp.StatusCode)
	}
	return c.GetContact(ctx, userID, contactPath)
}

func (c *Client) UpdateContact(ctx context.Context, userID, contactPath, ifMatch string, contact *Contact) (string, error) {
	contactPath = normalizeDAVHref(contactPath)
	vcard := contactVCardPayload(contact)
	headers := map[string]string{
		"Content-Type": "text/vcard; charset=utf-8",
	}
	if strings.TrimSpace(ifMatch) != "" {
		headers["If-Match"] = strings.TrimSpace(ifMatch)
	}

	resp, err := c.DoAsUser(ctx, "PUT", contactPath, strings.NewReader(vcard), userID, headers)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode == http.StatusPreconditionFailed {
		return "", ErrETagMismatch
	}
	if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusCreated {
		return "", fmt.Errorf("update contact failed: %d", resp.StatusCode)
	}
	return strings.TrimSpace(resp.Header.Get("ETag")), nil
}

func (c *Client) GetContact(ctx context.Context, userID, contactPath string) (*Contact, error) {
	contactPath = normalizeDAVHref(contactPath)
	resp, err := c.DoAsUser(ctx, "GET", contactPath, nil, userID, nil)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("get contact failed: %d", resp.StatusCode)
	}
	raw, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
	contact := parseVCard(string(raw))
	contact.Path = contactPath
	contact.ETag = strings.TrimSpace(resp.Header.Get("ETag"))
	contact.RawVCard = string(raw)
	return &contact, nil
}

func (c *Client) DeleteContact(ctx context.Context, userID, contactPath string) error {
	contactPath = normalizeDAVHref(contactPath)
	resp, err := c.DoAsUser(ctx, "DELETE", contactPath, nil, userID, nil)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != 204 {
		return fmt.Errorf("delete contact failed: %d", resp.StatusCode)
	}
	return nil
}

func (c *Client) SearchContacts(ctx context.Context, userID, bookPath, query string) ([]Contact, error) {
	body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
  <d:prop>
    <d:getetag/>
    <card:address-data/>
  </d:prop>
  <card:filter>
    <card:prop-filter name="FN">
      <card:text-match collation="i;unicode-casemap" match-type="contains">%s</card:text-match>
    </card:prop-filter>
  </card:filter>
</card:addressbook-query>`, query)

	resp, err := c.DoAsUser(ctx, "REPORT", bookPath, strings.NewReader(body), userID, map[string]string{
		"Depth":        "1",
		"Content-Type": "application/xml",
	})
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	raw, err := readResponseBody(resp)
	if err != nil {
		return nil, err
	}
	if err := davResponseError(raw, resp.StatusCode); err != nil {
		return nil, err
	}
	return parseContactList(strings.NewReader(string(raw)))
}

func contactVCardPayload(contact *Contact) string {
	if contact != nil {
		if raw := strings.TrimSpace(contact.RawVCard); raw != "" {
			return raw
		}
	}
	return buildVCard(contact)
}

func buildVCard(contact *Contact) string {
	var b strings.Builder
	b.WriteString("BEGIN:VCARD\r\n")
	b.WriteString("VERSION:3.0\r\n")
	if contact.UID != "" {
		b.WriteString(fmt.Sprintf("UID:%s\r\n", contact.UID))
	}
	b.WriteString(fmt.Sprintf("FN:%s\r\n", contact.FullName))
	if contact.Email != "" {
		b.WriteString(fmt.Sprintf("EMAIL;TYPE=INTERNET:%s\r\n", contact.Email))
	}
	if contact.Phone != "" {
		b.WriteString(fmt.Sprintf("TEL;TYPE=CELL:%s\r\n", contact.Phone))
	}
	if contact.Org != "" {
		b.WriteString(fmt.Sprintf("ORG:%s\r\n", contact.Org))
	}
	b.WriteString("END:VCARD\r\n")
	return b.String()
}

func parseAddressBookList(body io.Reader, basePath string) ([]AddressBook, error) {
	var ms multistatus
	if err := xml.NewDecoder(body).Decode(&ms); err != nil {
		return nil, err
	}

	basePath = normalizeDAVHref(basePath)
	books := make([]AddressBook, 0)
	for _, r := range ms.Responses {
		href := normalizeDAVHref(r.Href)
		if href == basePath {
			continue
		}
		name := r.Propstat.Prop.DisplayName
		if name == "" {
			continue
		}
		books = append(books, AddressBook{
			ID:          strings.TrimSuffix(strings.TrimPrefix(href, basePath), "/"),
			DisplayName: name,
			Path:        href,
		})
	}
	return books, nil
}

func normalizeDAVHref(href string) string {
	href = strings.TrimSpace(href)
	for {
		switch {
		case strings.HasPrefix(href, "/cloud/"):
			href = strings.TrimPrefix(href, "/cloud")
		case strings.HasPrefix(href, "cloud/"):
			href = strings.TrimPrefix(href, "cloud")
		default:
			if href != "" && !strings.HasPrefix(href, "/") {
				href = "/" + href
			}
			return href
		}
	}
}

func buildSyncCollectionRequest(syncToken string) string {
	var b strings.Builder
	b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
	b.WriteString(`<d:sync-collection xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">`)
	if syncToken != "" {
		b.WriteString("<d:sync-token>")
		b.WriteString(xmlEscape(syncToken))
		b.WriteString("</d:sync-token>")
	}
	b.WriteString(`<d:sync-level>1</d:sync-level>`)
	b.WriteString(`<d:prop><d:getetag/><card:address-data/></d:prop>`)
	b.WriteString(`</d:sync-collection>`)
	return b.String()
}

func xmlEscape(s string) string {
	var b strings.Builder
	_ = xml.EscapeText(&b, []byte(s))
	return b.String()
}

func parseContactList(body io.Reader) ([]Contact, error) {
	var ms cardMultistatus
	if err := xml.NewDecoder(body).Decode(&ms); err != nil {
		return nil, err
	}

	contacts := make([]Contact, 0, len(ms.Responses))
	for _, r := range ms.Responses {
		if isAddressBookCollectionHref(r.Href) {
			continue
		}
		contact, ok := contactFromCardProp(r.Href, r.Propstat.Prop)
		if !ok {
			continue
		}
		contacts = append(contacts, contact)
	}
	return contacts, nil
}

func parseContactSyncResponse(body io.Reader) (ContactSyncResult, error) {
	var ms cardSyncMultistatus
	if err := xml.NewDecoder(body).Decode(&ms); err != nil {
		return ContactSyncResult{}, err
	}

	out := ContactSyncResult{
		SyncToken: strings.TrimSpace(ms.SyncToken),
		Contacts:  make([]Contact, 0, len(ms.Responses)),
		Deleted:   make([]string, 0),
	}
	for _, r := range ms.Responses {
		if isAddressBookCollectionHref(r.Href) {
			continue
		}
		if isDeletedSyncResponse(r) {
			out.Deleted = append(out.Deleted, r.Href)
			continue
		}
		contact, ok := contactFromCardProp(r.Href, r.Propstat.Prop)
		if !ok {
			continue
		}
		out.Contacts = append(out.Contacts, contact)
	}
	return out, nil
}

func contactFromCardProp(href string, prop cardProp) (Contact, bool) {
	vcard := strings.TrimSpace(prop.AddressData)
	if vcard == "" {
		return Contact{}, false
	}
	contact := parseVCard(vcard)
	contact.Path = normalizeDAVHref(href)
	contact.ETag = normalizeETag(prop.ETag)
	contact.RawVCard = vcard
	return contact, true
}

func normalizeETag(etag string) string {
	return strings.TrimSpace(etag)
}

func isAddressBookCollectionHref(href string) bool {
	return strings.HasSuffix(href, "/")
}

func isDeletedSyncResponse(r cardSyncResponse) bool {
	if statusIndicatesDeleted(r.Status) {
		return true
	}
	return statusIndicatesDeleted(r.Propstat.Status)
}

func statusIndicatesDeleted(status string) bool {
	return strings.Contains(status, "404")
}

func parseVCard(vcard string) Contact {
	var c Contact
	for _, line := range strings.Split(vcard, "\n") {
		line = strings.TrimSpace(line)
		switch {
		case strings.HasPrefix(line, "UID:"):
			c.UID = strings.TrimPrefix(line, "UID:")
		case strings.HasPrefix(line, "FN:"):
			c.FullName = strings.TrimPrefix(line, "FN:")
		case strings.HasPrefix(line, "EMAIL"):
			if idx := strings.LastIndex(line, ":"); idx >= 0 {
				c.Email = line[idx+1:]
			}
		case strings.HasPrefix(line, "TEL"):
			if idx := strings.LastIndex(line, ":"); idx >= 0 {
				c.Phone = line[idx+1:]
			}
		case strings.HasPrefix(line, "ORG:"):
			c.Org = strings.TrimPrefix(line, "ORG:")
		}
	}
	return c
}

type cardMultistatus struct {
	XMLName   xml.Name       `xml:"multistatus"`
	Responses []cardResponse `xml:"response"`
}

type cardResponse struct {
	Href     string       `xml:"href"`
	Propstat cardPropstat `xml:"propstat"`
}

type cardSyncMultistatus struct {
	XMLName   xml.Name           `xml:"multistatus"`
	SyncToken string             `xml:"sync-token"`
	Responses []cardSyncResponse `xml:"response"`
}

type cardSyncResponse struct {
	Href     string       `xml:"href"`
	Status   string       `xml:"status"`
	Propstat cardPropstat `xml:"propstat"`
}

type cardPropstat struct {
	Status string   `xml:"status"`
	Prop   cardProp `xml:"prop"`
}

type cardProp struct {
	ETag        string `xml:"getetag"`
	AddressData string `xml:"address-data"`
}
