package nextcloud

import (
	"context"
	"encoding/json"
	"encoding/xml"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"
)

type FileInfo struct {
	Path         string `json:"path"`
	Name         string `json:"name"`
	Type         string `json:"type"` // "file" or "directory"
	Size         int64  `json:"size"`
	MimeType     string `json:"mime_type"`
	LastModified string `json:"last_modified"`
	ETag         string `json:"etag"`
	FileID       int64  `json:"file_id,omitempty"`
	IsFavorite   bool   `json:"is_favorite"`
	IsShared     bool   `json:"is_shared"`
	Source       string `json:"source,omitempty"`
}

type ShareInfo struct {
	ID                   string `json:"id"`
	Path                 string `json:"path"`
	ShareType            int    `json:"share_type"`
	Permissions          int    `json:"permissions"`
	URL                  string `json:"url"`
	InternalURL          string `json:"internal_url,omitempty"`
	AccessMode           string `json:"access_mode,omitempty"`
	ExpiresAt            string `json:"expires_at,omitempty"`
	CreatedAt            string `json:"created_at,omitempty"`
	ShareWith            string `json:"share_with,omitempty"`
	ShareWithDisplayName string `json:"share_with_displayname,omitempty"`
	OwnerID              string `json:"owner_id,omitempty"`
	OwnerDisplayName     string `json:"owner_displayname,omitempty"`
	FileOwnerID          string `json:"file_owner_id,omitempty"`
	FileOwnerDisplayName string `json:"file_owner_displayname,omitempty"`
	Note                 string `json:"note,omitempty"`
	ItemType             string `json:"item_type,omitempty"`
	HasPassword          bool   `json:"has_password,omitempty"`
	Label                string `json:"label,omitempty"`
	Token                string `json:"token,omitempty"`
}

// CreateShareOptions holds optional OCS share creation parameters.
type CreateShareOptions struct {
	ShareType   int
	Permissions int
	ShareWith   string
	Password    string
	ExpireDate  string
	Note        string
	Label       string
	Attributes  string
	SendMail    bool
	AccessMode  string
}

type HTTPStatusError struct {
	Operation  string
	StatusCode int
}

func (e *HTTPStatusError) Error() string {
	return fmt.Sprintf("%s failed: %d", e.Operation, e.StatusCode)
}

type UserQuota struct {
	Used     int64 `json:"used"`
	Free     int64 `json:"free"`
	Total    int64 `json:"total"`
	Relative int64 `json:"relative"`
}

func (c *Client) ListFiles(ctx context.Context, userID, path string) ([]FileInfo, error) {
	davPath := c.WebDAVPath(userID, path)
	body := `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
  <d:prop>
    <d:getlastmodified/>
    <d:getetag/>
    <d:getcontenttype/>
    <d:getcontentlength/>
    <d:resourcetype/>
    <oc:fileid/>
    <oc:size/>
    <oc:favorite/>
    <oc:share-types/>
    <d:displayname/>
  </d:prop>
</d:propfind>`

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

	if resp.StatusCode != 207 {
		return nil, &HTTPStatusError{Operation: "propfind", StatusCode: resp.StatusCode}
	}

	return parsePropfindResponse(resp.Body, path)
}

func (c *Client) Upload(ctx context.Context, userID, path string, content io.Reader, contentType string) error {
	davPath := c.WebDAVPath(userID, path)
	headers := map[string]string{}
	if contentType != "" {
		headers["Content-Type"] = contentType
	}

	resp, err := c.DoAsUser(ctx, "PUT", davPath, content, userID, headers)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode != 201 && resp.StatusCode != 204 {
		return &HTTPStatusError{Operation: "upload", StatusCode: resp.StatusCode}
	}
	return nil
}

func (c *Client) Download(ctx context.Context, userID, path string) (io.ReadCloser, string, error) {
	path = NormalizeClientFilePath(userID, path)
	davPath := c.WebDAVPath(userID, path)
	resp, err := c.DoAsUser(ctx, "GET", davPath, nil, userID, nil)
	if err != nil {
		return nil, "", err
	}

	if resp.StatusCode != 200 {
		resp.Body.Close()
		return nil, "", &HTTPStatusError{Operation: "download", StatusCode: resp.StatusCode}
	}

	return resp.Body, resp.Header.Get("Content-Type"), nil
}

func (c *Client) CreateFolder(ctx context.Context, userID, path string) error {
	davPath := c.WebDAVPath(userID, path)
	resp, err := c.DoAsUser(ctx, "MKCOL", davPath, nil, userID, nil)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode != 201 {
		return &HTTPStatusError{Operation: "mkcol", StatusCode: resp.StatusCode}
	}
	return nil
}

func (c *Client) Delete(ctx context.Context, userID, path string) error {
	davPath := c.WebDAVPath(userID, path)
	resp, err := c.DoAsUser(ctx, "DELETE", davPath, nil, userID, nil)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode != 204 {
		return &HTTPStatusError{Operation: "delete", StatusCode: resp.StatusCode}
	}
	return nil
}

func normalizeOperationPath(userID, p string) string {
	return NormalizeClientFilePath(userID, NormalizeClientPath(p))
}

func (c *Client) Move(ctx context.Context, userID, srcPath, destPath string) error {
	srcPath = normalizeOperationPath(userID, srcPath)
	destPath = normalizeOperationPath(userID, destPath)
	davSrc := c.WebDAVPath(userID, srcPath)
	destHeader := c.webDAVDestination(c.WebDAVPath(userID, destPath))
	resp, err := c.DoAsUser(ctx, "MOVE", davSrc, nil, userID, map[string]string{
		"Destination": destHeader,
		"Overwrite":   "F",
	})
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode != 201 && resp.StatusCode != 204 {
		return &HTTPStatusError{Operation: "move", StatusCode: resp.StatusCode}
	}
	return nil
}

func (c *Client) Copy(ctx context.Context, userID, srcPath, destPath string) error {
	srcPath = normalizeOperationPath(userID, srcPath)
	destPath = normalizeOperationPath(userID, destPath)
	davSrc := c.WebDAVPath(userID, srcPath)
	destHeader := c.webDAVDestination(c.WebDAVPath(userID, destPath))
	resp, err := c.DoAsUser(ctx, "COPY", davSrc, nil, userID, map[string]string{
		"Destination": destHeader,
		"Overwrite":   "F",
	})
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
		return &HTTPStatusError{Operation: "copy", StatusCode: resp.StatusCode}
	}
	return nil
}

func (c *Client) UploadChunk(ctx context.Context, userID, uploadID, chunkName string, content io.Reader, contentType string) error {
	uploadPath := fmt.Sprintf("/remote.php/dav/uploads/%s/%s/%s", userID, uploadID, chunkName)
	headers := map[string]string{}
	if contentType != "" {
		headers["Content-Type"] = contentType
	}
	resp, err := c.DoAsUser(ctx, "PUT", uploadPath, content, userID, headers)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
		return &HTTPStatusError{Operation: "upload chunk", StatusCode: resp.StatusCode}
	}
	return nil
}

func (c *Client) AssembleChunks(ctx context.Context, userID, uploadID, destinationPath string, totalSize int64) error {
	source := fmt.Sprintf("/remote.php/dav/uploads/%s/%s/.file", userID, uploadID)
	destination := c.webDAVDestination(c.WebDAVPath(userID, destinationPath))
	headers := map[string]string{
		"Destination": destination,
	}
	if totalSize > 0 {
		headers["OC-Total-Length"] = strconv.FormatInt(totalSize, 10)
	}
	resp, err := c.DoAsUser(ctx, "MOVE", source, nil, userID, headers)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
		return &HTTPStatusError{Operation: "assemble chunks", StatusCode: resp.StatusCode}
	}
	return nil
}

func (c *Client) AbortChunkUpload(ctx context.Context, userID, uploadID string) error {
	uploadPath := fmt.Sprintf("/remote.php/dav/uploads/%s/%s", userID, uploadID)
	resp, err := c.DoAsUser(ctx, "DELETE", uploadPath, nil, userID, nil)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound {
		return &HTTPStatusError{Operation: "abort upload", StatusCode: resp.StatusCode}
	}
	return nil
}

func (c *Client) ListTrash(ctx context.Context, userID string) ([]FileInfo, error) {
	basePath := fmt.Sprintf("/remote.php/dav/trashbin/%s/trash", url.PathEscape(strings.TrimSpace(userID)))
	body := `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
  <d:prop>
    <d:getlastmodified/>
    <d:getetag/>
    <d:getcontenttype/>
    <d:getcontentlength/>
    <d:resourcetype/>
    <oc:size/>
    <oc:favorite/>
    <d:displayname/>
  </d:prop>
</d:propfind>`

	resp, err := c.DoAsUser(ctx, "PROPFIND", basePath, strings.NewReader(body), userID, map[string]string{
		"Depth":        "1",
		"Content-Type": "application/xml",
	})
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != 207 {
		return nil, &HTTPStatusError{Operation: "list trash", StatusCode: resp.StatusCode}
	}
	return parsePropfindResponse(resp.Body, "/")
}

func (c *Client) ListRecent(ctx context.Context, userID string, limit int) ([]FileInfo, error) {
	files, err := c.listRecentOCS(ctx, userID, limit)
	if err == nil {
		return files, nil
	}
	var statusErr *HTTPStatusError
	if errors.As(err, &statusErr) && (statusErr.StatusCode == http.StatusNotFound || statusErr.StatusCode == http.StatusMethodNotAllowed) {
		return c.listRecentFromRoot(ctx, userID, limit)
	}
	return nil, err
}

func (c *Client) listRecentOCS(ctx context.Context, userID string, limit int) ([]FileInfo, error) {
	path := "/ocs/v2.php/apps/files/api/v1/recent?format=json"
	if limit > 0 {
		path = fmt.Sprintf("%s&limit=%d", path, limit)
	}
	resp, err := c.DoAsUser(ctx, "GET", path, nil, userID, ocsJSONHeaders())
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, &HTTPStatusError{Operation: "list recent", StatusCode: resp.StatusCode}
	}

	var payload struct {
		OCS struct {
			Data []struct {
				Path     string `json:"path"`
				Name     string `json:"name"`
				MimeType string `json:"mimetype"`
				ETag     string `json:"etag"`
				Type     string `json:"type"`
				Size     int64  `json:"size"`
				MTime    any    `json:"mtime"`
			} `json:"data"`
		} `json:"ocs"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
		return nil, err
	}
	files := make([]FileInfo, 0, len(payload.OCS.Data))
	for _, item := range payload.OCS.Data {
		fileType := "file"
		if strings.EqualFold(item.Type, "dir") || strings.EqualFold(item.Type, "directory") {
			fileType = "directory"
		}
		lastModified := ""
		if ts := parseAnyInt64(item.MTime); ts > 0 {
			lastModified = time.Unix(ts, 0).UTC().Format(time.RFC3339)
		}
		logicalPath := EnsureClientFilePath(
			NormalizeClientFilePath(userID, item.Path),
			item.Name,
		)
		name := SyncFileDisplayName(logicalPath, item.Name)
		files = append(files, FileInfo{
			Path:         logicalPath,
			Name:         name,
			Type:         fileType,
			Size:         item.Size,
			MimeType:     item.MimeType,
			LastModified: lastModified,
			ETag:         strings.Trim(item.ETag, "\""),
		})
	}
	return files, nil
}

// listRecentFromRoot approximates recents when the Files app recent API is unavailable.
func (c *Client) listRecentFromRoot(ctx context.Context, userID string, limit int) ([]FileInfo, error) {
	if limit <= 0 {
		limit = 50
	}
	all, err := c.ListFiles(ctx, userID, "/")
	if err != nil {
		return nil, err
	}
	files := make([]FileInfo, 0, len(all))
	for _, f := range all {
		if f.Type == "file" {
			files = append(files, f)
		}
	}
	sort.Slice(files, func(i, j int) bool {
		return fileModifiedTime(files[i].LastModified).After(fileModifiedTime(files[j].LastModified))
	})
	if len(files) > limit {
		files = files[:limit]
	}
	return files, nil
}

func (c *Client) ListSharedWithMe(ctx context.Context, userID string) ([]FileInfo, error) {
	path := "/ocs/v2.php/apps/files_sharing/api/v1/shares?shared_with_me=true&format=json"
	resp, err := c.DoAsUser(ctx, "GET", path, nil, userID, ocsJSONHeaders())
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, &HTTPStatusError{Operation: "list shared with me", StatusCode: resp.StatusCode}
	}

	var payload struct {
		OCS struct {
			Data []struct {
				Path     string `json:"path"`
				Name     string `json:"name"`
				ItemType string `json:"item_type"`
				MimeType string `json:"mimetype"`
				ETag     string `json:"etag"`
				Size     int64  `json:"storage"`
				MTime    any    `json:"mtime"`
			} `json:"data"`
		} `json:"ocs"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
		return nil, err
	}

	files := make([]FileInfo, 0, len(payload.OCS.Data))
	for _, item := range payload.OCS.Data {
		name := strings.TrimSpace(item.Name)
		if name == "" {
			name = pathBaseName(item.Path)
		}
		fileType := "file"
		if strings.EqualFold(item.ItemType, "folder") ||
			strings.EqualFold(item.ItemType, "dir") ||
			strings.EqualFold(item.ItemType, "directory") ||
			strings.HasPrefix(item.MimeType, "httpd/unix-directory") {
			fileType = "directory"
		}
		lastModified := ""
		if ts := parseAnyInt64(item.MTime); ts > 0 {
			lastModified = time.Unix(ts, 0).UTC().Format(time.RFC3339)
		}
		logicalPath := EnsureClientFilePath(
			NormalizeClientFilePath(userID, item.Path),
			name,
		)
		name = SyncFileDisplayName(logicalPath, name)
		files = append(files, FileInfo{
			Path:         logicalPath,
			Name:         name,
			Type:         fileType,
			Size:         item.Size,
			MimeType:     item.MimeType,
			LastModified: lastModified,
			ETag:         strings.Trim(item.ETag, "\""),
			IsShared:     true,
		})
	}
	return files, nil
}

func pathBaseName(raw string) string {
	raw = strings.TrimSpace(raw)
	raw = strings.Trim(raw, "/")
	if raw == "" {
		return ""
	}
	if idx := strings.LastIndex(raw, "/"); idx >= 0 {
		return raw[idx+1:]
	}
	return raw
}

func (c *Client) ListShares(ctx context.Context, userID, filePath string) ([]ShareInfo, error) {
	apiPath := "/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json"
	if strings.TrimSpace(filePath) != "" {
		apiPath += "&path=" + url.QueryEscape(filePath)
	}
	path := apiPath
	resp, err := c.DoAsUser(ctx, "GET", path, nil, userID, map[string]string{
		"Accept": "application/json",
	})
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, &HTTPStatusError{Operation: "list shares", StatusCode: resp.StatusCode}
	}

	var ocsResp struct {
		OCS struct {
			Data json.RawMessage `json:"data"`
		} `json:"ocs"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&ocsResp); err != nil {
		return nil, err
	}
	items, err := decodeOCSShareRecords(ocsResp.OCS.Data)
	if err != nil {
		return nil, err
	}
	out := make([]ShareInfo, 0, len(items))
	for _, item := range items {
		out = append(out, mapOCSShareRecord(item, filePath))
	}
	return out, nil
}

func (c *Client) UpdateShare(ctx context.Context, userID, shareID string, permissions int, expireDate, password string) (*ShareInfo, error) {
	form := fmt.Sprintf("permissions=%d", permissions)
	if expireDate != "" {
		form += "&expireDate=" + expireDate
	}
	if password != "" {
		form += "&password=" + password
	}
	apiPath := fmt.Sprintf("/ocs/v2.php/apps/files_sharing/api/v1/shares/%s", shareID)
	resp, err := c.DoAsUser(ctx, "PUT", apiPath, strings.NewReader(form), userID, map[string]string{
		"Content-Type": "application/x-www-form-urlencoded",
		"Accept":       "application/json",
	})
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, &HTTPStatusError{Operation: "update share", StatusCode: resp.StatusCode}
	}
	return decodeShareResponse(resp.Body, "")
}

func (c *Client) DeleteShare(ctx context.Context, userID, shareID string) error {
	apiPath := fmt.Sprintf("/ocs/v2.php/apps/files_sharing/api/v1/shares/%s", shareID)
	resp, err := c.DoAsUser(ctx, "DELETE", apiPath, nil, userID, map[string]string{
		"Accept": "application/json",
	})
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
		return &HTTPStatusError{Operation: "delete share", StatusCode: resp.StatusCode}
	}
	return nil
}

func trashbinItemSeg(trashName string) string {
	logical := strings.Trim(strings.TrimPrefix(trashName, "/"), "/")
	if logical == "" {
		return ""
	}
	parts := strings.Split(logical, "/")
	for i, p := range parts {
		parts[i] = url.PathEscape(p)
	}
	return strings.Join(parts, "/")
}

func (c *Client) RestoreFromTrash(ctx context.Context, userID, trashName string) error {
	userSeg := url.PathEscape(strings.TrimSpace(userID))
	nameSeg := trashbinItemSeg(trashName)
	src := fmt.Sprintf("/remote.php/dav/trashbin/%s/trash/%s", userSeg, nameSeg)
	destHeader := c.webDAVDestination(fmt.Sprintf("/remote.php/dav/trashbin/%s/restore/%s", userSeg, nameSeg))
	resp, err := c.DoAsUser(ctx, "MOVE", src, nil, userID, map[string]string{
		"Destination": destHeader,
		"Overwrite":   "T",
	})
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
		return &HTTPStatusError{Operation: "restore trash", StatusCode: resp.StatusCode}
	}
	return nil
}

func (c *Client) DeleteFromTrash(ctx context.Context, userID, trashName string) error {
	userSeg := url.PathEscape(strings.TrimSpace(userID))
	nameSeg := trashbinItemSeg(trashName)
	apiPath := fmt.Sprintf("/remote.php/dav/trashbin/%s/trash/%s", userSeg, nameSeg)
	resp, err := c.DoAsUser(ctx, "DELETE", apiPath, nil, userID, nil)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
		return &HTTPStatusError{Operation: "delete trash", StatusCode: resp.StatusCode}
	}
	return nil
}

func (c *Client) EmptyTrash(ctx context.Context, userID string) error {
	userSeg := url.PathEscape(strings.TrimSpace(userID))
	apiPath := fmt.Sprintf("/remote.php/dav/trashbin/%s/trash", userSeg)
	resp, err := c.DoAsUser(ctx, "DELETE", apiPath, nil, userID, nil)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
		return &HTTPStatusError{Operation: "empty trash", StatusCode: resp.StatusCode}
	}
	return nil
}

const (
	favoritesMaxDirs      = 2000
	favoritesMaxCollect   = 500
	filterCorpusMaxDirs   = 2000
	filterCorpusMaxFiles  = 10000
)

func (c *Client) ListFavorites(ctx context.Context, userID, basePath string, maxCollect int) ([]FileInfo, error) {
	if maxCollect <= 0 {
		maxCollect = favoritesMaxCollect
	}
	basePath = normalizeSearchPath(basePath)
	if basePath == "" {
		basePath = "/"
	}

	queue := []string{basePath}
	seen := map[string]struct{}{basePath: {}}
	results := make([]FileInfo, 0, maxCollect)
	visited := 0

	for len(queue) > 0 && visited < favoritesMaxDirs && len(results) < maxCollect {
		dir := queue[0]
		queue = queue[1:]
		visited++

		files, err := c.ListFiles(ctx, userID, dir)
		if err != nil {
			continue
		}
		for _, f := range files {
			if f.IsFavorite {
				results = append(results, f)
				if len(results) >= maxCollect {
					break
				}
			}
			if !isDirectoryEntry(f) {
				continue
			}
			child := normalizeSearchPath(f.Path)
			if child == "" || child == "/" {
				continue
			}
			if _, ok := seen[child]; ok {
				continue
			}
			seen[child] = struct{}{}
			queue = append(queue, child)
		}
	}
	return results, nil
}

// ListFilesRecursive collects file entries (not directories) under basePath for client-side filtering.
func (c *Client) ListFilesRecursive(ctx context.Context, userID, basePath string, maxFiles int) ([]FileInfo, error) {
	if maxFiles <= 0 {
		maxFiles = filterCorpusMaxFiles
	}
	basePath = normalizeSearchPath(basePath)
	if basePath == "" {
		basePath = "/"
	}

	queue := []string{basePath}
	seen := map[string]struct{}{basePath: {}}
	results := make([]FileInfo, 0, min(maxFiles, 256))
	visited := 0

	for len(queue) > 0 && visited < filterCorpusMaxDirs && len(results) < maxFiles {
		dir := queue[0]
		queue = queue[1:]
		visited++

		files, err := c.ListFiles(ctx, userID, dir)
		if err != nil {
			continue
		}
		for _, f := range files {
			if isDirectoryEntry(f) {
				child := normalizeSearchPath(f.Path)
				if child == "" || child == "/" {
					continue
				}
				if _, ok := seen[child]; ok {
					continue
				}
				seen[child] = struct{}{}
				queue = append(queue, child)
				continue
			}
			results = append(results, f)
			if len(results) >= maxFiles {
				break
			}
		}
	}
	return results, nil
}

func (c *Client) SetFavorite(ctx context.Context, userID, filePath string, favorite bool) error {
	filePath = normalizeOperationPath(userID, filePath)
	davPath := c.WebDAVPath(userID, filePath)
	val := "0"
	if favorite {
		val = "1"
	}
	body := fmt.Sprintf(`<?xml version="1.0"?>
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
  <d:set>
    <d:prop><oc:favorite>%s</oc:favorite></d:prop>
  </d:set>
</d:propertyupdate>`, val)
	resp, err := c.DoAsUser(ctx, "PROPPATCH", davPath, strings.NewReader(body), userID, map[string]string{
		"Content-Type": "application/xml",
	})
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
		return &HTTPStatusError{Operation: "set favorite", StatusCode: resp.StatusCode}
	}
	return nil
}

func decodeShareResponse(body io.Reader, path string) (*ShareInfo, error) {
	return decodeOCSShareResponse(body, path)
}

func (c *Client) CreateShare(ctx context.Context, userID, path string, opts CreateShareOptions) (*ShareInfo, error) {
	form := url.Values{}
	form.Set("path", path)
	form.Set("shareType", strconv.Itoa(opts.ShareType))
	form.Set("permissions", strconv.Itoa(opts.Permissions))
	if shareWith := strings.TrimSpace(opts.ShareWith); shareWith != "" {
		form.Set("shareWith", shareWith)
	}
	if password := strings.TrimSpace(opts.Password); password != "" {
		form.Set("password", password)
	}
	if expireDate := strings.TrimSpace(opts.ExpireDate); expireDate != "" {
		form.Set("expireDate", expireDate)
	}
	if note := strings.TrimSpace(opts.Note); note != "" {
		form.Set("note", note)
	}
	if label := strings.TrimSpace(opts.Label); label != "" {
		form.Set("label", label)
	}
	if attributes := strings.TrimSpace(opts.Attributes); attributes != "" {
		form.Set("attributes", attributes)
	}
	if opts.SendMail {
		form.Set("sendMail", "true")
	}

	resp, err := c.DoAsUser(ctx, "POST", "/ocs/v2.php/apps/files_sharing/api/v1/shares",
		strings.NewReader(form.Encode()), userID, map[string]string{
			"Content-Type": "application/x-www-form-urlencoded",
			"Accept":       "application/json",
		})
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
		return nil, &HTTPStatusError{Operation: "create share", StatusCode: resp.StatusCode}
	}

	share, err := decodeShareResponse(resp.Body, path)
	if err != nil {
		return nil, err
	}
	if opts.AccessMode != "" {
		share.AccessMode = opts.AccessMode
	} else if share.AccessMode == "" && opts.ShareType == 3 {
		share.AccessMode = "public"
	}
	if opts.ShareType == 4 {
		share.AccessMode = "email"
	}
	if opts.ShareType == 0 {
		share.AccessMode = "user"
	}
	return share, nil
}

func (c *Client) SendShareEmail(ctx context.Context, userID, shareID, password string) error {
	form := url.Values{}
	if password := strings.TrimSpace(password); password != "" {
		form.Set("password", password)
	}
	apiPath := fmt.Sprintf("/ocs/v2.php/apps/files_sharing/api/v1/shares/%s/send-email", url.PathEscape(shareID))
	resp, err := c.DoAsUser(ctx, "POST", apiPath, strings.NewReader(form.Encode()), userID, map[string]string{
		"Content-Type": "application/x-www-form-urlencoded",
		"Accept":       "application/json",
	})
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return &HTTPStatusError{Operation: "send share email", StatusCode: resp.StatusCode}
	}
	return nil
}

func (c *Client) GetQuota(ctx context.Context, userID string) (UserQuota, error) {
	var lastErr error
	if q, err := c.getQuotaOCSCurrentUser(ctx, userID); err == nil {
		return q, nil
	} else {
		lastErr = err
	}
	if q, err := c.getQuotaOCSUserRecord(ctx, userID); err == nil {
		return q, nil
	} else {
		lastErr = err
	}
	if q, err := c.getQuotaWebDAV(ctx, userID); err == nil {
		return q, nil
	} else {
		lastErr = err
	}
	return UserQuota{}, lastErr
}

func (c *Client) getQuotaOCSCurrentUser(ctx context.Context, userID string) (UserQuota, error) {
	resp, err := c.DoAsUser(ctx, "GET", "/ocs/v2.php/cloud/user?format=json", nil, userID, ocsJSONHeaders())
	if err != nil {
		return UserQuota{}, err
	}
	defer resp.Body.Close()
	return decodeOCSQuotaResponse(resp, "get quota (cloud/user)")
}

func (c *Client) getQuotaOCSUserRecord(ctx context.Context, userID string) (UserQuota, error) {
	path := fmt.Sprintf("/ocs/v1.php/cloud/users/%s?format=json", url.PathEscape(userID))
	resp, err := c.DoAsUser(ctx, "GET", path, nil, userID, ocsJSONHeaders())
	if err != nil {
		return UserQuota{}, err
	}
	defer resp.Body.Close()
	return decodeOCSQuotaResponse(resp, "get quota (cloud/users)")
}

func (c *Client) getQuotaWebDAV(ctx context.Context, userID string) (UserQuota, error) {
	davPath := c.WebDAVPath(userID, "")
	body := `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:">
  <d:prop>
    <d:quota-available-bytes/>
    <d:quota-used-bytes/>
  </d:prop>
</d:propfind>`
	resp, err := c.DoAsUser(ctx, "PROPFIND", davPath, strings.NewReader(body), userID, map[string]string{
		"Depth":        "0",
		"Content-Type": "application/xml",
	})
	if err != nil {
		return UserQuota{}, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
		return UserQuota{}, &HTTPStatusError{Operation: "get quota (webdav)", StatusCode: resp.StatusCode}
	}
	return parseQuotaPropfind(resp.Body)
}

func decodeOCSQuotaResponse(resp *http.Response, op string) (UserQuota, error) {
	if resp.StatusCode != http.StatusOK {
		return UserQuota{}, &HTTPStatusError{Operation: op, StatusCode: resp.StatusCode}
	}
	raw, err := io.ReadAll(resp.Body)
	if err != nil {
		return UserQuota{}, err
	}
	var payload struct {
		OCS struct {
			Meta struct {
				StatusCode int `json:"statuscode"`
			} `json:"meta"`
			Data struct {
				Quota struct {
					Free     any `json:"free"`
					Used     any `json:"used"`
					Total    any `json:"total"`
					Relative any `json:"relative"`
				} `json:"quota"`
			} `json:"data"`
		} `json:"ocs"`
	}
	if err := json.Unmarshal(raw, &payload); err != nil {
		return UserQuota{}, err
	}
	if code := payload.OCS.Meta.StatusCode; code != 0 && code != 100 && code != 200 {
		return UserQuota{}, fmt.Errorf("%s: ocs status %d", op, code)
	}
	q := payload.OCS.Data.Quota
	used := parseAnyInt64(q.Used)
	free := parseAnyInt64(q.Free)
	total := parseAnyInt64(q.Total)
	if total <= 0 && used >= 0 && free >= 0 {
		total = used + free
	}
	return UserQuota{
		Used:     used,
		Free:     free,
		Total:    total,
		Relative: parseAnyInt64(q.Relative),
	}, nil
}

func parseQuotaPropfind(body io.Reader) (UserQuota, error) {
	var ms struct {
		XMLName   xml.Name `xml:"multistatus"`
		Responses []struct {
			Propstat struct {
				Prop struct {
					Available int64 `xml:"quota-available-bytes"`
					Used      int64 `xml:"quota-used-bytes"`
				} `xml:"prop"`
			} `xml:"propstat"`
		} `xml:"response"`
	}
	if err := xml.NewDecoder(body).Decode(&ms); err != nil {
		return UserQuota{}, err
	}
	if len(ms.Responses) == 0 {
		return UserQuota{}, fmt.Errorf("quota propfind: empty response")
	}
	used := ms.Responses[0].Propstat.Prop.Used
	free := ms.Responses[0].Propstat.Prop.Available
	total := used + free
	var relative int64
	if total > 0 {
		relative = (used * 100) / total
	}
	return UserQuota{Used: used, Free: free, Total: total, Relative: relative}, nil
}

func ocsJSONHeaders() map[string]string {
	return map[string]string{
		"Accept":         "application/json",
		"OCS-APIRequest": "true",
	}
}

func fileModifiedTime(raw string) time.Time {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return time.Time{}
	}
	if ts, err := time.Parse(time.RFC3339, raw); err == nil {
		return ts
	}
	if ts, err := time.Parse(time.RFC1123, raw); err == nil {
		return ts
	}
	if ts, err := http.ParseTime(raw); err == nil {
		return ts
	}
	return time.Time{}
}

// PROPFIND XML response parsing
type multistatus struct {
	XMLName   xml.Name   `xml:"multistatus"`
	Responses []response `xml:"response"`
}

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

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

type prop struct {
	LastModified  string       `xml:"getlastmodified"`
	ETag          string       `xml:"getetag"`
	ContentType   string       `xml:"getcontenttype"`
	ContentLength int64        `xml:"getcontentlength"`
	ResourceType  resourceType `xml:"resourcetype"`
	Size          int64        `xml:"size"`
	Favorite      int          `xml:"favorite"`
	ShareTypes    shareTypes   `xml:"http://owncloud.org/ns share-types"`
	FileID        string       `xml:"http://owncloud.org/ns fileid"`
	DisplayName   string       `xml:"displayname"`
	Permissions   string       `xml:"http://owncloud.org/ns permissions"`
	OwnerID       string       `xml:"http://owncloud.org/ns owner-id"`
	CalendarColor string       `xml:"calendar-color"`
}

type resourceType struct {
	Collection *struct{} `xml:"collection"`
}

type shareTypes struct {
	ShareType []string `xml:"http://owncloud.org/ns share-type"`
}

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

	files := make([]FileInfo, 0, len(ms.Responses))
	for i, r := range ms.Responses {
		if i == 0 {
			continue // skip the folder itself
		}

		name := fileNameFromDAVProp(r.Propstat.Prop.DisplayName, r.Href)
		clientPath := ResolvePropfindClientPath(listDir, r.Href, name)
		name = SyncFileDisplayName(clientPath, name)

		fileType := "file"
		if r.Propstat.Prop.ResourceType.Collection != nil {
			fileType = "directory"
		}

		size := r.Propstat.Prop.ContentLength
		if r.Propstat.Prop.Size > 0 {
			size = r.Propstat.Prop.Size
		}

		files = append(files, FileInfo{
			Path:         clientPath,
			Name:         name,
			Type:         fileType,
			Size:         size,
			MimeType:     r.Propstat.Prop.ContentType,
			LastModified: r.Propstat.Prop.LastModified,
			ETag:         strings.Trim(r.Propstat.Prop.ETag, "\""),
			FileID:       parseFileID(r.Propstat.Prop.FileID),
			IsFavorite:   r.Propstat.Prop.Favorite == 1,
			IsShared:     len(r.Propstat.Prop.ShareTypes.ShareType) > 0,
		})
	}

	return files, nil
}

func parseFileID(raw string) int64 {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return 0
	}
	id, err := strconv.ParseInt(raw, 10, 64)
	if err != nil {
		return 0
	}
	return id
}

func parseInt64(raw string) int64 {
	n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
	if err != nil {
		return 0
	}
	return n
}

func parseAnyInt64(raw any) int64 {
	switch v := raw.(type) {
	case nil:
		return 0
	case float64:
		return int64(v)
	case int64:
		return v
	case int:
		return int64(v)
	case string:
		return parseInt64(v)
	default:
		return parseInt64(fmt.Sprintf("%v", v))
	}
}
