// Copyright 2015 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package webdav

import (
	"context"
	"encoding/xml"
	"fmt"
	"net/http"
	"os"
	"reflect"
	"regexp"
	"sort"
	"testing"
)

func TestMemPS(t *testing.T) {
	ctx := context.Background()
	// calcProps calculates the getlastmodified and getetag DAV: property
	// values in pstats for resource name in file-system fs.
	calcProps := func(name string, fs FileSystem, ls LockSystem, pstats []Propstat) error {
		fi, err := fs.Stat(ctx, name)
		if err != nil {
			return err
		}
		for _, pst := range pstats {
			for i, p := range pst.Props {
				switch p.XMLName {
				case xml.Name{Space: "DAV:", Local: "getlastmodified"}:
					p.InnerXML = []byte(fi.ModTime().UTC().Format(http.TimeFormat))
					pst.Props[i] = p
				case xml.Name{Space: "DAV:", Local: "getetag"}:
					if fi.IsDir() {
						continue
					}
					etag, err := findETag(ctx, fs, ls, name, fi)
					if err != nil {
						return err
					}
					p.InnerXML = []byte(etag)
					pst.Props[i] = p
				}
			}
		}
		return nil
	}

	const (
		lockEntry = `` +
			`<D:lockentry xmlns:D="DAV:">` +
			`<D:lockscope><D:exclusive/></D:lockscope>` +
			`<D:locktype><D:write/></D:locktype>` +
			`</D:lockentry>`
		statForbiddenError = `<D:cannot-modify-protected-property xmlns:D="DAV:"/>`
	)

	type propOp struct {
		op            string
		name          string
		pnames        []xml.Name
		patches       []Proppatch
		wantPnames    []xml.Name
		wantPropstats []Propstat
	}

	testCases := []struct {
		desc        string
		noDeadProps bool
		buildfs     []string
		propOp      []propOp
	}{{
		desc:    "propname",
		buildfs: []string{"mkdir /dir", "touch /file"},
		propOp: []propOp{{
			op:   "propname",
			name: "/dir",
			wantPnames: []xml.Name{
				{Space: "DAV:", Local: "resourcetype"},
				{Space: "DAV:", Local: "displayname"},
				{Space: "DAV:", Local: "supportedlock"},
				{Space: "DAV:", Local: "getlastmodified"},
			},
		}, {
			op:   "propname",
			name: "/file",
			wantPnames: []xml.Name{
				{Space: "DAV:", Local: "resourcetype"},
				{Space: "DAV:", Local: "displayname"},
				{Space: "DAV:", Local: "getcontentlength"},
				{Space: "DAV:", Local: "getlastmodified"},
				{Space: "DAV:", Local: "getcontenttype"},
				{Space: "DAV:", Local: "getetag"},
				{Space: "DAV:", Local: "supportedlock"},
			},
		}},
	}, {
		desc:    "allprop dir and file",
		buildfs: []string{"mkdir /dir", "write /file foobarbaz"},
		propOp: []propOp{{
			op:   "allprop",
			name: "/dir",
			wantPropstats: []Propstat{{
				Status: http.StatusOK,
				Props: []Property{{
					XMLName:  xml.Name{Space: "DAV:", Local: "resourcetype"},
					InnerXML: []byte(`<D:collection xmlns:D="DAV:"/>`),
				}, {
					XMLName:  xml.Name{Space: "DAV:", Local: "displayname"},
					InnerXML: []byte("dir"),
				}, {
					XMLName:  xml.Name{Space: "DAV:", Local: "getlastmodified"},
					InnerXML: nil, // Calculated during test.
				}, {
					XMLName:  xml.Name{Space: "DAV:", Local: "supportedlock"},
					InnerXML: []byte(lockEntry),
				}},
			}},
		}, {
			op:   "allprop",
			name: "/file",
			wantPropstats: []Propstat{{
				Status: http.StatusOK,
				Props: []Property{{
					XMLName:  xml.Name{Space: "DAV:", Local: "resourcetype"},
					InnerXML: []byte(""),
				}, {
					XMLName:  xml.Name{Space: "DAV:", Local: "displayname"},
					InnerXML: []byte("file"),
				}, {
					XMLName:  xml.Name{Space: "DAV:", Local: "getcontentlength"},
					InnerXML: []byte("9"),
				}, {
					XMLName:  xml.Name{Space: "DAV:", Local: "getlastmodified"},
					InnerXML: nil, // Calculated during test.
				}, {
					XMLName:  xml.Name{Space: "DAV:", Local: "getcontenttype"},
					InnerXML: []byte("text/plain; charset=utf-8"),
				}, {
					XMLName:  xml.Name{Space: "DAV:", Local: "getetag"},
					InnerXML: nil, // Calculated during test.
				}, {
					XMLName:  xml.Name{Space: "DAV:", Local: "supportedlock"},
					InnerXML: []byte(lockEntry),
				}},
			}},
		}, {
			op:   "allprop",
			name: "/file",
			pnames: []xml.Name{
				{Space: "DAV:", Local: "resourcetype"},
				{Space: "foo", Local: "bar"},
			},
			wantPropstats: []Propstat{{
				Status: http.StatusOK,
				Props: []Property{{
					XMLName:  xml.Name{Space: "DAV:", Local: "resourcetype"},
					InnerXML: []byte(""),
				}, {
					XMLName:  xml.Name{Space: "DAV:", Local: "displayname"},
					InnerXML: []byte("file"),
				}, {
					XMLName:  xml.Name{Space: "DAV:", Local: "getcontentlength"},
					InnerXML: []byte("9"),
				}, {
					XMLName:  xml.Name{Space: "DAV:", Local: "getlastmodified"},
					InnerXML: nil, // Calculated during test.
				}, {
					XMLName:  xml.Name{Space: "DAV:", Local: "getcontenttype"},
					InnerXML: []byte("text/plain; charset=utf-8"),
				}, {
					XMLName:  xml.Name{Space: "DAV:", Local: "getetag"},
					InnerXML: nil, // Calculated during test.
				}, {
					XMLName:  xml.Name{Space: "DAV:", Local: "supportedlock"},
					InnerXML: []byte(lockEntry),
				}}}, {
				Status: http.StatusNotFound,
				Props: []Property{{
					XMLName: xml.Name{Space: "foo", Local: "bar"},
				}}},
			},
		}},
	}, {
		desc:    "propfind DAV:resourcetype",
		buildfs: []string{"mkdir /dir", "touch /file"},
		propOp: []propOp{{
			op:     "propfind",
			name:   "/dir",
			pnames: []xml.Name{{Space: "DAV:", Local: "resourcetype"}},
			wantPropstats: []Propstat{{
				Status: http.StatusOK,
				Props: []Property{{
					XMLName:  xml.Name{Space: "DAV:", Local: "resourcetype"},
					InnerXML: []byte(`<D:collection xmlns:D="DAV:"/>`),
				}},
			}},
		}, {
			op:     "propfind",
			name:   "/file",
			pnames: []xml.Name{{Space: "DAV:", Local: "resourcetype"}},
			wantPropstats: []Propstat{{
				Status: http.StatusOK,
				Props: []Property{{
					XMLName:  xml.Name{Space: "DAV:", Local: "resourcetype"},
					InnerXML: []byte(""),
				}},
			}},
		}},
	}, {
		desc:    "propfind unsupported DAV properties",
		buildfs: []string{"mkdir /dir"},
		propOp: []propOp{{
			op:     "propfind",
			name:   "/dir",
			pnames: []xml.Name{{Space: "DAV:", Local: "getcontentlanguage"}},
			wantPropstats: []Propstat{{
				Status: http.StatusNotFound,
				Props: []Property{{
					XMLName: xml.Name{Space: "DAV:", Local: "getcontentlanguage"},
				}},
			}},
		}, {
			op:     "propfind",
			name:   "/dir",
			pnames: []xml.Name{{Space: "DAV:", Local: "creationdate"}},
			wantPropstats: []Propstat{{
				Status: http.StatusNotFound,
				Props: []Property{{
					XMLName: xml.Name{Space: "DAV:", Local: "creationdate"},
				}},
			}},
		}},
	}, {
		desc:    "propfind getetag for files but not for directories",
		buildfs: []string{"mkdir /dir", "touch /file"},
		propOp: []propOp{{
			op:     "propfind",
			name:   "/dir",
			pnames: []xml.Name{{Space: "DAV:", Local: "getetag"}},
			wantPropstats: []Propstat{{
				Status: http.StatusNotFound,
				Props: []Property{{
					XMLName: xml.Name{Space: "DAV:", Local: "getetag"},
				}},
			}},
		}, {
			op:     "propfind",
			name:   "/file",
			pnames: []xml.Name{{Space: "DAV:", Local: "getetag"}},
			wantPropstats: []Propstat{{
				Status: http.StatusOK,
				Props: []Property{{
					XMLName:  xml.Name{Space: "DAV:", Local: "getetag"},
					InnerXML: nil, // Calculated during test.
				}},
			}},
		}},
	}, {
		desc:        "proppatch property on no-dead-properties file system",
		buildfs:     []string{"mkdir /dir"},
		noDeadProps: true,
		propOp: []propOp{{
			op:   "proppatch",
			name: "/dir",
			patches: []Proppatch{{
				Props: []Property{{
					XMLName: xml.Name{Space: "foo", Local: "bar"},
				}},
			}},
			wantPropstats: []Propstat{{
				Status: http.StatusForbidden,
				Props: []Property{{
					XMLName: xml.Name{Space: "foo", Local: "bar"},
				}},
			}},
		}, {
			op:   "proppatch",
			name: "/dir",
			patches: []Proppatch{{
				Props: []Property{{
					XMLName: xml.Name{Space: "DAV:", Local: "getetag"},
				}},
			}},
			wantPropstats: []Propstat{{
				Status:   http.StatusForbidden,
				XMLError: statForbiddenError,
				Props: []Property{{
					XMLName: xml.Name{Space: "DAV:", Local: "getetag"},
				}},
			}},
		}},
	}, {
		desc:    "proppatch dead property",
		buildfs: []string{"mkdir /dir"},
		propOp: []propOp{{
			op:   "proppatch",
			name: "/dir",
			patches: []Proppatch{{
				Props: []Property{{
					XMLName:  xml.Name{Space: "foo", Local: "bar"},
					InnerXML: []byte("baz"),
				}},
			}},
			wantPropstats: []Propstat{{
				Status: http.StatusOK,
				Props: []Property{{
					XMLName: xml.Name{Space: "foo", Local: "bar"},
				}},
			}},
		}, {
			op:     "propfind",
			name:   "/dir",
			pnames: []xml.Name{{Space: "foo", Local: "bar"}},
			wantPropstats: []Propstat{{
				Status: http.StatusOK,
				Props: []Property{{
					XMLName:  xml.Name{Space: "foo", Local: "bar"},
					InnerXML: []byte("baz"),
				}},
			}},
		}},
	}, {
		desc:    "proppatch dead property with failed dependency",
		buildfs: []string{"mkdir /dir"},
		propOp: []propOp{{
			op:   "proppatch",
			name: "/dir",
			patches: []Proppatch{{
				Props: []Property{{
					XMLName:  xml.Name{Space: "foo", Local: "bar"},
					InnerXML: []byte("baz"),
				}},
			}, {
				Props: []Property{{
					XMLName:  xml.Name{Space: "DAV:", Local: "displayname"},
					InnerXML: []byte("xxx"),
				}},
			}},
			wantPropstats: []Propstat{{
				Status:   http.StatusForbidden,
				XMLError: statForbiddenError,
				Props: []Property{{
					XMLName: xml.Name{Space: "DAV:", Local: "displayname"},
				}},
			}, {
				Status: StatusFailedDependency,
				Props: []Property{{
					XMLName: xml.Name{Space: "foo", Local: "bar"},
				}},
			}},
		}, {
			op:     "propfind",
			name:   "/dir",
			pnames: []xml.Name{{Space: "foo", Local: "bar"}},
			wantPropstats: []Propstat{{
				Status: http.StatusNotFound,
				Props: []Property{{
					XMLName: xml.Name{Space: "foo", Local: "bar"},
				}},
			}},
		}},
	}, {
		desc:    "proppatch remove dead property",
		buildfs: []string{"mkdir /dir"},
		propOp: []propOp{{
			op:   "proppatch",
			name: "/dir",
			patches: []Proppatch{{
				Props: []Property{{
					XMLName:  xml.Name{Space: "foo", Local: "bar"},
					InnerXML: []byte("baz"),
				}, {
					XMLName:  xml.Name{Space: "spam", Local: "ham"},
					InnerXML: []byte("eggs"),
				}},
			}},
			wantPropstats: []Propstat{{
				Status: http.StatusOK,
				Props: []Property{{
					XMLName: xml.Name{Space: "foo", Local: "bar"},
				}, {
					XMLName: xml.Name{Space: "spam", Local: "ham"},
				}},
			}},
		}, {
			op:   "propfind",
			name: "/dir",
			pnames: []xml.Name{
				{Space: "foo", Local: "bar"},
				{Space: "spam", Local: "ham"},
			},
			wantPropstats: []Propstat{{
				Status: http.StatusOK,
				Props: []Property{{
					XMLName:  xml.Name{Space: "foo", Local: "bar"},
					InnerXML: []byte("baz"),
				}, {
					XMLName:  xml.Name{Space: "spam", Local: "ham"},
					InnerXML: []byte("eggs"),
				}},
			}},
		}, {
			op:   "proppatch",
			name: "/dir",
			patches: []Proppatch{{
				Remove: true,
				Props: []Property{{
					XMLName: xml.Name{Space: "foo", Local: "bar"},
				}},
			}},
			wantPropstats: []Propstat{{
				Status: http.StatusOK,
				Props: []Property{{
					XMLName: xml.Name{Space: "foo", Local: "bar"},
				}},
			}},
		}, {
			op:   "propfind",
			name: "/dir",
			pnames: []xml.Name{
				{Space: "foo", Local: "bar"},
				{Space: "spam", Local: "ham"},
			},
			wantPropstats: []Propstat{{
				Status: http.StatusNotFound,
				Props: []Property{{
					XMLName: xml.Name{Space: "foo", Local: "bar"},
				}},
			}, {
				Status: http.StatusOK,
				Props: []Property{{
					XMLName:  xml.Name{Space: "spam", Local: "ham"},
					InnerXML: []byte("eggs"),
				}},
			}},
		}},
	}, {
		desc:    "propname with dead property",
		buildfs: []string{"touch /file"},
		propOp: []propOp{{
			op:   "proppatch",
			name: "/file",
			patches: []Proppatch{{
				Props: []Property{{
					XMLName:  xml.Name{Space: "foo", Local: "bar"},
					InnerXML: []byte("baz"),
				}},
			}},
			wantPropstats: []Propstat{{
				Status: http.StatusOK,
				Props: []Property{{
					XMLName: xml.Name{Space: "foo", Local: "bar"},
				}},
			}},
		}, {
			op:   "propname",
			name: "/file",
			wantPnames: []xml.Name{
				{Space: "DAV:", Local: "resourcetype"},
				{Space: "DAV:", Local: "displayname"},
				{Space: "DAV:", Local: "getcontentlength"},
				{Space: "DAV:", Local: "getlastmodified"},
				{Space: "DAV:", Local: "getcontenttype"},
				{Space: "DAV:", Local: "getetag"},
				{Space: "DAV:", Local: "supportedlock"},
				{Space: "foo", Local: "bar"},
			},
		}},
	}, {
		desc:    "proppatch remove unknown dead property",
		buildfs: []string{"mkdir /dir"},
		propOp: []propOp{{
			op:   "proppatch",
			name: "/dir",
			patches: []Proppatch{{
				Remove: true,
				Props: []Property{{
					XMLName: xml.Name{Space: "foo", Local: "bar"},
				}},
			}},
			wantPropstats: []Propstat{{
				Status: http.StatusOK,
				Props: []Property{{
					XMLName: xml.Name{Space: "foo", Local: "bar"},
				}},
			}},
		}},
	}, {
		desc:    "bad: propfind unknown property",
		buildfs: []string{"mkdir /dir"},
		propOp: []propOp{{
			op:     "propfind",
			name:   "/dir",
			pnames: []xml.Name{{Space: "foo:", Local: "bar"}},
			wantPropstats: []Propstat{{
				Status: http.StatusNotFound,
				Props: []Property{{
					XMLName: xml.Name{Space: "foo:", Local: "bar"},
				}},
			}},
		}},
	}}

	for _, tc := range testCases {
		fs, err := buildTestFS(tc.buildfs)
		if err != nil {
			t.Fatalf("%s: cannot create test filesystem: %v", tc.desc, err)
		}
		if tc.noDeadProps {
			fs = noDeadPropsFS{fs}
		}
		ls := NewMemLS()
		for _, op := range tc.propOp {
			desc := fmt.Sprintf("%s: %s %s", tc.desc, op.op, op.name)
			if err = calcProps(op.name, fs, ls, op.wantPropstats); err != nil {
				t.Fatalf("%s: calcProps: %v", desc, err)
			}

			// Call property system.
			var propstats []Propstat
			switch op.op {
			case "propname":
				pnames, err := propnames(ctx, fs, ls, op.name)
				if err != nil {
					t.Errorf("%s: got error %v, want nil", desc, err)
					continue
				}
				sort.Sort(byXMLName(pnames))
				sort.Sort(byXMLName(op.wantPnames))
				if !reflect.DeepEqual(pnames, op.wantPnames) {
					t.Errorf("%s: pnames\ngot  %q\nwant %q", desc, pnames, op.wantPnames)
				}
				continue
			case "allprop":
				propstats, err = allprop(ctx, fs, ls, op.name, op.pnames)
			case "propfind":
				propstats, err = props(ctx, fs, ls, op.name, op.pnames)
			case "proppatch":
				propstats, err = patch(ctx, fs, ls, op.name, op.patches)
			default:
				t.Fatalf("%s: %s not implemented", desc, op.op)
			}
			if err != nil {
				t.Errorf("%s: got error %v, want nil", desc, err)
				continue
			}
			// Compare return values from allprop, propfind or proppatch.
			for _, pst := range propstats {
				sort.Sort(byPropname(pst.Props))
			}
			for _, pst := range op.wantPropstats {
				sort.Sort(byPropname(pst.Props))
			}
			sort.Sort(byStatus(propstats))
			sort.Sort(byStatus(op.wantPropstats))
			if !reflect.DeepEqual(propstats, op.wantPropstats) {
				t.Errorf("%s: propstat\ngot  %q\nwant %q", desc, propstats, op.wantPropstats)
			}
		}
	}
}

func cmpXMLName(a, b xml.Name) bool {
	if a.Space != b.Space {
		return a.Space < b.Space
	}
	return a.Local < b.Local
}

type byXMLName []xml.Name

func (b byXMLName) Len() int           { return len(b) }
func (b byXMLName) Swap(i, j int)      { b[i], b[j] = b[j], b[i] }
func (b byXMLName) Less(i, j int) bool { return cmpXMLName(b[i], b[j]) }

type byPropname []Property

func (b byPropname) Len() int           { return len(b) }
func (b byPropname) Swap(i, j int)      { b[i], b[j] = b[j], b[i] }
func (b byPropname) Less(i, j int) bool { return cmpXMLName(b[i].XMLName, b[j].XMLName) }

type byStatus []Propstat

func (b byStatus) Len() int           { return len(b) }
func (b byStatus) Swap(i, j int)      { b[i], b[j] = b[j], b[i] }
func (b byStatus) Less(i, j int) bool { return b[i].Status < b[j].Status }

type noDeadPropsFS struct {
	FileSystem
}

func (fs noDeadPropsFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (File, error) {
	f, err := fs.FileSystem.OpenFile(ctx, name, flag, perm)
	if err != nil {
		return nil, err
	}
	return noDeadPropsFile{f}, nil
}

// noDeadPropsFile wraps a File but strips any optional DeadPropsHolder methods
// provided by the underlying File implementation.
type noDeadPropsFile struct {
	f File
}

func (f noDeadPropsFile) Close() error                              { return f.f.Close() }
func (f noDeadPropsFile) Read(p []byte) (int, error)                { return f.f.Read(p) }
func (f noDeadPropsFile) Readdir(count int) ([]os.FileInfo, error)  { return f.f.Readdir(count) }
func (f noDeadPropsFile) Seek(off int64, whence int) (int64, error) { return f.f.Seek(off, whence) }
func (f noDeadPropsFile) Stat() (os.FileInfo, error)                { return f.f.Stat() }
func (f noDeadPropsFile) Write(p []byte) (int, error)               { return f.f.Write(p) }

type overrideContentType struct {
	os.FileInfo
	contentType string
	err         error
}

func (o *overrideContentType) ContentType(ctx context.Context) (string, error) {
	return o.contentType, o.err
}

func TestFindContentTypeOverride(t *testing.T) {
	fs, err := buildTestFS([]string{"touch /file"})
	if err != nil {
		t.Fatalf("cannot create test filesystem: %v", err)
	}
	ctx := context.Background()
	fi, err := fs.Stat(ctx, "/file")
	if err != nil {
		t.Fatalf("cannot Stat /file: %v", err)
	}

	// Check non overridden case
	originalContentType, err := findContentType(ctx, fs, nil, "/file", fi)
	if err != nil {
		t.Fatalf("findContentType /file failed: %v", err)
	}
	if originalContentType != "text/plain; charset=utf-8" {
		t.Fatalf("ContentType wrong want %q got %q", "text/plain; charset=utf-8", originalContentType)
	}

	// Now try overriding the ContentType
	o := &overrideContentType{fi, "OverriddenContentType", nil}
	ContentType, err := findContentType(ctx, fs, nil, "/file", o)
	if err != nil {
		t.Fatalf("findContentType /file failed: %v", err)
	}
	if ContentType != o.contentType {
		t.Fatalf("ContentType wrong want %q got %q", o.contentType, ContentType)
	}

	// Now return ErrNotImplemented and check we get the original content type
	o = &overrideContentType{fi, "OverriddenContentType", ErrNotImplemented}
	ContentType, err = findContentType(ctx, fs, nil, "/file", o)
	if err != nil {
		t.Fatalf("findContentType /file failed: %v", err)
	}
	if ContentType != originalContentType {
		t.Fatalf("ContentType wrong want %q got %q", originalContentType, ContentType)
	}
}

type overrideETag struct {
	os.FileInfo
	eTag string
	err  error
}

func (o *overrideETag) ETag(ctx context.Context) (string, error) {
	return o.eTag, o.err
}

func TestFindETagOverride(t *testing.T) {
	fs, err := buildTestFS([]string{"touch /file"})
	if err != nil {
		t.Fatalf("cannot create test filesystem: %v", err)
	}
	ctx := context.Background()
	fi, err := fs.Stat(ctx, "/file")
	if err != nil {
		t.Fatalf("cannot Stat /file: %v", err)
	}

	// Check non overridden case
	originalETag, err := findETag(ctx, fs, nil, "/file", fi)
	if err != nil {
		t.Fatalf("findETag /file failed: %v", err)
	}
	matchETag := regexp.MustCompile(`^"-?[0-9a-f]{6,}"$`)
	if !matchETag.MatchString(originalETag) {
		t.Fatalf("ETag wrong, wanted something matching %v got %q", matchETag, originalETag)
	}

	// Now try overriding the ETag
	o := &overrideETag{fi, `"OverriddenETag"`, nil}
	ETag, err := findETag(ctx, fs, nil, "/file", o)
	if err != nil {
		t.Fatalf("findETag /file failed: %v", err)
	}
	if ETag != o.eTag {
		t.Fatalf("ETag wrong want %q got %q", o.eTag, ETag)
	}

	// Now return ErrNotImplemented and check we get the original Etag
	o = &overrideETag{fi, `"OverriddenETag"`, ErrNotImplemented}
	ETag, err = findETag(ctx, fs, nil, "/file", o)
	if err != nil {
		t.Fatalf("findETag /file failed: %v", err)
	}
	if ETag != originalETag {
		t.Fatalf("ETag wrong want %q got %q", originalETag, ETag)
	}
}
