package db

import (
	"compress/gzip"
	"context"
	"encoding/json"
	"io"
	"os"
	"path/filepath"

	"github.com/google/wire"
	"github.com/spf13/afero"
	"golang.org/x/xerrors"
	"k8s.io/utils/clock"

	"github.com/aquasecurity/trivy-db/pkg/db"
	"github.com/aquasecurity/trivy/pkg/github"
	"github.com/aquasecurity/trivy/pkg/indicator"
	"github.com/aquasecurity/trivy/pkg/log"
)

const (
	fullDB  = "trivy.db.gz"
	lightDB = "trivy-light.db.gz"

	metadataFile = "metadata.json"
)

var SuperSet = wire.NewSet(
	// indicator.ProgressBar
	indicator.NewProgressBar,

	// clock.Clock
	wire.Struct(new(clock.RealClock)),
	wire.Bind(new(clock.Clock), new(clock.RealClock)),

	// db.Config
	wire.Struct(new(db.Config)),
	wire.Bind(new(dbOperation), new(db.Config)),

	// github.Client
	github.NewClient,
	wire.Bind(new(github.Operation), new(github.Client)),

	// Metadata
	afero.NewOsFs,
	NewMetadata,

	// db.Client
	NewClient,
	wire.Bind(new(Operation), new(Client)),
)

type Operation interface {
	NeedsUpdate(cliVersion string, skip, light bool) (need bool, err error)
	Download(ctx context.Context, cacheDir string, light bool) (err error)
	UpdateMetadata(cacheDir string) (err error)
}

type dbOperation interface {
	GetMetadata() (metadata db.Metadata, err error)
}

type Client struct {
	dbc          dbOperation
	githubClient github.Operation
	pb           indicator.ProgressBar
	clock        clock.Clock
	metadata     Metadata
}

func NewClient(dbc dbOperation, githubClient github.Operation, pb indicator.ProgressBar, clock clock.Clock, metadata Metadata) Client {
	return Client{
		dbc:          dbc,
		githubClient: githubClient,
		pb:           pb,
		clock:        clock,
		metadata:     metadata,
	}
}

func (c Client) NeedsUpdate(cliVersion string, light, skip bool) (bool, error) {
	dbType := db.TypeFull
	if light {
		dbType = db.TypeLight
	}

	metadata, err := c.metadata.Get()
	if err != nil {
		log.Logger.Debugf("There is no valid metadata file: %s", err)
		if skip {
			log.Logger.Error("The first run cannot skip downloading DB")
			return false, xerrors.New("--skip-update cannot be specified on the first run")
		}
		metadata = db.Metadata{} // suppress a warning
	}

	if db.SchemaVersion < metadata.Version {
		log.Logger.Errorf("Trivy version (%s) is old. Update to the latest version.", cliVersion)
		return false, xerrors.Errorf("the version of DB schema doesn't match. Local DB: %d, Expected: %d",
			metadata.Version, db.SchemaVersion)
	}

	if skip {
		if db.SchemaVersion != metadata.Version {
			log.Logger.Error("The local DB is old and needs to be updated")
			return false, xerrors.New("--skip-update cannot be specified with the old DB")
		} else if metadata.Type != dbType {
			if dbType == db.TypeFull {
				log.Logger.Error("The local DB is a lightweight DB. You have to download a full DB")
			} else {
				log.Logger.Error("The local DB is a full DB. You have to download a lightweight DB")
			}
			return false, xerrors.New("--skip-update cannot be specified with the different schema DB")
		}
		return false, nil
	}

	if db.SchemaVersion == metadata.Version && metadata.Type == dbType &&
		c.clock.Now().Before(metadata.NextUpdate) {
		log.Logger.Debug("DB update was skipped because DB is the latest")
		return false, nil
	}
	return true, nil
}

func (c Client) Download(ctx context.Context, cacheDir string, light bool) error {
	// Remove the metadata file before downloading DB
	if err := c.metadata.Delete(); err != nil {
		log.Logger.Debug("no metadata file")
	}

	dbFile := fullDB
	if light {
		dbFile = lightDB
	}

	rc, size, err := c.githubClient.DownloadDB(ctx, dbFile)
	if err != nil {
		return xerrors.Errorf("failed to download vulnerability DB: %w", err)
	}
	defer rc.Close()

	bar := c.pb.Start(int64(size))
	barReader := bar.NewProxyReader(rc)
	defer bar.Finish()

	gr, err := gzip.NewReader(barReader)
	if err != nil {
		return xerrors.Errorf("invalid gzip file: %w", err)
	}

	dbPath := db.Path(cacheDir)
	dbDir := filepath.Dir(dbPath)

	if err = os.MkdirAll(dbDir, 0700); err != nil {
		return xerrors.Errorf("failed to mkdir: %w", err)
	}

	file, err := os.Create(dbPath)
	if err != nil {
		return xerrors.Errorf("unable to open DB file: %w", err)
	}
	defer file.Close()

	if _, err = io.Copy(file, gr); err != nil {
		return xerrors.Errorf("failed to save DB file: %w", err)
	}

	return nil
}

func (c Client) UpdateMetadata(cacheDir string) error {
	log.Logger.Debug("Updating database metadata...")

	// make sure the DB has been successfully downloaded
	if err := db.Init(cacheDir); err != nil {
		return xerrors.Errorf("DB error: %w", err)
	}
	defer db.Close()

	metadata, err := c.dbc.GetMetadata()
	if err != nil {
		return xerrors.Errorf("unable to get a metadata: %w", err)
	}

	if err = c.metadata.Store(metadata); err != nil {
		return xerrors.Errorf("failed to store metadata: %w", err)
	}

	return nil
}

type Metadata struct {
	fs       afero.Fs
	filePath string
}

func NewMetadata(fs afero.Fs, cacheDir string) Metadata {
	filePath := MetadataPath(cacheDir)
	return Metadata{
		fs:       fs,
		filePath: filePath,
	}
}

func MetadataPath(cacheDir string) string {
	dbPath := db.Path(cacheDir)
	dbDir := filepath.Dir(dbPath)
	return filepath.Join(dbDir, metadataFile)
}

// StoreMetadata stores database metadata as a file
func (m Metadata) Store(metadata db.Metadata) error {
	f, err := m.fs.Create(m.filePath)
	if err != nil {
		return xerrors.Errorf("unable to create a metadata file: %w", err)
	}
	defer f.Close()

	if err = json.NewEncoder(f).Encode(metadata); err != nil {
		return xerrors.Errorf("unable to encode metadata: %w", err)
	}
	return nil
}

// DeleteMetadata deletes the file of database metadata
func (m Metadata) Delete() error {
	if err := m.fs.Remove(m.filePath); err != nil {
		return xerrors.Errorf("unable to remove the metadata file: %w", err)
	}
	return nil
}

func (m Metadata) Get() (db.Metadata, error) {
	f, err := m.fs.Open(m.filePath)
	if err != nil {
		return db.Metadata{}, xerrors.Errorf("unable to open a file: %w", err)
	}
	defer f.Close()

	var metadata db.Metadata
	if err = json.NewDecoder(f).Decode(&metadata); err != nil {
		return db.Metadata{}, xerrors.Errorf("unable to decode metadata: %w", err)
	}
	return metadata, nil
}
