// Copyright 2019 Dolthub, Inc.
//
// 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 schcmds

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"github.com/fatih/color"

	"github.com/dolthub/dolt/go/cmd/dolt/cli"
	"github.com/dolthub/dolt/go/cmd/dolt/commands"
	"github.com/dolthub/dolt/go/cmd/dolt/errhand"
	eventsapi "github.com/dolthub/dolt/go/gen/proto/dolt/services/eventsapi/v1alpha1"
	"github.com/dolthub/dolt/go/libraries/doltcore/doltdb"
	"github.com/dolthub/dolt/go/libraries/doltcore/env"
	"github.com/dolthub/dolt/go/libraries/doltcore/env/actions"
	"github.com/dolthub/dolt/go/libraries/doltcore/rowconv"
	"github.com/dolthub/dolt/go/libraries/doltcore/schema"
	"github.com/dolthub/dolt/go/libraries/doltcore/schema/encoding"
	"github.com/dolthub/dolt/go/libraries/doltcore/sqle"
	"github.com/dolthub/dolt/go/libraries/doltcore/table"
	"github.com/dolthub/dolt/go/libraries/doltcore/table/untyped/csv"
	"github.com/dolthub/dolt/go/libraries/utils/argparser"
	"github.com/dolthub/dolt/go/libraries/utils/filesys"
	"github.com/dolthub/dolt/go/libraries/utils/funcitr"
	"github.com/dolthub/dolt/go/libraries/utils/set"
	"github.com/dolthub/dolt/go/store/types"
)

const (
	createFlag          = "create"
	updateFlag          = "update"
	replaceFlag         = "replace"
	dryRunFlag          = "dry-run"
	fileTypeParam       = "file-type"
	pksParam            = "pks"
	mappingParam        = "map"
	floatThresholdParam = "float-threshold"
	keepTypesParam      = "keep-types"
	delimParam          = "delim"
)

var MappingFileHelp = "A mapping file is json in the format:" + `

	{
		"source_field_name":"dest_field_name"
		...
	}

where source_field_name is the name of a field in the file being imported and dest_field_name is the name of a field in the table being imported to.
`

var schImportDocs = cli.CommandDocumentationContent{
	ShortDesc: "Creates a new table with an inferred schema.",
	LongDesc: `If {{.EmphasisLeft}}--create | -c{{.EmphasisRight}} is given the operation will create {{.LessThan}}table{{.GreaterThan}} with a schema that it infers from the supplied file. One or more primary key columns must be specified using the {{.EmphasisLeft}}--pks{{.EmphasisRight}} parameter.

If {{.EmphasisLeft}}--update | -u{{.EmphasisRight}} is given the operation will update {{.LessThan}}table{{.GreaterThan}} any additional columns, or change the types of columns based on the file supplied.  If the {{.EmphasisLeft}}--keep-types{{.EmphasisRight}} parameter is supplied then the types for existing columns will not be modified, even if they differ from what is in the supplied file.

If {{.EmphasisLeft}}--replace | -r{{.EmphasisRight}} is given the operation will replace {{.LessThan}}table{{.GreaterThan}} with a new, empty table which has a schema inferred from the supplied file but columns tags will be maintained across schemas.  {{.EmphasisLeft}}--keep-types{{.EmphasisRight}} can also be supplied here to guarantee that types are the same in the file and in the pre-existing table.

A mapping file can be used to map fields between the file being imported and the table's schema being inferred.  This can be used when creating a new table, or updating or replacing an existing table.

` + MappingFileHelp + `

In create, update, and replace scenarios the file's extension is used to infer the type of the file.  If a file does not have the expected extension then the {{.EmphasisLeft}}--file-type{{.EmphasisRight}} parameter should be used to explicitly define the format of the file in one of the supported formats (Currently only csv is supported).  For files separated by a delimiter other than a ',', the --delim parameter can be used to specify a delimeter.

If the parameter {{.EmphasisLeft}}--dry-run{{.EmphasisRight}} is supplied a sql statement will be generated showing what would be executed if this were run without the --dry-run flag

{{.EmphasisLeft}}--float-threshold{{.EmphasisRight}} is the threshold at which a string representing a floating point number should be interpreted as a float versus an int.  If FloatThreshold is 0.0 then any number with a decimal point will be interpreted as a float (such as 0.0, 1.0, etc).  If FloatThreshold is 1.0 then any number with a decimal point will be converted to an int (0.5 will be the int 0, 1.99 will be the int 1, etc.  If the FloatThreshold is 0.001 then numbers with a fractional component greater than or equal to 0.001 will be treated as a float (1.0 would be an int, 1.0009 would be an int, 1.001 would be a float, 1.1 would be a float, etc)
`,

	Synopsis: []string{
		`[--create|--replace] [--force] [--dry-run] [--lower|--upper] [--keep-types] [--file-type <type>] [--float-threshold] [--map {{.LessThan}}mapping-file{{.GreaterThan}}] [--delim {{.LessThan}}delimiter{{.GreaterThan}}]--pks {{.LessThan}}field{{.GreaterThan}},... {{.LessThan}}table{{.GreaterThan}} {{.LessThan}}file{{.GreaterThan}}`,
	},
}

type SchImportOp int

const (
	CreateOp SchImportOp = iota
	UpdateOp
	ReplaceOp
)

type importOptions struct {
	op             SchImportOp
	fileName       string
	fileType       string
	delim          string
	tableName      string
	existingSch    schema.Schema
	PkCols         []string
	keepTypes      bool
	colMapper      rowconv.NameMapper
	floatThreshold float64
}

func (im *importOptions) ColNameMapper() rowconv.NameMapper {
	return im.colMapper
}
func (im *importOptions) FloatThreshold() float64 {
	return im.floatThreshold
}

type ImportCmd struct{}

// Name is returns the name of the Dolt cli command. This is what is used on the command line to invoke the command
func (cmd ImportCmd) Name() string {
	return "import"
}

// Description returns a description of the command
func (cmd ImportCmd) Description() string {
	return "Creates a new table with an inferred schema."
}

// EventType returns the type of the event to log
func (cmd ImportCmd) EventType() eventsapi.ClientEventType {
	return eventsapi.ClientEventType_SCHEMA
}

// CreateMarkdown creates a markdown file containing the helptext for the command at the given path
func (cmd ImportCmd) CreateMarkdown(fs filesys.Filesys, path, commandStr string) error {
	ap := cmd.createArgParser()
	return commands.CreateMarkdown(fs, path, cli.GetCommandDocumentation(commandStr, schImportDocs, ap))
}

func (cmd ImportCmd) createArgParser() *argparser.ArgParser {
	ap := argparser.NewArgParser()
	ap.ArgListHelp = append(ap.ArgListHelp, [2]string{"table", "Name of the table to be created."})
	ap.ArgListHelp = append(ap.ArgListHelp, [2]string{"file", "The file being used to infer the schema."})
	ap.SupportsFlag(createFlag, "c", "Create a table with the schema inferred from the {{.LessThan}}file{{.GreaterThan}} provided.")
	ap.SupportsFlag(updateFlag, "u", "Update a table to match the inferred schema of the {{.LessThan}}file{{.GreaterThan}} provided")
	ap.SupportsFlag(replaceFlag, "r", "Replace a table with a new schema that has the inferred schema from the {{.LessThan}}file{{.GreaterThan}} provided. All previous data will be lost.")
	ap.SupportsFlag(dryRunFlag, "", "Print the sql statement that would be run if executed without the flag.")
	ap.SupportsFlag(keepTypesParam, "", "When a column already exists in the table, and it's also in the {{.LessThan}}file{{.GreaterThan}} provided, use the type from the table.")
	ap.SupportsString(fileTypeParam, "", "type", "Explicitly define the type of the file if it can't be inferred from the file extension.")
	ap.SupportsString(pksParam, "", "comma-separated-col-names", "List of columns used as the primary key cols.  Order of the columns will determine sort order.")
	ap.SupportsString(mappingParam, "m", "mapping-file", "A file that can map a column name in {{.LessThan}}file{{.GreaterThan}} to a new value.")
	ap.SupportsString(floatThresholdParam, "", "float", "Minimum value at which the fractional component of a value must exceed in order to be considered a float.")
	ap.SupportsString(delimParam, "", "delimiter", "Specify a delimiter for a csv style file with a non-comma delimiter.")
	return ap
}

// Exec implements the import schema command that will take a file and infer it's schema, and then create a table matching that schema.
// Exec executes the command
func (cmd ImportCmd) Exec(ctx context.Context, commandStr string, args []string, dEnv *env.DoltEnv) int {
	ap := cmd.createArgParser()
	help, usage := cli.HelpAndUsagePrinters(cli.GetCommandDocumentation(commandStr, schImportDocs, ap))
	apr := cli.ParseArgsOrDie(ap, args, help)

	if apr.NArg() != 2 {
		usage()
		return 1
	}

	return commands.HandleVErrAndExitCode(importSchema(ctx, dEnv, apr), usage)
}

func getSchemaImportArgs(ctx context.Context, apr *argparser.ArgParseResults, dEnv *env.DoltEnv, root *doltdb.RootValue) (*importOptions, errhand.VerboseError) {
	tblName := apr.Arg(0)
	fileName := apr.Arg(1)

	fileExists, _ := dEnv.FS.Exists(fileName)

	if !fileExists {
		return nil, errhand.BuildDError("error: file '%s' not found.", fileName).Build()
	}

	if err := ValidateTableNameForCreate(tblName); err != nil {
		return nil, err
	}

	flags := apr.ContainsMany(createFlag, updateFlag, replaceFlag)
	if len(flags) == 0 {
		return nil, errhand.BuildDError("error: missing required parameter.").AddDetails("Must provide exactly one of the operation flags '--create', or '--replace'").SetPrintUsage().Build()
	} else if len(flags) > 1 {
		return nil, errhand.BuildDError("error: multiple operations supplied").AddDetails("Only one of the flags '--create', '--update', or '--replace' may be provided").SetPrintUsage().Build()
	}

	var op SchImportOp
	switch flags[0] {
	case createFlag:
		op = CreateOp
	case updateFlag:
		op = UpdateOp
	case replaceFlag:
		op = ReplaceOp
	}

	if apr.Contains(keepTypesParam) && op == CreateOp {
		return nil, errhand.BuildDError("error: parameter keep-types not supported for create operations").AddDetails("keep-types parameter is used to keep the existing column types as is without modification.").Build()
	}

	tbl, tblExists, err := root.GetTable(ctx, tblName)

	if err != nil {
		return nil, errhand.BuildDError("error: failed to read from database.").AddCause(err).Build()
	} else if tblExists && op == CreateOp {
		return nil, errhand.BuildDError("error: failed to create table.").AddDetails("A table named '%s' already exists.", tblName).AddDetails("Use --replace or --update instead of --create.").Build()
	}

	var existingSch schema.Schema = schema.EmptySchema
	if tblExists {
		existingSch, err = tbl.GetSchema(ctx)

		if err != nil {
			return nil, errhand.BuildDError("error: failed to read schema from '%s'", tblName).AddCause(err).Build()
		}
	}

	val, pksOK := apr.GetValue(pksParam)
	pks := funcitr.MapStrings(strings.Split(val, ","), strings.TrimSpace)
	pks = funcitr.FilterStrings(pks, func(s string) bool { return s != "" })

	if !pksOK {
		return nil, errhand.BuildDError("error: missing required parameter pks").SetPrintUsage().Build()
	}
	if len(pks) == 0 {
		return nil, errhand.BuildDError("error: no valid columns provided in --pks argument").Build()
	}

	mappingFile := apr.GetValueOrDefault(mappingParam, "")
	colMapper, err := rowconv.NameMapperFromFile(mappingFile, dEnv.FS)

	if err != nil {
		return nil, errhand.VerboseErrorFromError(err)
	}

	floatThresholdStr := apr.GetValueOrDefault(floatThresholdParam, "0.0")
	floatThreshold, err := strconv.ParseFloat(floatThresholdStr, 64)

	if err != nil {
		return nil, errhand.BuildDError("error: '%s' is not a valid float in the range 0.0 (all floats) to 1.0 (no floats)", floatThresholdStr).SetPrintUsage().Build()
	}

	return &importOptions{
		op:             op,
		fileName:       fileName,
		fileType:       apr.GetValueOrDefault(fileTypeParam, filepath.Ext(fileName)),
		delim:          apr.GetValueOrDefault(delimParam, ","),
		tableName:      tblName,
		existingSch:    existingSch,
		PkCols:         pks,
		keepTypes:      apr.Contains(keepTypesParam),
		colMapper:      colMapper,
		floatThreshold: floatThreshold,
	}, nil
}

func importSchema(ctx context.Context, dEnv *env.DoltEnv, apr *argparser.ArgParseResults) errhand.VerboseError {
	root, verr := commands.GetWorkingWithVErr(dEnv)
	if verr != nil {
		return verr
	}

	impArgs, verr := getSchemaImportArgs(ctx, apr, dEnv, root)
	if verr != nil {
		return verr
	}

	sch, verr := inferSchemaFromFile(ctx, dEnv.DoltDB.ValueReadWriter().Format(), impArgs, root)
	if verr != nil {
		return verr
	}

	tblName := impArgs.tableName
	// inferred schemas have no foreign keys
	sqlDb := sqle.NewSingleTableDatabase(tblName, sch, nil, nil)
	sqlCtx, engine, _ := sqle.PrepareCreateTableStmt(ctx, sqlDb)
	stmt, err := sqle.GetCreateTableStmt(sqlCtx, engine, tblName)
	if err != nil {
		return errhand.VerboseErrorFromError(err)
	}
	cli.Println(stmt)

	if !apr.Contains(dryRunFlag) {
		tbl, tblExists, err := root.GetTable(ctx, tblName)
		if err != nil {
			return errhand.BuildDError("error: failed to get table.").AddCause(err).Build()
		}

		schVal, err := encoding.MarshalSchemaAsNomsValue(context.Background(), root.VRW(), sch)
		if err != nil {
			return errhand.BuildDError("error: failed to encode schema.").AddCause(err).Build()
		}

		empty, err := types.NewMap(ctx, root.VRW())
		if err != nil {
			return errhand.BuildDError("error: failed to create table.").AddCause(err).Build()
		}

		indexData := empty
		if tblExists {
			indexData, err = tbl.GetIndexData(ctx)
			if err != nil {
				return errhand.BuildDError("error: failed to create table.").AddCause(err).Build()
			}
		}

		tbl, err = doltdb.NewTable(ctx, root.VRW(), schVal, empty, indexData, nil)
		if err != nil {
			return errhand.BuildDError("error: failed to create table.").AddCause(err).Build()
		}

		root, err = root.PutTable(ctx, tblName, tbl)
		if err != nil {
			return errhand.BuildDError("error: failed to add table.").AddCause(err).Build()
		}

		err = dEnv.UpdateWorkingRoot(ctx, root)
		if err != nil {
			return errhand.BuildDError("error: failed to update the working set.").AddCause(err).Build()
		}

		cli.PrintErrln(color.CyanString("Created table successfully."))
	}

	return nil
}

func inferSchemaFromFile(ctx context.Context, nbf *types.NomsBinFormat, impOpts *importOptions, root *doltdb.RootValue) (schema.Schema, errhand.VerboseError) {
	if impOpts.fileType[0] == '.' {
		impOpts.fileType = impOpts.fileType[1:]
	}

	var rd table.TableReadCloser
	csvInfo := csv.NewCSVInfo().SetDelim(",")

	switch impOpts.fileType {
	case "csv":
		if impOpts.delim != "" {
			csvInfo.SetDelim(impOpts.delim)
		}
	case "psv":
		csvInfo.SetDelim("|")
	default:
		return nil, errhand.BuildDError("error: unsupported file type '%s'", impOpts.fileType).Build()
	}

	f, err := os.Open(impOpts.fileName)

	if err != nil {
		return nil, errhand.BuildDError("error: failed to open '%s'", impOpts.fileName).Build()
	}

	defer f.Close()

	rd, err = csv.NewCSVReader(nbf, f, csvInfo)

	if err != nil {
		return nil, errhand.BuildDError("error: failed to create a CSVReader.").AddCause(err).Build()
	}

	defer rd.Close(ctx)

	infCols, err := actions.InferColumnTypesFromTableReader(ctx, root, rd, impOpts)

	if err != nil {
		return nil, errhand.BuildDError("error: failed to infer schema").AddCause(err).Build()
	}

	return CombineColCollections(ctx, root, infCols, impOpts)
}

func CombineColCollections(ctx context.Context, root *doltdb.RootValue, inferredCols *schema.ColCollection, impOpts *importOptions) (schema.Schema, errhand.VerboseError) {
	existingCols := impOpts.existingSch.GetAllCols()

	// oldCols is the subset of existingCols that will be kept in the new schema
	var oldCols *schema.ColCollection
	// newCols is the subset of inferredCols that will be added to the new schema
	var newCols *schema.ColCollection

	var verr errhand.VerboseError
	switch impOpts.op {
	case CreateOp:
		oldCols = schema.EmptyColColl
		newCols = columnsForSchemaCreate(inferredCols, impOpts.PkCols)
	case UpdateOp:
		oldCols, newCols, verr = columnsForSchemaUpdate(existingCols, inferredCols, impOpts.keepTypes)
	case ReplaceOp:
		oldCols, newCols, verr = columnsForSchemaReplace(existingCols, inferredCols, impOpts.keepTypes)
	}

	if verr != nil {
		return nil, verr
	}

	newCols, err := root.GenerateTagsForNewColColl(ctx, impOpts.tableName, newCols)
	if err != nil {
		return nil, errhand.BuildDError("failed to generate new schema").AddCause(err).Build()
	}

	combined := oldCols.AppendColl(newCols)

	err = schema.ValidateForInsert(combined)
	if err != nil {
		return nil, errhand.BuildDError("invalid schema").AddCause(err).Build()
	}

	sch, err := schema.SchemaFromCols(combined)
	if err != nil {
		return nil, errhand.BuildDError("failed to get schema from cols").AddCause(err).Build()
	}
	return sch, nil
}

func columnsForSchemaCreate(inferredCols *schema.ColCollection, pkNames []string) (newCols *schema.ColCollection) {
	pks := set.NewStrSet(pkNames)
	newCols = schema.MapColCollection(inferredCols, func(col schema.Column) schema.Column {
		col.IsPartOfPK = pks.Contains(col.Name)
		return col
	})
	return newCols
}

func columnsForSchemaUpdate(existingCols, inferredCols *schema.ColCollection, keepTypes bool) (oldCols, newCols *schema.ColCollection, verr errhand.VerboseError) {
	ecn := set.NewStrSet(existingCols.GetColumnNames())
	icn := set.NewStrSet(inferredCols.GetColumnNames())

	// (L - R), (L ∩ R), (R - L)
	left, inter, right := ecn.LeftIntersectionRight(icn)

	// intersection columns with the same types are added to oldCols
	sameType := set.NewStrSet(nil)
	inter.Iterate(func(colName string) (cont bool) {
		ec, _ := existingCols.GetByName(colName)
		ic, _ := inferredCols.GetByName(colName)
		if ec.TypeInfo.Equals(ic.TypeInfo) {
			sameType.Add(colName)
		}
		return true
	})

	if keepTypes {
		oldCols = existingCols
		newCols = schema.FilterColCollection(inferredCols, func(col schema.Column) bool {
			return right.Contains(col.Name)
		})
	} else {
		oldCols = schema.FilterColCollection(existingCols, func(col schema.Column) bool {
			return left.Contains(col.Name) || sameType.Contains(col.Name)
		})
		newCols = schema.FilterColCollection(inferredCols, func(col schema.Column) bool {
			return !sameType.Contains(col.Name)
		})
	}

	verr = verifyPKsUnchanged(existingCols, oldCols, newCols)
	if verr != nil {
		return nil, nil, verr
	}

	return oldCols, newCols, nil
}

func columnsForSchemaReplace(existingCols, inferredCols *schema.ColCollection, keepTypes bool) (oldCols, newCols *schema.ColCollection, verr errhand.VerboseError) {
	ecn := set.NewStrSet(existingCols.GetColumnNames())
	icn := set.NewStrSet(inferredCols.GetColumnNames())

	// (L - R), (L ∩ R), (R - L)
	_, inter, right := ecn.LeftIntersectionRight(icn)

	// intersection columns with the same types are added to oldCols
	sameType := set.NewStrSet(nil)
	inter.Iterate(func(colName string) (cont bool) {
		ec, _ := existingCols.GetByName(colName)
		ic, _ := inferredCols.GetByName(colName)
		if ec.TypeInfo.Equals(ic.TypeInfo) {
			sameType.Add(colName)
		}
		return true
	})

	if keepTypes {
		oldCols = schema.FilterColCollection(existingCols, func(col schema.Column) bool {
			return inter.Contains(col.Name)
		})
		newCols = schema.FilterColCollection(inferredCols, func(col schema.Column) bool {
			return right.Contains(col.Name)
		})
	} else {
		oldCols = schema.FilterColCollection(existingCols, func(col schema.Column) bool {
			return sameType.Contains(col.Name)
		})
		newCols = schema.FilterColCollection(inferredCols, func(col schema.Column) bool {
			return !sameType.Contains(col.Name)
		})
	}

	verr = verifyPKsUnchanged(existingCols, oldCols, newCols)
	if verr != nil {
		return nil, nil, verr
	}

	return oldCols, newCols, nil
}

func verifyPKsUnchanged(existingCols, oldCols, newCols *schema.ColCollection) errhand.VerboseError {
	err := newCols.Iter(func(tag uint64, col schema.Column) (stop bool, err error) {
		if col.IsPartOfPK {
			return true, fmt.Errorf("Cannot add primary keys using schema import")
		}
		return false, nil
	})
	if err != nil {
		return errhand.VerboseErrorFromError(err)
	}

	existingPKs := schema.FilterColCollection(existingCols, func(col schema.Column) bool {
		return col.IsPartOfPK
	})
	newPKs := schema.FilterColCollection(oldCols, func(col schema.Column) bool {
		return col.IsPartOfPK
	})
	if !schema.ColCollsAreEqual(existingPKs, newPKs) {
		return errhand.BuildDError("input primary keys do not match primary keys of existing table").Build()
	}

	return nil
}
