// Copyright 2011 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 build

import (
	"appengine"
	"appengine/datastore"
	"bytes"
	"compress/gzip"
	"crypto/sha1"
	"fmt"
	"http"
	"io"
	"json"
	"os"
	"strings"
)

const commitsPerPage = 20

// A Package describes a package that is listed on the dashboard.
type Package struct {
	Name    string
	Path    string // (empty for the main Go tree)
	NextNum int    // Num of the next head Commit
}

func (p *Package) Key(c appengine.Context) *datastore.Key {
	key := p.Path
	if key == "" {
		key = "go"
	}
	return datastore.NewKey(c, "Package", key, 0, nil)
}

func GetPackage(c appengine.Context, path string) (*Package, os.Error) {
	p := &Package{Path: path}
	err := datastore.Get(c, p.Key(c), p)
	if err == datastore.ErrNoSuchEntity {
		return nil, fmt.Errorf("package %q not found", path)
	}
	return p, err
}

// A Commit describes an individual commit in a package.
//
// Each Commit entity is a descendant of its associated Package entity.
// In other words, all Commits with the same PackagePath belong to the same
// datastore entity group.
type Commit struct {
	PackagePath string // (empty for Go commits)
	Hash        string
	ParentHash  string
	Num         int // Internal monotonic counter unique to this package.

	User string
	Desc string `datastore:",noindex"`
	Time datastore.Time

	// Result is the Data string of each build Result for this Commit.
	// For non-Go commits, only the Results for the current Go tip, weekly,
	// and release Tags are stored here. This is purely de-normalized data.
	// The complete data set is stored in Result entities.
	Result []string `datastore:",noindex"`
}

func (com *Commit) Key(c appengine.Context) *datastore.Key {
	if com.Hash == "" {
		panic("tried Key on Commit with empty Hash")
	}
	p := Package{Path: com.PackagePath}
	key := com.PackagePath + "|" + com.Hash
	return datastore.NewKey(c, "Commit", key, 0, p.Key(c))
}

func (c *Commit) Valid() os.Error {
	if !validHash(c.Hash) {
		return os.NewError("invalid Hash")
	}
	if c.ParentHash != "" && !validHash(c.ParentHash) { // empty is OK
		return os.NewError("invalid ParentHash")
	}
	return nil
}

// AddResult adds the denormalized Reuslt data to the Commit's Result field.
// It must be called from inside a datastore transaction.
func (com *Commit) AddResult(c appengine.Context, r *Result) os.Error {
	if err := datastore.Get(c, com.Key(c), com); err != nil {
		return err
	}
	com.Result = append(com.Result, r.Data())
	_, err := datastore.Put(c, com.Key(c), com)
	return err
}

func (com *Commit) HasResult(builder string) bool {
	for _, r := range com.Result {
		if strings.SplitN(r, "|", 2)[0] == builder {
			return true
		}
	}
	return false
}

func (com *Commit) HasGoHashResult(builder, goHash string) bool {
	for _, r := range com.Result {
		p := strings.SplitN(r, "|", 4)
		if len(p) == 4 && p[0] == builder && p[3] == goHash {
			return true
		}
	}
	return false
}

// A Result describes a build result for a Commit on an OS/architecture.
//
// Each Result entity is a descendant of its associated Commit entity.
type Result struct {
	Builder     string // "arch-os[-note]"
	Hash        string
	PackagePath string // (empty for Go commits)

	// The Go Commit this was built against (empty for Go commits).
	GoHash string

	OK      bool
	Log     []byte `datastore:"-"`        // for JSON unmarshaling
	LogHash string `datastore:",noindex"` // Key to the Log record.
}

func (r *Result) Key(c appengine.Context) *datastore.Key {
	p := Package{Path: r.PackagePath}
	key := r.Builder + "|" + r.PackagePath + "|" + r.Hash + "|" + r.GoHash
	return datastore.NewKey(c, "Result", key, 0, p.Key(c))
}

func (r *Result) Data() string {
	return fmt.Sprintf("%v|%v|%v|%v", r.Builder, r.OK, r.LogHash, r.GoHash)
}

func (r *Result) Valid() os.Error {
	if !validHash(r.Hash) {
		return os.NewError("invalid Hash")
	}
	if r.PackagePath != "" && !validHash(r.GoHash) {
		return os.NewError("invalid GoHash")
	}
	return nil
}

// A Log is a gzip-compressed log file stored under the SHA1 hash of the
// uncompressed log text.
type Log struct {
	CompressedLog []byte
}

func PutLog(c appengine.Context, text []byte) (hash string, err os.Error) {
	h := sha1.New()
	h.Write(text)
	b := new(bytes.Buffer)
	z, _ := gzip.NewWriterLevel(b, gzip.BestCompression)
	z.Write(text)
	z.Close()
	hash = fmt.Sprintf("%x", h.Sum())
	key := datastore.NewKey(c, "Log", hash, 0, nil)
	_, err = datastore.Put(c, key, &Log{b.Bytes()})
	return
}

// A Tag is used to keep track of the most recent Go weekly and release tags.
// Typically there will be one Tag entity for each kind of hg tag.
type Tag struct {
	Kind string // "weekly", "release", or "tip"
	Name string // the tag itself (for example: "release.r60")
	Hash string
}

func (t *Tag) Key(c appengine.Context) *datastore.Key {
	p := &Package{Path: ""}
	return datastore.NewKey(c, "Tag", t.Kind, 0, p.Key(c))
}

func (t *Tag) Valid() os.Error {
	if t.Kind != "weekly" || t.Kind != "release" || t.Kind != "tip" {
		return os.NewError("invalid Kind")
	}
	if !validHash(t.Hash) {
		return os.NewError("invalid Hash")
	}
	return nil
}

// commitHandler retrieves commit data or records a new commit.
//
// For GET requests it returns a Commit value for the specified
// packagePath and hash.
//
// For POST requests it reads a JSON-encoded Commit value from the request
// body and creates a new Commit entity. It also updates the "tip" Tag for
// each new commit at tip.
//
// This handler is used by a gobuilder process in -commit mode.
func commitHandler(r *http.Request) (interface{}, os.Error) {
	c := appengine.NewContext(r)
	com := new(Commit)

	if r.Method == "GET" {
		com.PackagePath = r.FormValue("packagePath")
		com.Hash = r.FormValue("hash")
		if err := datastore.Get(c, com.Key(c), com); err != nil {
			return nil, err
		}
		return com, nil
	}
	if r.Method != "POST" {
		return nil, errBadMethod(r.Method)
	}

	// POST request
	defer r.Body.Close()
	if err := json.NewDecoder(r.Body).Decode(com); err != nil {
		return nil, err
	}
	if err := com.Valid(); err != nil {
		return nil, err
	}
	tx := func(c appengine.Context) os.Error {
		return addCommit(c, com)
	}
	return nil, datastore.RunInTransaction(c, tx, nil)
}

// addCommit adds the Commit entity to the datastore and updates the tip Tag.
// It must be run inside a datastore transaction.
func addCommit(c appengine.Context, com *Commit) os.Error {
	// if this commit is already in the datastore, do nothing
	var tc Commit // temp value so we don't clobber com
	err := datastore.Get(c, com.Key(c), &tc)
	if err != datastore.ErrNoSuchEntity {
		return err
	}
	// get the next commit number
	p, err := GetPackage(c, com.PackagePath)
	if err != nil {
		return err
	}
	com.Num = p.NextNum
	p.NextNum++
	if _, err := datastore.Put(c, p.Key(c), p); err != nil {
		return err
	}
	// if this isn't the first Commit test the parent commit exists
	if com.Num > 0 {
		n, err := datastore.NewQuery("Commit").
			Filter("Hash =", com.ParentHash).
			Ancestor(p.Key(c)).
			Count(c)
		if err != nil {
			return err
		}
		if n == 0 {
			return os.NewError("parent commit not found")
		}
	}
	// update the tip Tag if this is the Go repo
	if p.Path == "" {
		t := &Tag{Kind: "tip", Hash: com.Hash}
		if _, err = datastore.Put(c, t.Key(c), t); err != nil {
			return err
		}
	}
	// put the Commit
	_, err = datastore.Put(c, com.Key(c), com)
	return err
}

// tagHandler records a new tag. It reads a JSON-encoded Tag value from the
// request body and updates the Tag entity for the Kind of tag provided.
//
// This handler is used by a gobuilder process in -commit mode.
func tagHandler(r *http.Request) (interface{}, os.Error) {
	if r.Method != "POST" {
		return nil, errBadMethod(r.Method)
	}

	t := new(Tag)
	defer r.Body.Close()
	if err := json.NewDecoder(r.Body).Decode(t); err != nil {
		return nil, err
	}
	if err := t.Valid(); err != nil {
		return nil, err
	}
	c := appengine.NewContext(r)
	_, err := datastore.Put(c, t.Key(c), t)
	return nil, err
}

// todoHandler returns the hash of the next Commit to be built.
// It expects a "builder" query parameter.
//
// By default it scans the first 20 Go Commits in Num-descending order and
// returns the first one it finds that doesn't have a Result for this builder.
//
// If provided with additional packagePath and goHash query parameters,
// and scans the first 20 Commits in Num-descending order for the specified
// packagePath and returns the first that doesn't have a Result for this builder
// and goHash combination.
func todoHandler(r *http.Request) (interface{}, os.Error) {
	builder := r.FormValue("builder")
	goHash := r.FormValue("goHash")

	c := appengine.NewContext(r)
	p, err := GetPackage(c, r.FormValue("packagePath"))
	if err != nil {
		return nil, err
	}

	t := datastore.NewQuery("Commit").
		Ancestor(p.Key(c)).
		Limit(commitsPerPage).
		Order("-Num").
		Run(c)
	for {
		com := new(Commit)
		if _, err := t.Next(com); err != nil {
			if err == datastore.Done {
				err = nil
			}
			return nil, err
		}
		var hasResult bool
		if goHash != "" {
			hasResult = com.HasGoHashResult(builder, goHash)
		} else {
			hasResult = com.HasResult(builder)
		}
		if !hasResult {
			return com.Hash, nil
		}
	}
	panic("unreachable")
}

// packagesHandler returns a list of the non-Go Packages monitored
// by the dashboard.
func packagesHandler(r *http.Request) (interface{}, os.Error) {
	c := appengine.NewContext(r)
	var pkgs []*Package
	for t := datastore.NewQuery("Package").Run(c); ; {
		pkg := new(Package)
		if _, err := t.Next(pkg); err == datastore.Done {
			break
		} else if err != nil {
			return nil, err
		}
		if pkg.Path != "" {
			pkgs = append(pkgs, pkg)
		}
	}
	return pkgs, nil
}

// resultHandler records a build result.
// It reads a JSON-encoded Result value from the request body,
// creates a new Result entity, and updates the relevant Commit entity.
// If the Log field is not empty, resultHandler creates a new Log entity
// and updates the LogHash field before putting the Commit entity.
func resultHandler(r *http.Request) (interface{}, os.Error) {
	if r.Method != "POST" {
		return nil, errBadMethod(r.Method)
	}

	c := appengine.NewContext(r)
	res := new(Result)
	defer r.Body.Close()
	if err := json.NewDecoder(r.Body).Decode(res); err != nil {
		return nil, err
	}
	if err := res.Valid(); err != nil {
		return nil, err
	}
	// store the Log text if supplied
	if len(res.Log) > 0 {
		hash, err := PutLog(c, res.Log)
		if err != nil {
			return nil, err
		}
		res.LogHash = hash
	}
	tx := func(c appengine.Context) os.Error {
		// check Package exists
		if _, err := GetPackage(c, res.PackagePath); err != nil {
			return err
		}
		// put Result
		if _, err := datastore.Put(c, res.Key(c), res); err != nil {
			return err
		}
		// add Result to Commit
		com := &Commit{PackagePath: res.PackagePath, Hash: res.Hash}
		return com.AddResult(c, res)
	}
	return nil, datastore.RunInTransaction(c, tx, nil)
}

func logHandler(w http.ResponseWriter, r *http.Request) {
	c := appengine.NewContext(r)
	h := r.URL.Path[len("/log/"):]
	k := datastore.NewKey(c, "Log", h, 0, nil)
	l := new(Log)
	if err := datastore.Get(c, k, l); err != nil {
		logErr(w, r, err)
		return
	}
	d, err := gzip.NewReader(bytes.NewBuffer(l.CompressedLog))
	if err != nil {
		logErr(w, r, err)
		return
	}
	if _, err := io.Copy(w, d); err != nil {
		logErr(w, r, err)
	}
}

type errBadMethod string

func (e errBadMethod) String() string {
	return "bad method: " + string(e)
}

type dashHandler func(*http.Request) (interface{}, os.Error)

type dashResponse struct {
	Response interface{}
	Error    string
}

// AuthHandler wraps a http.HandlerFunc with a handler that validates the
// supplied key and builder query parameters.
func AuthHandler(h dashHandler) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// Put the URL Query values into r.Form to avoid parsing the
		// request body when calling r.FormValue.
		r.Form = r.URL.Query()

		// Validate key query parameter for POST requests only.
		key := r.FormValue("key")
		if r.Method == "POST" && key != secretKey {
			h := sha1.New()
			h.Write([]byte(r.FormValue("builder") + secretKey))
			if key != fmt.Sprintf("%x", h.Sum()) {
				logErr(w, r, os.NewError("invalid key"))
				return
			}
		}

		// Call the original HandlerFunc and return the response.
		c := appengine.NewContext(r)
		resp, err := h(r)
		dashResp := dashResponse{Response: resp}
		if err != nil {
			c.Errorf("%v", err)
			dashResp.Error = err.String()
		}
		w.Header().Set("Content-Type", "application/json")
		if err = json.NewEncoder(w).Encode(dashResp); err != nil {
			c.Criticalf("%v", err)
		}
	}
}

func initHandler(w http.ResponseWriter, r *http.Request) {
	// TODO(adg): devise a better way of bootstrapping new packages
	var pkgs = []*Package{
		&Package{Name: "Go", Path: ""},
		&Package{Name: "Test", Path: "code.google.com/p/go.test"},
	}
	c := appengine.NewContext(r)
	for _, p := range pkgs {
		_, err := datastore.Put(c, p.Key(c), p)
		if err != nil {
			logErr(w, r, err)
			return
		}
	}
	fmt.Fprint(w, "OK")
}

func init() {
	// admin handlers
	http.HandleFunc("/init", initHandler)

	// authenticated handlers
	http.HandleFunc("/commit", AuthHandler(commitHandler))
	http.HandleFunc("/packages", AuthHandler(packagesHandler))
	http.HandleFunc("/result", AuthHandler(resultHandler))
	http.HandleFunc("/tag", AuthHandler(tagHandler))
	http.HandleFunc("/todo", AuthHandler(todoHandler))

	// public handlers
	http.HandleFunc("/log/", logHandler)
}

func validHash(hash string) bool {
	// TODO(adg): correctly validate a hash
	return hash != ""
}

func logErr(w http.ResponseWriter, r *http.Request, err os.Error) {
	appengine.NewContext(r).Errorf("Error: %v", err)
	w.WriteHeader(http.StatusInternalServerError)
	fmt.Fprint(w, "Error: ", err)
}
