package jsonfile

import (
	"bufio"
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/buger/jsonparser"

	"github.com/ignite/cli/v29/ignite/pkg/errors"
	"github.com/ignite/cli/v29/ignite/pkg/safeconverter"
	"github.com/ignite/cli/v29/ignite/pkg/tarball"
)

const (
	keySeparator = "."
)

var (
	// ErrFieldNotFound parameter not found into json.
	ErrFieldNotFound = errors.New("JSON field not found")

	// ErrInvalidValueType invalid value type.
	ErrInvalidValueType = errors.New("invalid value type")

	// ErrInvalidURL invalid file URL.
	ErrInvalidURL = errors.New("invalid file URL")
)

type (
	// JSONFile represents the JSON file and also implements the io.write interface,
	// saving directly to the file.
	JSONFile struct {
		file        ReadWriteSeeker
		tarballPath string
		url         string
		updates     map[string][]byte
		cache       []byte
	}

	// UpdateFileOption configures file update function with key and value.
	UpdateFileOption func(map[string][]byte)
)

type (
	// writeTruncate represents the truncate method from io.WriteSeeker interface.
	writeTruncate interface {
		Truncate(size int64) error
	}

	// ReadWriteSeeker represents the owns ReadWriteSeeker interface inherit from io.ReadWriteSeeker.
	ReadWriteSeeker interface {
		io.ReadWriteSeeker
		Close() error
		Sync() error
	}
)

// New creates a new JSONFile.
func New(file ReadWriteSeeker) *JSONFile {
	return &JSONFile{
		updates: make(map[string][]byte),
		file:    file,
	}
}

// FromPath parses a JSONFile object from path.
func FromPath(path string) (*JSONFile, error) {
	file, err := os.OpenFile(path, os.O_RDWR|os.O_APPEND, 0o600)
	if err != nil {
		return nil, errors.Wrap(err, "cannot open the file")
	}
	return New(file), nil
}

// FromURL fetches the file from the given URL and returns its content.
// If tarballFileName is not empty, the URL is interpreted as a tarball file,
// tarballFileName is extracted from it and is returned instead of the URL
// content.
func FromURL(ctx context.Context, url, destPath, tarballFileName string) (*JSONFile, error) {
	return fromURL(ctx, url, destPath, tarballFileName, http.DefaultClient)
}

// FromURLWithClient fetches the file using the provided HTTP client.
// If client is nil, http.DefaultClient is used.
func FromURLWithClient(ctx context.Context, url, destPath, tarballFileName string, client *http.Client) (*JSONFile, error) {
	if client == nil {
		client = http.DefaultClient
	}
	return fromURL(ctx, url, destPath, tarballFileName, client)
}

func fromURL(ctx context.Context, url, destPath, tarballFileName string, client *http.Client) (*JSONFile, error) {
	// TODO create a cache system to avoid download genesis with the same hash again

	// Download the file from URL
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode == http.StatusNotFound {
		return nil, ErrInvalidURL
	}

	// Remove the old file if exists and create a new one
	if err := os.RemoveAll(destPath); err != nil {
		return nil, err
	}
	file, err := os.OpenFile(destPath, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0o600)
	if err != nil {
		return nil, errors.Wrap(err, "cannot create the file")
	}

	// Copy the downloaded file to buffer and the opened file
	var buf bytes.Buffer
	if _, err := io.Copy(file, io.TeeReader(resp.Body, &buf)); err != nil {
		return nil, err
	}

	// Check if the downloaded file is a tarball and extract only the necessary JSON file
	var ext bytes.Buffer
	tarballPath, err := tarball.ExtractFile(&buf, &ext, tarballFileName)
	if err != nil && !errors.Is(err, tarball.ErrNotGzipType) && !errors.Is(err, tarball.ErrInvalidFileName) {
		return nil, err
	} else if err == nil {
		// Erase the tarball bite code from the file and copy the correct one
		if err := truncate(file, 0); err != nil {
			return nil, err
		}
		if _, err := io.Copy(file, &ext); err != nil {
			return nil, err
		}
	}

	return &JSONFile{
		updates:     make(map[string][]byte),
		file:        file,
		url:         url,
		tarballPath: tarballPath,
	}, nil
}

// Bytes returns the jsonfile byte array.
func (f *JSONFile) Bytes() ([]byte, error) {
	file := f.cache
	if file != nil {
		return file, nil
	}
	if err := f.Reset(); err != nil {
		return nil, err
	}
	scanner := bufio.NewScanner(f.file)
	for scanner.Scan() {
		file = append(file, scanner.Bytes()...)
	}
	if err := scanner.Err(); err != nil {
		return nil, err
	}
	f.cache = file
	return file, nil
}

// Field returns the param by key and the position into byte slice from the file reader.
// Key can be a path to a nested parameters eg: app_state.staking.accounts.
func (f *JSONFile) Field(key string, param interface{}) error {
	file, err := f.Bytes()
	if err != nil {
		return err
	}

	value, dataType, _, err := jsonparser.Get(file, strings.Split(key, keySeparator)...)
	if errors.Is(err, jsonparser.KeyPathNotFoundError) {
		return ErrFieldNotFound
	} else if err != nil {
		return err
	}

	switch dataType {
	case jsonparser.Boolean, jsonparser.Array, jsonparser.Number, jsonparser.Object:
		err := json.Unmarshal(value, param)
		var unmarshalTypeError *json.UnmarshalTypeError
		if errors.As(err, &unmarshalTypeError) {
			return ErrInvalidValueType
		}
	case jsonparser.String:
		result, err := jsonparser.ParseString(value)
		if err != nil {
			return err
		}
		paramStr, ok := param.(*string)
		if ok {
			*paramStr = result
			break
		}
		var (
			unmarshalTypeError *json.UnmarshalTypeError
			syntaxTypeError    *json.SyntaxError
		)
		if err := json.Unmarshal(value, param); errors.As(err, &unmarshalTypeError) ||
			errors.As(err, &syntaxTypeError) {
			return ErrInvalidValueType
		}
	case jsonparser.NotExist:
	case jsonparser.Null:
	case jsonparser.Unknown:
	default:
		return ErrInvalidValueType
	}
	return nil
}

// WithKeyValue updates a file value object by key.
func WithKeyValue(key string, value string) UpdateFileOption {
	return func(update map[string][]byte) {
		update[key] = []byte(`"` + value + `"`)
	}
}

// WithKeyValueByte updates a file byte value object by key.
func WithKeyValueByte(key string, value []byte) UpdateFileOption {
	return func(update map[string][]byte) {
		update[key] = value
	}
}

// WithKeyValueTimestamp updates a time value.
func WithKeyValueTimestamp(key string, t int64) UpdateFileOption {
	return func(update map[string][]byte) {
		formatted := time.Unix(t, 0).UTC().Format(time.RFC3339Nano)
		update[key] = []byte(`"` + formatted + `"`)
	}
}

// WithKeyValueInt updates a file int value object by key.
func WithKeyValueInt(key string, value int64) UpdateFileOption {
	return func(update map[string][]byte) {
		update[key] = []byte(strconv.FormatInt(value, 10))
	}
}

// WithKeyValueUint updates a file uint value object by key.
func WithKeyValueUint(key string, value uint64) UpdateFileOption {
	return WithKeyValueInt(key, safeconverter.ToInt64[uint64](value))
}

// Update updates the file with the new parameters by key.
func (f *JSONFile) Update(opts ...UpdateFileOption) error {
	for _, opt := range opts {
		opt(f.updates)
	}
	if err := f.Reset(); err != nil {
		return err
	}
	_, err := io.Copy(f, f.file)
	return err
}

// Write implement the write method for io.Writer interface.
func (f *JSONFile) Write(p []byte) (int, error) {
	var err error
	length := len(p)
	for key, value := range f.updates {
		p, err = jsonparser.Set(p, value, strings.Split(key, keySeparator)...)
		if err != nil {
			return 0, err
		}
		delete(f.updates, key)
	}
	f.cache = p

	err = truncate(f.file, 0)
	if err != nil {
		return 0, err
	}

	if err := f.Reset(); err != nil {
		return 0, err
	}
	n, err := f.file.Write(p)
	if err != nil {
		return n, err
	}

	if n != len(p) {
		return n, io.ErrShortWrite
	}

	return length, nil
}

// truncate removes the current file content.
func truncate(rws io.WriteSeeker, size int) error {
	t, ok := rws.(writeTruncate)
	if !ok {
		return errors.New("truncate: unable to truncate")
	}

	return t.Truncate(int64(size))
}

// Close the file.
func (f *JSONFile) Close() error {
	return f.file.Close()
}

// URL returns the genesis URL.
func (f *JSONFile) URL() string {
	return f.url
}

// TarballPath returns the tarball path.
func (f *JSONFile) TarballPath() string {
	return f.tarballPath
}

// Hash returns the hash of the file.
func (f *JSONFile) Hash() (string, error) {
	if err := f.Reset(); err != nil {
		return "", err
	}

	h := sha256.New()
	if _, err := io.Copy(h, f.file); err != nil {
		return "", err
	}

	return hex.EncodeToString(h.Sum(nil)), nil
}

// String returns the file string.
func (f *JSONFile) String() (string, error) {
	if err := f.Reset(); err != nil {
		return "", err
	}

	data, err := io.ReadAll(f.file)
	return string(data), err
}

// Reset sets the offset for the next Read or Write to 0.
func (f *JSONFile) Reset() error {
	// TODO find a better way to reset or create a
	// read of copy the writer with io.TeeReader
	_, err := f.file.Seek(0, 0)
	return err
}
