package api

import (
	"bytes"
	"context"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"reflect"
	"strings"
	"testing"
	"time"

	"github.com/go-chi/chi/v5"
	"google.golang.org/protobuf/encoding/protojson"
	"google.golang.org/protobuf/proto"
	"google.golang.org/protobuf/types/known/timestamppb"

	"github.com/smallstep/linkedca"

	"github.com/smallstep/assert"
	"github.com/smallstep/certificates/acme"
	"github.com/smallstep/certificates/authority/admin"
)

func readProtoJSON(r io.ReadCloser, m proto.Message) error {
	defer r.Close()
	data, err := io.ReadAll(r)
	if err != nil {
		return err
	}
	return protojson.Unmarshal(data, m)
}

func mockMustAuthority(t *testing.T, a adminAuthority) {
	t.Helper()
	fn := mustAuthority
	t.Cleanup(func() {
		mustAuthority = fn
	})
	mustAuthority = func(ctx context.Context) adminAuthority {
		return a
	}
}

func TestHandler_requireEABEnabled(t *testing.T) {
	type test struct {
		ctx        context.Context
		next       http.HandlerFunc
		err        *admin.Error
		statusCode int
	}
	var tests = map[string]func(t *testing.T) test{
		"fail/prov.GetDetails": func(t *testing.T) test {
			prov := &linkedca.Provisioner{
				Id:   "provID",
				Name: "provName",
			}
			ctx := linkedca.NewContextWithProvisioner(context.Background(), prov)
			err := admin.NewErrorISE("error getting ACME details for provisioner 'provName'")
			err.Message = "error getting ACME details for provisioner 'provName'"
			return test{
				ctx:        ctx,
				err:        err,
				statusCode: 500,
			}
		},
		"fail/prov.GetDetails.GetACME": func(t *testing.T) test {
			prov := &linkedca.Provisioner{
				Id:      "provID",
				Name:    "provName",
				Details: &linkedca.ProvisionerDetails{},
			}
			ctx := linkedca.NewContextWithProvisioner(context.Background(), prov)
			err := admin.NewErrorISE("error getting ACME details for provisioner 'provName'")
			err.Message = "error getting ACME details for provisioner 'provName'"
			return test{
				ctx:        ctx,
				err:        err,
				statusCode: 500,
			}
		},
		"ok/eab-disabled": func(t *testing.T) test {
			prov := &linkedca.Provisioner{
				Id:   "provID",
				Name: "provName",
				Details: &linkedca.ProvisionerDetails{
					Data: &linkedca.ProvisionerDetails_ACME{
						ACME: &linkedca.ACMEProvisioner{
							RequireEab: false,
						},
					},
				},
			}
			ctx := linkedca.NewContextWithProvisioner(context.Background(), prov)
			err := admin.NewError(admin.ErrorBadRequestType, "ACME EAB not enabled for provisioner provName")
			err.Message = "ACME EAB not enabled for provisioner 'provName'"
			return test{
				ctx:        ctx,
				err:        err,
				statusCode: 400,
			}
		},
		"ok/eab-enabled": func(t *testing.T) test {
			prov := &linkedca.Provisioner{
				Id:   "provID",
				Name: "provName",
				Details: &linkedca.ProvisionerDetails{
					Data: &linkedca.ProvisionerDetails_ACME{
						ACME: &linkedca.ACMEProvisioner{
							RequireEab: true,
						},
					},
				},
			}
			ctx := linkedca.NewContextWithProvisioner(context.Background(), prov)
			return test{
				ctx: ctx,
				next: func(w http.ResponseWriter, r *http.Request) {
					w.Write(nil) // mock response with status 200
				},
				statusCode: 200,
			}
		},
	}

	for name, prep := range tests {
		tc := prep(t)
		t.Run(name, func(t *testing.T) {
			req := httptest.NewRequest("GET", "/foo", http.NoBody).WithContext(tc.ctx)
			w := httptest.NewRecorder()
			requireEABEnabled(tc.next)(w, req)
			res := w.Result()

			assert.Equals(t, tc.statusCode, res.StatusCode)

			body, err := io.ReadAll(res.Body)
			res.Body.Close()
			assert.FatalError(t, err)

			if res.StatusCode >= 400 {
				err := admin.Error{}
				assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &err))

				assert.Equals(t, tc.err.Type, err.Type)
				assert.Equals(t, tc.err.Message, err.Message)
				assert.Equals(t, tc.err.StatusCode(), res.StatusCode)
				assert.Equals(t, tc.err.Detail, err.Detail)
				assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
				return
			}
		})
	}
}

func TestCreateExternalAccountKeyRequest_Validate(t *testing.T) {
	type fields struct {
		Reference string
	}
	tests := []struct {
		name    string
		fields  fields
		wantErr bool
	}{
		{
			name: "fail/reference-too-long",
			fields: fields{
				Reference: strings.Repeat("A", 257),
			},
			wantErr: true,
		},
		{
			name: "ok/empty-reference",
			fields: fields{
				Reference: "",
			},
			wantErr: false,
		},
		{
			name: "ok",
			fields: fields{
				Reference: "my-eab-reference",
			},
			wantErr: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			r := &CreateExternalAccountKeyRequest{
				Reference: tt.fields.Reference,
			}
			if err := r.Validate(); (err != nil) != tt.wantErr {
				t.Errorf("CreateExternalAccountKeyRequest.Validate() error = %v, wantErr %v", err, tt.wantErr)
			}
		})
	}
}

func TestHandler_CreateExternalAccountKey(t *testing.T) {
	type test struct {
		ctx        context.Context
		statusCode int
		err        *admin.Error
	}
	var tests = map[string]func(t *testing.T) test{
		"ok": func(t *testing.T) test {
			chiCtx := chi.NewRouteContext()
			ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx)
			return test{
				ctx:        ctx,
				statusCode: 501,
				err: &admin.Error{
					Type:    admin.ErrorNotImplementedType.String(),
					Status:  http.StatusNotImplemented,
					Message: "this functionality is currently only available in Certificate Manager: https://u.step.sm/cm",
					Detail:  "not implemented",
				},
			}
		},
	}
	for name, prep := range tests {
		tc := prep(t)
		t.Run(name, func(t *testing.T) {

			req := httptest.NewRequest("POST", "/foo", http.NoBody) // chi routing is prepared in test setup
			req = req.WithContext(tc.ctx)
			w := httptest.NewRecorder()
			acmeResponder := NewACMEAdminResponder()
			acmeResponder.CreateExternalAccountKey(w, req)
			res := w.Result()
			assert.Equals(t, tc.statusCode, res.StatusCode)

			body, err := io.ReadAll(res.Body)
			res.Body.Close()
			assert.FatalError(t, err)

			adminErr := admin.Error{}
			assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &adminErr))

			assert.Equals(t, tc.err.Type, adminErr.Type)
			assert.Equals(t, tc.err.Message, adminErr.Message)
			assert.Equals(t, tc.err.StatusCode(), res.StatusCode)
			assert.Equals(t, tc.err.Detail, adminErr.Detail)
			assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])

		})
	}
}

func TestHandler_DeleteExternalAccountKey(t *testing.T) {
	type test struct {
		ctx        context.Context
		statusCode int
		err        *admin.Error
	}
	var tests = map[string]func(t *testing.T) test{
		"ok": func(t *testing.T) test {
			chiCtx := chi.NewRouteContext()
			chiCtx.URLParams.Add("provisionerName", "provName")
			chiCtx.URLParams.Add("id", "keyID")
			ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx)
			return test{
				ctx:        ctx,
				statusCode: 501,
				err: &admin.Error{
					Type:    admin.ErrorNotImplementedType.String(),
					Status:  http.StatusNotImplemented,
					Message: "this functionality is currently only available in Certificate Manager: https://u.step.sm/cm",
					Detail:  "not implemented",
				},
			}
		},
	}
	for name, prep := range tests {
		tc := prep(t)
		t.Run(name, func(t *testing.T) {

			req := httptest.NewRequest("DELETE", "/foo", http.NoBody) // chi routing is prepared in test setup
			req = req.WithContext(tc.ctx)
			w := httptest.NewRecorder()
			acmeResponder := NewACMEAdminResponder()
			acmeResponder.DeleteExternalAccountKey(w, req)
			res := w.Result()
			assert.Equals(t, tc.statusCode, res.StatusCode)

			body, err := io.ReadAll(res.Body)
			res.Body.Close()
			assert.FatalError(t, err)

			adminErr := admin.Error{}
			assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &adminErr))

			assert.Equals(t, tc.err.Type, adminErr.Type)
			assert.Equals(t, tc.err.Message, adminErr.Message)
			assert.Equals(t, tc.err.StatusCode(), res.StatusCode)
			assert.Equals(t, tc.err.Detail, adminErr.Detail)
			assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
		})
	}
}

func TestHandler_GetExternalAccountKeys(t *testing.T) {
	type test struct {
		ctx        context.Context
		statusCode int
		req        *http.Request
		err        *admin.Error
	}
	var tests = map[string]func(t *testing.T) test{
		"ok": func(t *testing.T) test {
			chiCtx := chi.NewRouteContext()
			chiCtx.URLParams.Add("provisionerName", "provName")
			req := httptest.NewRequest("GET", "/foo", http.NoBody)
			ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx)
			return test{
				ctx:        ctx,
				statusCode: 501,
				req:        req,
				err: &admin.Error{
					Type:    admin.ErrorNotImplementedType.String(),
					Status:  http.StatusNotImplemented,
					Message: "this functionality is currently only available in Certificate Manager: https://u.step.sm/cm",
					Detail:  "not implemented",
				},
			}
		},
	}
	for name, prep := range tests {
		tc := prep(t)
		t.Run(name, func(t *testing.T) {

			req := tc.req.WithContext(tc.ctx)
			w := httptest.NewRecorder()
			acmeResponder := NewACMEAdminResponder()
			acmeResponder.GetExternalAccountKeys(w, req)

			res := w.Result()
			assert.Equals(t, tc.statusCode, res.StatusCode)

			body, err := io.ReadAll(res.Body)
			res.Body.Close()
			assert.FatalError(t, err)

			adminErr := admin.Error{}
			assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &adminErr))

			assert.Equals(t, tc.err.Type, adminErr.Type)
			assert.Equals(t, tc.err.Message, adminErr.Message)
			assert.Equals(t, tc.err.StatusCode(), res.StatusCode)
			assert.Equals(t, tc.err.Detail, adminErr.Detail)
			assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
		})
	}
}

func Test_eakToLinked(t *testing.T) {
	tests := []struct {
		name string
		k    *acme.ExternalAccountKey
		want *linkedca.EABKey
	}{
		{
			name: "no-key",
			k:    nil,
			want: nil,
		},
		{
			name: "no-policy",
			k: &acme.ExternalAccountKey{
				ID:            "keyID",
				ProvisionerID: "provID",
				Reference:     "ref",
				AccountID:     "accID",
				HmacKey:       []byte{1, 3, 3, 7},
				CreatedAt:     time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC).Add(-1 * time.Hour),
				BoundAt:       time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC),
				Policy:        nil,
			},
			want: &linkedca.EABKey{
				Id:          "keyID",
				Provisioner: "provID",
				HmacKey:     []byte{1, 3, 3, 7},
				Reference:   "ref",
				Account:     "accID",
				CreatedAt:   timestamppb.New(time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC).Add(-1 * time.Hour)),
				BoundAt:     timestamppb.New(time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC)),
				Policy:      nil,
			},
		},
		{
			name: "with-policy",
			k: &acme.ExternalAccountKey{
				ID:            "keyID",
				ProvisionerID: "provID",
				Reference:     "ref",
				AccountID:     "accID",
				HmacKey:       []byte{1, 3, 3, 7},
				CreatedAt:     time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC).Add(-1 * time.Hour),
				BoundAt:       time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC),
				Policy: &acme.Policy{
					X509: acme.X509Policy{
						Allowed: acme.PolicyNames{
							DNSNames: []string{"*.local"},
							IPRanges: []string{"10.0.0.0/24"},
						},
						Denied: acme.PolicyNames{
							DNSNames: []string{"badhost.local"},
							IPRanges: []string{"10.0.0.30"},
						},
					},
				},
			},
			want: &linkedca.EABKey{
				Id:          "keyID",
				Provisioner: "provID",
				HmacKey:     []byte{1, 3, 3, 7},
				Reference:   "ref",
				Account:     "accID",
				CreatedAt:   timestamppb.New(time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC).Add(-1 * time.Hour)),
				BoundAt:     timestamppb.New(time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC)),
				Policy: &linkedca.Policy{
					X509: &linkedca.X509Policy{
						Allow: &linkedca.X509Names{
							Dns: []string{"*.local"},
							Ips: []string{"10.0.0.0/24"},
						},
						Deny: &linkedca.X509Names{
							Dns: []string{"badhost.local"},
							Ips: []string{"10.0.0.30"},
						},
					},
				},
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := eakToLinked(tt.k); !reflect.DeepEqual(got, tt.want) {
				t.Errorf("eakToLinked() = %v, want %v", got, tt.want)
			}
		})
	}
}

func Test_linkedEAKToCertificates(t *testing.T) {
	tests := []struct {
		name string
		k    *linkedca.EABKey
		want *acme.ExternalAccountKey
	}{
		{
			name: "no-key",
			k:    nil,
			want: nil,
		},
		{
			name: "no-policy",
			k: &linkedca.EABKey{
				Id:          "keyID",
				Provisioner: "provID",
				HmacKey:     []byte{1, 3, 3, 7},
				Reference:   "ref",
				Account:     "accID",
				CreatedAt:   timestamppb.New(time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC).Add(-1 * time.Hour)),
				BoundAt:     timestamppb.New(time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC)),
				Policy:      nil,
			},
			want: &acme.ExternalAccountKey{
				ID:            "keyID",
				ProvisionerID: "provID",
				Reference:     "ref",
				AccountID:     "accID",
				HmacKey:       []byte{1, 3, 3, 7},
				CreatedAt:     time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC).Add(-1 * time.Hour),
				BoundAt:       time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC),
				Policy:        nil,
			},
		},
		{
			name: "no-x509-policy",
			k: &linkedca.EABKey{
				Id:          "keyID",
				Provisioner: "provID",
				HmacKey:     []byte{1, 3, 3, 7},
				Reference:   "ref",
				Account:     "accID",
				CreatedAt:   timestamppb.New(time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC).Add(-1 * time.Hour)),
				BoundAt:     timestamppb.New(time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC)),
				Policy:      &linkedca.Policy{},
			},
			want: &acme.ExternalAccountKey{
				ID:            "keyID",
				ProvisionerID: "provID",
				Reference:     "ref",
				AccountID:     "accID",
				HmacKey:       []byte{1, 3, 3, 7},
				CreatedAt:     time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC).Add(-1 * time.Hour),
				BoundAt:       time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC),
				Policy:        &acme.Policy{},
			},
		},
		{
			name: "with-x509-policy",
			k: &linkedca.EABKey{
				Id:          "keyID",
				Provisioner: "provID",
				HmacKey:     []byte{1, 3, 3, 7},
				Reference:   "ref",
				Account:     "accID",
				CreatedAt:   timestamppb.New(time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC).Add(-1 * time.Hour)),
				BoundAt:     timestamppb.New(time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC)),
				Policy: &linkedca.Policy{
					X509: &linkedca.X509Policy{
						Allow: &linkedca.X509Names{
							Dns: []string{"*.local"},
							Ips: []string{"10.0.0.0/24"},
						},
						Deny: &linkedca.X509Names{
							Dns: []string{"badhost.local"},
							Ips: []string{"10.0.0.30"},
						},
						AllowWildcardNames: true,
					},
				},
			},
			want: &acme.ExternalAccountKey{
				ID:            "keyID",
				ProvisionerID: "provID",
				Reference:     "ref",
				AccountID:     "accID",
				HmacKey:       []byte{1, 3, 3, 7},
				CreatedAt:     time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC).Add(-1 * time.Hour),
				BoundAt:       time.Date(2022, 04, 12, 9, 30, 30, 0, time.UTC),
				Policy: &acme.Policy{
					X509: acme.X509Policy{
						Allowed: acme.PolicyNames{
							DNSNames: []string{"*.local"},
							IPRanges: []string{"10.0.0.0/24"},
						},
						Denied: acme.PolicyNames{
							DNSNames: []string{"badhost.local"},
							IPRanges: []string{"10.0.0.30"},
						},
						AllowWildcardNames: true,
					},
				},
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := linkedEAKToCertificates(tt.k); !reflect.DeepEqual(got, tt.want) {
				t.Errorf("linkedEAKToCertificates() = %v, want %v", got, tt.want)
			}
		})
	}
}
