package jsonfile

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"reflect"
	"testing"

	"cosmossdk.io/math"
	sdk "github.com/cosmos/cosmos-sdk/types"
	"github.com/stretchr/testify/require"

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

type roundTripperFunc func(*http.Request) (*http.Response, error)

func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
	return f(req)
}

func newTestClient(statusCode int, body []byte) *http.Client {
	return &http.Client{
		Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
			return &http.Response{
				StatusCode:    statusCode,
				Body:          io.NopCloser(bytes.NewReader(body)),
				Header:        make(http.Header),
				ContentLength: int64(len(body)),
				Request:       req,
			}, nil
		}),
	}
}

func TestJSONFile_Field(t *testing.T) {
	type (
		invalidStruct struct {
			name   string
			number int
		}
		evidence struct {
			MaxAgeDuration  string `json:"max_age_duration"`
			MaxAgeNumBlocks string `json:"max_age_num_blocks"`
			MaxBytes        int64  `json:"max_bytes"`
		}
	)

	tests := []struct {
		name     string
		filepath string
		key      string
		want     interface{}
		err      error
	}{
		{
			name:     "get string parameter",
			filepath: "testdata/jsonfile.json",
			key:      "consensus_params.block.max_bytes",
			want:     "22020096",
		},
		{
			name:     "get boolean parameter",
			filepath: "testdata/jsonfile.json",
			key:      "launched",
			want:     true,
		},
		{
			name:     "get array parameter",
			filepath: "testdata/jsonfile.json",
			key:      "consensus_params.block.best_blocks",
			want:     []int{100, 20, 11, 4, 2},
		},
		{
			name:     "get number parameter",
			filepath: "testdata/jsonfile.json",
			key:      "consensus_params.block.time_iota_ms",
			want:     1000,
		},
		{
			name:     "get coins parameter",
			filepath: "testdata/jsonfile.json",
			key:      "app_state.bank.balances.[0].coins",
			want:     sdk.Coins{sdk.NewCoin("stake", math.NewInt(95000000))},
		},
		{
			name:     "get custom parameter",
			filepath: "testdata/jsonfile.json",
			key:      "consensus_params.evidence",
			want: evidence{
				MaxAgeDuration:  "172800000000000",
				MaxAgeNumBlocks: "100000",
				MaxBytes:        1048576,
			},
		},
		{
			name:     "invalid coins parameter",
			filepath: "testdata/jsonfile.json",
			key:      "app_state.bank.balances.[0].coins",
			want:     invalidStruct{name: "invalid", number: 110},
			err:      ErrInvalidValueType,
		},
		{
			name:     "invalid path",
			filepath: "testdata/jsonfile.json",
			key:      "invalid.field.path",
			want:     invalidStruct{name: "invalid", number: 110},
			err:      ErrFieldNotFound,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			f, err := FromPath(tt.filepath)
			require.NoError(t, err)
			t.Cleanup(func() {
				err = f.Close()
				require.NoError(t, err)
			})
			out := reflect.New(reflect.TypeOf(tt.want))
			err = f.Field(tt.key, out.Interface())
			if tt.err != nil {
				require.Error(t, err)
				require.ErrorIs(t, err, tt.err)
				return
			}
			require.NoError(t, err)
			require.Equal(t, tt.want, out.Elem().Interface())
		})
	}
}

func TestJSONFile_Update(t *testing.T) {
	coins := sdk.NewCoin("bar", math.NewInt(500))
	jsonCoins, err := json.Marshal(coins)
	require.NoError(t, err)

	tests := []struct {
		name     string
		filepath string
		opts     []UpdateFileOption
		want     []interface{}
		err      error
	}{
		{
			name:     "update string field",
			filepath: "testdata/jsonfile.json",
			opts: []UpdateFileOption{
				WithKeyValue(
					"consensus_params.block.max_bytes",
					"22020096",
				),
			},
			want: []interface{}{float64(22020096)},
		},
		{
			name:     "update string field to number",
			filepath: "testdata/jsonfile.json",
			opts: []UpdateFileOption{
				WithKeyValueInt(
					"consensus_params.block.max_bytes",
					22020096,
				),
			},
			want: []interface{}{float64(22020096)},
		},
		{
			name:     "update number field",
			filepath: "testdata/jsonfile.json",
			opts: []UpdateFileOption{
				WithKeyValueInt(
					"consensus_params.block.time_iota_ms",
					1000,
				),
			},
			want: []interface{}{float64(1000)},
		},
		{
			name:     "update timestamp field",
			filepath: "testdata/jsonfile.json",
			opts: []UpdateFileOption{
				WithKeyValueTimestamp(
					"genesis_time",
					10000000,
				),
			},
			want: nil, // TODO find a way to test timestamp values
		},
		{
			name:     "update two values type",
			filepath: "testdata/jsonfile.json",
			opts: []UpdateFileOption{
				WithKeyValue(
					"consensus_params.block.max_bytes",
					"3000000",
				),
				WithKeyValueInt(
					"consensus_params.block.time_iota_ms",
					1000,
				),
			},
			want: []interface{}{float64(3000000), float64(1000)},
		},
		{
			name:     "update coin field",
			filepath: "testdata/jsonfile.json",
			opts: []UpdateFileOption{
				WithKeyValueByte(
					"app_state.crisis.params.constant_fee",
					jsonCoins,
				),
			},
			want: []interface{}{map[string]interface{}{
				"denom":  coins.Denom,
				"amount": coins.Amount.String(),
			}},
		},
		{
			name:     "add non-existing field",
			filepath: "testdata/jsonfile.json",
			opts: []UpdateFileOption{
				WithKeyValue(
					"app_state.auth.params.sig_verify_cost_ed25519",
					"111",
				),
			},
			want: []interface{}{float64(111)},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			f, err := FromPath(tt.filepath)
			require.NoError(t, err)

			// Rollback files after change
			b, err := f.Bytes()
			require.NoError(t, err)
			t.Cleanup(func() {
				var prettyJSON bytes.Buffer
				err := json.Indent(&prettyJSON, b, "", "  ")
				require.NoError(t, err)

				err = truncate(f.file, 0)
				require.NoError(t, err)
				err = f.Reset()
				require.NoError(t, err)
				_, err = f.file.Write(prettyJSON.Bytes())
				require.NoError(t, err)
				err = f.Close()
				require.NoError(t, err)
			})

			err = f.Update(tt.opts...)
			if tt.err != nil {
				require.Error(t, err)
				require.ErrorIs(t, err, tt.err)
				return
			}
			require.NoError(t, err)

			updates := make(map[string][]byte)
			for _, opt := range tt.opts {
				opt(updates)
			}
			if tt.want != nil {
				got := make([]interface{}, 0)
				for key := range updates {
					var newValue interface{}
					err := f.Field(key, &newValue)
					require.NoError(t, err)
					got = append(got, newValue)
				}
				require.ElementsMatch(t, tt.want, got)
			}
		})
	}
}

func TestJSONFile_Hash(t *testing.T) {
	tests := []struct {
		name     string
		filepath string
		want     string
		err      error
	}{
		{
			name:     "file hash",
			filepath: "testdata/jsonfile.json",
			want:     "036dbc0020f4ab5604f46a8e5a05c368e4cba41f48fcac2864641902c1dfcad5",
		},
		{
			name:     "not found file",
			filepath: "testdata/genesis_not_found.json",
			err: errors.New(
				"cannot open the file: open testdata/genesis_not_found.json: no such file or directory",
			),
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			f, err := FromPath(tt.filepath)
			if tt.err != nil {
				require.Error(t, err)
				require.Equal(t, err.Error(), tt.err.Error())
				return
			}
			require.NoError(t, err)
			t.Cleanup(func() {
				err = f.Close()
				require.NoError(t, err)
			})
			got, err := f.Hash()
			if tt.err != nil {
				require.Error(t, err)
				require.ErrorIs(t, err, tt.err)
				return
			}
			require.NoError(t, err)
			require.Equal(t, tt.want, got)
		})
	}
}

func TestFromURL(t *testing.T) {
	type args struct {
		url             string
		filepath        string
		tarballFileName string
	}
	tests := []struct {
		name        string
		args        args
		verifyField string
		wantField   string
		err         error
	}{
		{
			name: "JSON URL",
			args: args{
				filepath: "testdata/jsonfile.json",
			},
			verifyField: "chain_id",
			wantField:   "earth-1",
		},
		{
			name: "tarball URL",
			args: args{
				filepath:        "testdata/example.tar.gz",
				tarballFileName: "example.json",
			},
			verifyField: "chain_id",
			wantField:   "gaia-1",
		},
		{
			name: "invalid tarball file name",
			args: args{
				filepath:        "testdata/example.tar.gz",
				tarballFileName: "invalid.json",
			},
			err: tarball.ErrGzipFileNotFound,
		},
		{
			name: "invalid link",
			args: args{
				url: "https://google.com/invalid_example.json",
			},
			err: ErrInvalidURL,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			url := tt.args.url
			if url == "" {
				url = "https://example.com/testdata"
			}

			var body []byte
			if tt.args.filepath != "" {
				var err error
				body, err = os.ReadFile(tt.args.filepath)
				require.NoError(t, err)
			}

			statusCode := http.StatusOK
			if tt.err == ErrInvalidURL {
				statusCode = http.StatusNotFound
			}
			client := newTestClient(statusCode, body)

			filepath := fmt.Sprintf("%s/jsonfile.json", t.TempDir())
			got, err := FromURLWithClient(context.TODO(), url, filepath, tt.args.tarballFileName, client)
			if tt.err != nil {
				require.Error(t, err)
				require.ErrorIs(t, err, tt.err)
				return
			}
			require.NoError(t, err)
			t.Cleanup(func() {
				err = got.Close()
				require.NoError(t, err)
			})
			var verificationField string
			err = got.Field(tt.verifyField, &verificationField)
			require.NoError(t, err)
			require.Equal(t, tt.wantField, verificationField)
		})
	}
}
