// Copyright (c) Facebook, Inc. and its affiliates.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package nvd

import (
	"context"
	"fmt"
	"io"
	"io/ioutil"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"github.com/facebookincubator/flog"
	"github.com/facebookincubator/nvdtools/providers/lib/client"
)

// CPE defines the CPE data feed for synchronization.
type CPE int

// Supported CPE feeds.
const (
	cpe23xmlGz  CPE = iota // CPE database in XML 2.3 format, gzip compressed.
	cpe23xmlZip            // CPE database in XML 2.3 format, zip compressed.
	cpe22xmlGz             // CPE database in XML 2.2 format, gzip compressed.
	cpe22xmlZip            // CPE database in XML 2.2 format, zip compressed.
)

// SupportedCPE contains all supported CPE data feeds indexed by name.
var SupportedCPE = map[string]CPE{
	"cpe-2.2.xml.gz":  cpe22xmlGz,
	"cpe-2.2.xml.zip": cpe22xmlZip,
	"cpe-2.3.xml.gz":  cpe23xmlGz,
	"cpe-2.3.xml.zip": cpe23xmlZip,
}

// Set implements the flag.Value interface.
func (c *CPE) Set(v string) error {
	feed, exists := SupportedCPE[v]
	if !exists {
		return fmt.Errorf("unsupported CPE feed: %q", v)
	}
	*c = feed
	return nil
}

// String implements the fmt.Stringer interface.
func (c CPE) String() string {
	return "cpe-" + c.version() + ".xml." + c.compression()
}

// Help returns the CPE flag help.
func (c CPE) Help() string {
	opts := make([]string, 0, len(SupportedCPE))
	for k := range SupportedCPE {
		opts = append(opts, k)
	}
	sort.Strings(opts)
	return fmt.Sprintf(
		"CPE feed to sync (default: %s)\navailable:\n%s",
		c, strings.Join(opts, "\n"),
	)
}

// compression returns the data feed compression: gz or zip.
func (c CPE) compression() string {
	switch c {
	case cpe22xmlGz, cpe23xmlGz:
		return "gz"
	case cpe22xmlZip, cpe23xmlZip:
		return "zip"
	default:
		panic("unsupported CPE compression")
	}
}

// version returns the data feed version.
func (c CPE) version() string {
	switch c {
	case cpe22xmlGz, cpe22xmlZip:
		return "2.2"
	case cpe23xmlGz, cpe23xmlZip:
		return "2.3"
	default:
		panic("unsupported CPE version")
	}
}

// Sync synchronizes the CPE feed to a local directory.
func (c CPE) Sync(ctx context.Context, src SourceConfig, localdir string) error {
	basename := "official-cpe-dictionary_v" + c.version()
	cf := cpeFile{
		CPE:      c,
		EtagFile: basename + ".etag",
		DataFile: basename + ".xml." + c.compression(),
	}
	return cf.Sync(ctx, src, localdir)
}

type cpeFile struct {
	CPE
	EtagFile string
	DataFile string
}

func (cf cpeFile) baseURL(src SourceConfig) string {
	u := url.URL{
		Scheme: src.Scheme,
		Host:   src.Host,
		Path:   src.CPEFeedPath,
	}
	baseURL := u.String()
	if !strings.HasSuffix(baseURL, "/") {
		baseURL += "/"
	}
	return baseURL
}

func (cf cpeFile) Sync(ctx context.Context, src SourceConfig, localdir string) error {
	baseURL := cf.baseURL(src)
	sourceURL := baseURL + cf.DataFile
	needsUpdate, err := cf.needsUpdate(ctx, sourceURL, localdir)
	if err != nil {
		return err
	}
	if !needsUpdate {
		return nil
	}
	etag, tempDataFilename, err := cf.download(ctx, sourceURL)
	if err != nil {
		return err
	}
	defer os.Remove(tempDataFilename)

	// write etag file
	etagFilename := filepath.Join(localdir, cf.EtagFile)
	err = ioutil.WriteFile(etagFilename, []byte(etag), 0644)
	if err != nil {
		return err
	}

	// write data file
	dataFilename := filepath.Join(localdir, cf.DataFile)
	bakDataFilename := dataFilename + ".bak"
	xRename(dataFilename, bakDataFilename)
	if err = xRename(tempDataFilename, dataFilename); err != nil {
		xRename(bakDataFilename, dataFilename)
		return err
	}
	os.Remove(bakDataFilename)
	return nil
}

func (cf cpeFile) needsUpdate(ctx context.Context, targetURL, localdir string) (bool, error) {
	flog.V(1).Infof("checking etag for %q", targetURL)
	req, err := httpNewRequestContext(ctx, "HEAD", targetURL)
	if err != nil {
		return false, err
	}
	resp, err := client.Default().Do(req)
	if err != nil {
		return false, err
	}
	defer resp.Body.Close()
	if err = httpResponseNotOK(resp); err != nil {
		return false, err
	}
	remoteEtag := resp.Header.Get("Etag")
	if remoteEtag == "" {
		return false, fmt.Errorf("server not returning etag header for %q", targetURL)
	}
	etagBytes, err := ioutil.ReadFile(filepath.Join(localdir, cf.EtagFile))
	if err != nil {
		flog.V(1).Infof("etag file %q for not exist in %q, needs sync", cf.EtagFile, localdir)
		return true, nil
	}
	localEtag := string(etagBytes)
	if localEtag != remoteEtag {
		flog.V(1).Infof("data file %q needs update in %q: hash mismatch %q != %q", cf.DataFile, localdir, localEtag, remoteEtag)
		return true, nil
	}
	return false, nil
}

// download file from targetURL, returns etag and path to local file.
func (cf cpeFile) download(ctx context.Context, targetURL string) (string, string, error) {
	flog.V(1).Infof("downloading data file %q", targetURL)
	req, err := http.NewRequest("GET", targetURL, nil)
	if err != nil {
		return "", "", err
	}
	resp, err := client.Default().Do(req.WithContext(ctx))
	if err != nil {
		return "", "", err
	}
	defer resp.Body.Close()
	if err = httpResponseNotOK(resp); err != nil {
		return "", "", err
	}
	dataFile, err := ioutil.TempFile("", "nvdsync-data-")
	if err != nil {
		return "", "", err
	}
	_, err = io.Copy(dataFile, resp.Body)
	if err != nil {
		return "", "", err
	}
	return resp.Header.Get("Etag"), dataFile.Name(), nil
}
