package ctl

import (
	"bufio"
	"context"
	"encoding/csv"
	"encoding/json"
	"fmt"
	"hash/fnv"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"sync"

	"github.com/featurebasedb/featurebase/v3/logger"
	"github.com/pkg/errors"
	"golang.org/x/sync/errgroup"
)

type PreSortCommand struct {
	// Optional Index filter
	File string

	Type string

	Table string

	PrimaryKeyFields    []string
	PrimaryKeySeparator string

	PartitionN int

	JobSize    int
	NumWorkers int

	// Path to write sorted files to
	OutputDir string

	// Standard input/output
	logDest logger.Logger

	outputLocks    []sync.Mutex
	outputFiles    map[int]*os.File
	outputEncoders map[int]*json.Encoder
	outputWriters  map[int]*csv.Writer
}

// NewPreSortCommand returns a new instance of PreSortCommand.
func NewPreSortCommand(logdest logger.Logger) *PreSortCommand {
	return &PreSortCommand{
		logDest:             logdest,
		Type:                "ndjson",
		OutputDir:           "presorted_files",
		PartitionN:          256,
		JobSize:             1000,
		NumWorkers:          4,
		PrimaryKeySeparator: "|",
		outputFiles:         make(map[int]*os.File),
	}
}

// Run executes the main program execution.
func (cmd *PreSortCommand) Run(ctx context.Context) error {
	if cmd.File == "" {
		return errors.New("must set a file or directory to read from")
	}
	if len(cmd.PrimaryKeyFields) == 0 {
		return errors.New("must define primary-key-fields")
	}
	if cmd.Table == "" {
		return errors.New("must set a table name")
	}
	cmd.outputLocks = make([]sync.Mutex, cmd.PartitionN)
	filelist := []string{}
	walkfunc := func(path string, d fs.DirEntry, err error) error {
		if !d.IsDir() {
			filelist = append(filelist, path)
		}
		return nil
	}
	err := filepath.WalkDir(cmd.File, walkfunc)
	if err != nil {
		return errors.Wrapf(err, "walking %s", cmd.File)
	}
	if len(filelist) > 3 {
		cmd.logDest.Printf("%d files: %v...", len(filelist), filelist[:3])
	} else {
		cmd.logDest.Printf("Files: %v", filelist)
	}

	if err := os.MkdirAll(cmd.OutputDir, 0755); err != nil {
		return errors.Wrapf(err, "creating output directory: '%s'", cmd.OutputDir)
	}

	for i := 0; i < cmd.PartitionN; i++ {
		name := filepath.Join(cmd.OutputDir, fmt.Sprintf("output_%d", i))
		f, err := os.Create(name)
		if err != nil {
			return errors.Wrapf(err, "couldn't open output file %s", name)
		}
		cmd.outputFiles[i] = f
	}
	defer func() {
		for _, f := range cmd.outputFiles {
			_ = f.Sync()
			_ = f.Close()
		}
	}()

	linech := make(chan [][]byte, 16)
	var eg *errgroup.Group
	switch cmd.Type {
	case "ndjson":
		eg = cmd.setupNDJSON(linech)
	case "csv":
		// CSV ends here
		return errors.Wrap(cmd.RunCSV(filelist), "running CSV command")
	default:
		return errors.Errorf("unsupported type %s, try ndjson or csv", cmd.Type)
	}

	// The rest of this is just for NDJSON... CSV doesn't currently
	// support multiple workers. It needs to do the file reading
	// differently in order to handle potential newlines within
	// fields.
	for _, fname := range filelist {
		f, err := os.Open(fname)
		if err != nil {
			return errors.Wrapf(err, "opening %s", fname)
		}

		sc := bufio.NewScanner(f)
	batch:
		for {
			lines := make([][]byte, 0, cmd.JobSize)
			for i := 0; i < cmd.JobSize; i++ {
				if !sc.Scan() {
					linech <- lines
					break batch
				}
				line := sc.Bytes()
				// must copy line as scanner will reuse the buffer it's given us
				lc := make([]byte, len(line))
				copy(lc, line)
				lines = append(lines, lc)
			}
			linech <- lines
		}
		if err := sc.Err(); err != nil {
			return errors.Wrap(err, "scanning file")
		}
	}

	close(linech)
	if err := eg.Wait(); err != nil {
		return err
	}

	return nil
}

func (cmd *PreSortCommand) setupNDJSON(linech chan [][]byte) *errgroup.Group {
	cmd.outputEncoders = make(map[int]*json.Encoder)
	for i, f := range cmd.outputFiles {
		cmd.outputEncoders[i] = json.NewEncoder(f)
		cmd.outputEncoders[i].SetIndent("", "")
		cmd.outputEncoders[i].SetEscapeHTML(false)
	}

	// start workers
	eg := &errgroup.Group{}
	for i := 0; i < cmd.NumWorkers; i++ {
		eg.Go(func() error {
			return cmd.ndjsonWorker(linech)
		})
	}
	return eg
}

func (cmd *PreSortCommand) ndjsonWorker(linech chan [][]byte) error {
	for lines := range linech {
		rec := map[string]interface{}{}
		for _, line := range lines {
			err := json.Unmarshal(line, &rec)
			if err != nil {
				return errors.Wrapf(err, "unmarshaling '%s'", line)
			}
			partition, err := cmd.ndjsonPartition(rec)
			if err != nil {
				return err
			}
			if err := cmd.outputNDJSON(partition, rec); err != nil {
				return errors.Wrap(err, "outputting ndjson")
			}
			for k := range rec {
				delete(rec, k)
			}
		}
	}
	return nil
}

func (cmd *PreSortCommand) outputNDJSON(partition int, record map[string]interface{}) error {
	cmd.outputLocks[partition].Lock()
	defer cmd.outputLocks[partition].Unlock()

	return cmd.outputEncoders[partition].Encode(record)
}

func (cmd *PreSortCommand) ndjsonPartition(rec map[string]interface{}) (int, error) {
	h := fnv.New64a()
	_, _ = h.Write([]byte(cmd.Table))

	for i, name := range cmd.PrimaryKeyFields {
		val, ok := rec[name]
		if !ok {
			return 0, errors.Errorf("couldn't find primary key part '%s' in record: %+v", name, rec)
		}
		h.Write([]byte(toString(val)))
		if i < len(cmd.PrimaryKeyFields)-1 {
			h.Write([]byte(cmd.PrimaryKeySeparator))
		}
	}
	return int(h.Sum64() % uint64(cmd.PartitionN)), nil
}

func toString(val interface{}) string {
	switch valt := val.(type) {
	case string:
		return valt
	case float64, int, int64, uint64, float32, uint:
		return fmt.Sprintf("%d", valt)
	default:
		return fmt.Sprintf("%v", valt)
	}
}

func (cmd *PreSortCommand) RunCSV(filelist []string) error {
	outputWriters := make(map[int]*csv.Writer)
	for i, f := range cmd.outputFiles {
		outputWriters[i] = csv.NewWriter(f)
	}

	for _, fname := range filelist {
		f, err := os.Open(fname)
		if err != nil {
			cmd.logDest.Warnf("Could not open %s, skipping", fname)
		}
		reader := csv.NewReader(f)
		reader.ReuseRecord = true
		rec, err := reader.Read()
		if err != nil {
			return errors.Wrap(err, "")
		}
		header := make(map[string]int)
		for i, key := range rec {
			header[key] = i
		}
		for rec, err = reader.Read(); err == nil; rec, err = reader.Read() {
			partition, err := cmd.csvPartition(header, rec)
			if err != nil {
				return err
			}
			err = outputWriters[partition].Write(rec)
			if err != nil {
				return errors.Wrapf(err, "writing record to partition %d", partition)
			}
		}
		if err != io.EOF && err != nil {
			return errors.Wrapf(err, "error attempting to decode json from %s", fname)
		}
	}

	for _, w := range outputWriters {
		w.Flush()
	}
	return nil
}

func (cmd *PreSortCommand) csvPartition(header map[string]int, rec []string) (int, error) {
	h := fnv.New64a()
	_, _ = h.Write([]byte(cmd.Table))

	for i, name := range cmd.PrimaryKeyFields {
		pos, ok := header[name]
		if !ok {
			return 0, errors.Errorf("couldn't find primary key part '%s' in header: %+v", name, header)
		}
		h.Write([]byte(rec[pos]))
		if i < len(cmd.PrimaryKeyFields)-1 {
			h.Write([]byte(cmd.PrimaryKeySeparator))
		}
	}
	return int(h.Sum64() % uint64(cmd.PartitionN)), nil
}
