package service

import (
	"database/sql"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	merlin "github.com/caraml-dev/merlin/client"
	mlp "github.com/caraml-dev/mlp/api/client"

	"github.com/caraml-dev/turing/api/turing/config"

	"github.com/DATA-DOG/go-sqlmock"
	pg "gorm.io/driver/postgres"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
	"gotest.tools/assert"

	"github.com/caraml-dev/turing/api/turing/models"
)

var expectedAlertContent = strings.Join([]string{
	`groups:`,
	`- name: env_team_service_throughput`,
	`  rules:`,
	`  - alert: service_throughput_violation_env`,
	`    expr: |`,
	`      sum(rate(revision_request_count{`,
	`        environment=\"env\",`,
	`        service_name=~\"service-[0-9]*\"`,
	`      }[1m])) \u003c 50.000000`,
	`    for: 5m`,
	`    labels:`,
	`      owner: team`,
	`      service_name: service`,
	`      severity: warning`,
	`    annotations:`,
	`      dashboard: https://example.com/dashboard?var-environment=test-environment\u0026var-cluster=test-cluster\u0026var-project=test-project\u0026var-router=test-router\u0026var-revision=$__all`, // nolint: lll
	`      description: 'throughput for the past 5m: {{ $value }}rps'`,
	`      playbook: https://example.com`,
	`      summary: 'throughput is lower than the threshold: 50rps'`,
	`  - alert: service_throughput_violation_env`,
	`    expr: |`,
	`      sum(rate(revision_request_count{`,
	`        environment=\"env\",`,
	`        service_name=~\"service-[0-9]*\"`,
	`      }[1m])) \u003c 25.000000`,
	`    for: 5m`,
	`    labels:`,
	`      owner: team`,
	`      service_name: service`,
	`      severity: critical`,
	`    annotations:`,
	`      dashboard: https://example.com/dashboard?var-environment=test-environment\u0026var-cluster=test-cluster\u0026var-project=test-project\u0026var-router=test-router\u0026var-revision=$__all`, // nolint: lll
	`      description: 'throughput for the past 5m: {{ $value }}rps'`,
	`      playbook: https://example.com`,
	`      summary: 'throughput is lower than the threshold: 25rps'\n`,
}, "\\n")

func TestGitlabOpsAlertServiceSave(t *testing.T) {
	mockGitlab, requestRecords := newMockGitlabServer()
	defer mockGitlab.Close()

	mockDb, mockSQLDb, mockSQL := newMockSQL(t)
	defer mockSQLDb.Close()

	mockSQL.ExpectBegin()
	mockSQL.
		ExpectQuery(`INSERT INTO "alerts"`).
		WithArgs(time.Unix(1593647218, 0), time.Unix(1593647219, 0), "env", "team",
			"service", "throughput", float64(50), float64(25), "5m").
		WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
	mockSQL.ExpectCommit()
	mockSQL.
		ExpectQuery(`SELECT (.+) FROM "alerts"`).
		WithArgs(1).
		WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))

	service, err := NewGitlabOpsAlertService(
		mockDb,
		config.AlertConfig{
			Enabled: true,
			GitLab: &config.GitlabConfig{
				BaseURL:    mockGitlab.URL,
				Token:      "token",
				ProjectID:  "project",
				Branch:     "master",
				PathPrefix: "prefix",
			},
			PlaybookURL: "https://example.com",
			DashboardURLTemplate: "https://example.com/dashboard?var-environment={{ .Environment }}" +
				"&var-cluster={{ .Cluster }}&var-project={{ .Project }}&var-router={{ .Router }}&var-revision={{ .Version }}",
		})
	assert.NilError(t, err)
	alert := models.Alert{
		Model: models.Model{
			CreatedAt: time.Unix(1593647218, 0),
			UpdatedAt: time.Unix(1593647219, 0),
		},
		Environment:       "env",
		Team:              "team",
		Service:           "service",
		Metric:            models.MetricThroughput,
		WarningThreshold:  50,
		CriticalThreshold: 25,
		Duration:          "5m",
	}

	_, err = service.Save(
		alert,
		"user@gojek.com",
		"https://example.com/dashboard?var-environment=test-environment&var-cluster=test-cluster"+
			"&var-project=test-project&var-router=test-router&var-revision=$__all",
	)
	assert.NilError(t, err)

	err = mockSQL.ExpectationsWereMet()
	assert.NilError(t, err)

	expectedReq := "POST /api/v4/projects/project/repository/files/prefix/env/team/service/throughput.yaml"
	expectedReqBody := fmt.Sprintf(
		`{"branch":"master","author_email":"user@gojek.com","content":"%s",`+
			`"commit_message":"Autogenerated alert for service: service, team: team, environment: env"}`,
		expectedAlertContent)
	reqBody, ok := requestRecords[expectedReq]
	assert.Check(t, ok == true, "Request: %s is not called. Actual requests: %v", expectedReq, requestRecords)
	assert.Check(t, reqBody == expectedReqBody, "Expected: %s, \nActual: %s", expectedReqBody, reqBody)
}

func TestGitlabOpsAlertServiceSaveShouldRevertGitWhenDbFail(t *testing.T) {
	mockGitlab, requestRecords := newMockGitlabServer()
	defer mockGitlab.Close()

	mockDb, mockSQLDb, mockSQL := newMockSQL(t)
	defer mockSQLDb.Close()

	mockSQL.ExpectBegin()
	mockSQL.
		ExpectQuery(`INSERT INTO "alerts"`).
		WillReturnError(errors.New("test mocked error - insertion error"))

	service, err := NewGitlabOpsAlertService(
		mockDb,
		config.AlertConfig{
			Enabled: true,
			GitLab: &config.GitlabConfig{
				BaseURL:    mockGitlab.URL,
				Token:      "token",
				ProjectID:  "project",
				Branch:     "master",
				PathPrefix: "prefix",
			},
			PlaybookURL: "https://example.com",
		})
	assert.NilError(t, err)

	alert := models.Alert{
		Environment: "env",
		Team:        "team",
		Service:     "service",
		Metric:      models.MetricThroughput,
		Duration:    "5m",
	}

	_, err = service.Save(alert, "user@gojek.com", "dashboardURL")
	assert.ErrorContains(t, err, "insertion error")

	expected := "DELETE /api/v4/projects/project/repository/files/prefix/env/team/service/throughput.yaml"
	_, ok := requestRecords[expected]
	assert.Check(t, ok == true, "Request: %s is not called. Actual requests: %v", expected, requestRecords)
}

func TestGitlabOpsAlertServiceList(t *testing.T) {
	mockDb, mockSQLDb, mockSQL := newMockSQL(t)
	defer mockSQLDb.Close()

	columns := []string{"environment", "team", "service", "metric", "duration"}
	mockSQL.
		ExpectQuery(`SELECT (.+) FROM "alerts"`).
		WithArgs("service").
		WillReturnRows(sqlmock.NewRows(columns).AddRow("env", "team", "service", "throughput", "5m"))

	service, err := NewGitlabOpsAlertService(
		mockDb,
		config.AlertConfig{
			Enabled: true,
			GitLab: &config.GitlabConfig{
				Token:      "token",
				ProjectID:  "project",
				Branch:     "master",
				PathPrefix: "prefix",
			},
			PlaybookURL: "https://example.com",
		})
	assert.NilError(t, err)

	alerts, err := service.List("service")
	assert.NilError(t, err)
	assert.Equal(t, len(alerts), 1)
	assert.DeepEqual(t, alerts[0],
		&models.Alert{
			Environment: "env",
			Team:        "team",
			Service:     "service",
			Metric:      "throughput",
			Duration:    "5m",
		},
	)

	err = mockSQL.ExpectationsWereMet()
	assert.NilError(t, err)
}

func TestGitlabOpsAlertServiceFindByID(t *testing.T) {
	mockDb, mockSQLDb, mockSQL := newMockSQL(t)
	defer mockSQLDb.Close()

	columns := []string{"environment", "team", "service", "metric", "duration"}
	mockSQL.
		ExpectQuery(`SELECT (.+) FROM "alerts"`).
		WithArgs(5).
		WillReturnRows(sqlmock.NewRows(columns).AddRow("env", "team", "service", "throughput", "5m"))

	service, err := NewGitlabOpsAlertService(
		mockDb,
		config.AlertConfig{
			Enabled: true,
			GitLab: &config.GitlabConfig{
				Token:      "token",
				ProjectID:  "project",
				Branch:     "master",
				PathPrefix: "prefix",
			},
			PlaybookURL: "https://example.com",
		})
	assert.NilError(t, err)

	alert, err := service.FindByID(5)
	assert.NilError(t, err)
	assert.DeepEqual(t, alert,
		&models.Alert{
			Environment: "env",
			Team:        "team",
			Service:     "service",
			Metric:      "throughput",
			Duration:    "5m",
		},
	)

	err = mockSQL.ExpectationsWereMet()
	assert.NilError(t, err)
}

func TestGitlabOpsAlertServiceFindByIDShouldReturnErrWhenNotFound(t *testing.T) {
	mockDb, mockSQLDb, mockSQL := newMockSQL(t)
	defer mockSQLDb.Close()

	mockSQL.
		ExpectQuery(`SELECT (.+) FROM "alerts"`).
		WithArgs(1).
		WillReturnError(errors.New("test mocked error - select not found"))

	service, err := NewGitlabOpsAlertService(
		mockDb,
		config.AlertConfig{
			Enabled: true,
			GitLab: &config.GitlabConfig{
				Token:      "token",
				ProjectID:  "project",
				Branch:     "master",
				PathPrefix: "prefix",
			},
			PlaybookURL: "https://example.com",
		})
	assert.NilError(t, err)

	_, err = service.FindByID(1)
	assert.ErrorContains(t, err, "select not found")
}

func TestGitlabOpsAlertServiceUpdate(t *testing.T) {
	mockGitlab, requestRecords := newMockGitlabServer()
	defer mockGitlab.Close()

	mockDb, mockSQLDb, mockSQL := newMockSQL(t)
	defer mockSQLDb.Close()

	mockSQL.
		ExpectQuery(`SELECT (.+) FROM "alerts"`).
		WithArgs(1).
		WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
	mockSQL.ExpectBegin()
	mockSQL.
		ExpectExec(`UPDATE "alerts"`).
		WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(),
			"env", "team", "service", "throughput", float64(50), float64(25), "5m", 1).
		WillReturnResult(sqlmock.NewResult(1, 1))
	mockSQL.ExpectCommit()

	service, err := NewGitlabOpsAlertService(
		mockDb,
		config.AlertConfig{
			Enabled: true,
			GitLab: &config.GitlabConfig{
				BaseURL:    mockGitlab.URL,
				Token:      "token",
				ProjectID:  "project",
				Branch:     "master",
				PathPrefix: "prefix",
			},
			PlaybookURL: "https://example.com",
			DashboardURLTemplate: "https://example.com/dashboard?var-environment={{ .Environment }}" +
				"&var-cluster={{ .Cluster }}&var-project={{ .Project }}&var-router={{ .Router }}&var-revision={{ .Version }}",
		})
	assert.NilError(t, err)

	alert := models.Alert{
		Model:             models.Model{ID: 1},
		Environment:       "env",
		Team:              "team",
		Service:           "service",
		Metric:            models.MetricThroughput,
		WarningThreshold:  50,
		CriticalThreshold: 25,
		Duration:          "5m",
	}

	err = service.Update(
		alert,
		"user@gojek.com",
		"https://example.com/dashboard?var-environment=test-environment&var-cluster=test-cluster"+
			"&var-project=test-project&var-router=test-router&var-revision=$__all")
	assert.NilError(t, err)

	err = mockSQL.ExpectationsWereMet()
	assert.NilError(t, err)

	expectedReq := "PUT /api/v4/projects/project/repository/files/prefix/env/team/service/throughput.yaml"
	expectedReqBody := fmt.Sprintf(
		`{"branch":"master","author_email":"user@gojek.com","content":"%s",`+
			`"commit_message":"Autogenerated alert for service: service, team: team, environment: env"}`,
		expectedAlertContent)
	reqBody, ok := requestRecords[expectedReq]
	assert.Check(t, ok == true, "Request: %s is not called. Actual requests: %v", expectedReq, requestRecords)
	assert.Check(t, reqBody == expectedReqBody, "Expected: %s, \nActual: %s", expectedReqBody, reqBody)
}

func TestGitlabOpsAlertServiceUpdateShouldRevertGitWhenDbFail(t *testing.T) {
	mockGitlab, requestRecords := newMockGitlabServer()
	defer mockGitlab.Close()

	mockDb, mockSQLDb, mockSQL := newMockSQL(t)
	defer mockSQLDb.Close()

	mockSQL.
		ExpectQuery(`SELECT (.+) FROM "alerts"`).
		WithArgs(1).
		WillReturnRows(
			sqlmock.
				NewRows([]string{"id", "environment", "team", "service", "metric"}).
				AddRow(1, "env", "team", "service", models.MetricLatency95p),
		)
	mockSQL.ExpectBegin()
	mockSQL.
		ExpectExec(`UPDATE "alerts"`).
		WillReturnError(errors.New("test mocked error - update error"))

	service, err := NewGitlabOpsAlertService(
		mockDb,
		config.AlertConfig{
			Enabled: true,
			GitLab: &config.GitlabConfig{
				BaseURL:    mockGitlab.URL,
				Token:      "token",
				ProjectID:  "project",
				Branch:     "master",
				PathPrefix: "prefix",
			},
			PlaybookURL: "https://example.com",
			DashboardURLTemplate: "https://example.com/dashboard?var-environment={{ .Environment }}&var-cluster" +
				"={{ .Cluster }}&var-project={{ .Project }}&var-router={{ .Router }}&var-revision={{ .Version }}",
		})
	assert.NilError(t, err)

	alert := models.Alert{
		Model:       models.Model{ID: 1},
		Environment: "env",
		Team:        "team",
		Service:     "service",
		Metric:      models.MetricThroughput,
		Duration:    "5m",
	}

	err = service.Update(alert, "user@gojek.com", "dashboardURL")
	assert.ErrorContains(t, err, "update error")

	expected := "PUT /api/v4/projects/project/repository/files/prefix/env/team/service/throughput.yaml"
	_, ok := requestRecords[expected]
	assert.Check(t, ok == true, "Request: %s is not called. Actual requests: %v", expected, requestRecords)

	expected = "PUT /api/v4/projects/project/repository/files/prefix/env/team/service/latency95p.yaml"
	_, ok = requestRecords[expected]
	assert.Check(t, ok == true, "Request: %s is not called. Actual requests: %v", expected, requestRecords)
}

func TestGitlabOpsAlertSeviceDelete(t *testing.T) {
	mockGitlab, requestRecords := newMockGitlabServer()
	defer mockGitlab.Close()

	mockDb, mockSQLDb, mockSQL := newMockSQL(t)
	defer mockSQLDb.Close()

	mockSQL.
		ExpectQuery(`SELECT (.+) FROM "alerts"`).
		WithArgs(1).
		WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
	mockSQL.ExpectBegin()
	mockSQL.
		ExpectExec(`DELETE FROM "alerts"`).
		WithArgs(1).
		WillReturnResult(sqlmock.NewResult(1, 1))
	mockSQL.ExpectCommit()

	service, err := NewGitlabOpsAlertService(
		mockDb,
		config.AlertConfig{
			Enabled: true,
			GitLab: &config.GitlabConfig{
				BaseURL:    mockGitlab.URL,
				Token:      "token",
				ProjectID:  "project",
				Branch:     "master",
				PathPrefix: "prefix",
			},
			PlaybookURL: "https://example.com",
		})
	assert.NilError(t, err)

	alert := models.Alert{
		Model: models.Model{
			ID: 1,
		},
		Environment: "env",
		Team:        "team",
		Service:     "service",
		Metric:      models.MetricLatency95p,
		Duration:    "5m",
	}

	err = service.Delete(alert, "user@gojek.com", "dashboardURL")
	assert.NilError(t, err)

	err = mockSQL.ExpectationsWereMet()
	assert.NilError(t, err)

	expected := "DELETE /api/v4/projects/project/repository/files/prefix/env/team/service/latency95p.yaml"
	_, ok := requestRecords[expected]
	assert.Check(t, ok == true, "Request: %s is not called. Actual requests: %v", expected, requestRecords)
}

func TestGitlabOpsAlertSeviceDeleteShouldRevertGitWhenDbFail(t *testing.T) {
	mockGitlab, requestRecords := newMockGitlabServer()
	defer mockGitlab.Close()

	mockDb, mockSQLDb, mockSQL := newMockSQL(t)
	defer mockSQLDb.Close()

	mockSQL.
		ExpectQuery(`SELECT (.+) FROM "alerts"`).
		WithArgs(1).
		WillReturnRows(
			sqlmock.
				NewRows([]string{"id", "environment", "team", "service", "metric"}).
				AddRow(1, "env", "team", "service", models.MetricLatency95p),
		)
	mockSQL.ExpectBegin()
	mockSQL.
		ExpectExec(`DELETE FROM "alerts"`).
		WillReturnError(errors.New("test mocked error - delete error"))

	service, err := NewGitlabOpsAlertService(
		mockDb,
		config.AlertConfig{
			Enabled: true,
			GitLab: &config.GitlabConfig{
				BaseURL:    mockGitlab.URL,
				Token:      "token",
				ProjectID:  "project",
				Branch:     "master",
				PathPrefix: "prefix",
			},
			PlaybookURL: "https://example.com",
		})
	assert.NilError(t, err)

	alert := models.Alert{
		Model:       models.Model{ID: 1},
		Environment: "env",
		Team:        "team",
		Service:     "service",
		Metric:      models.MetricLatency95p,
		Duration:    "5m",
	}

	err = service.Delete(alert, "user@gojek.com", "dashboardURL")
	assert.ErrorContains(t, err, "delete error")

	expected := "DELETE /api/v4/projects/project/repository/files/prefix/env/team/service/latency95p.yaml"
	_, ok := requestRecords[expected]
	assert.Check(t, ok == true, "Request: %s is not called. Actual requests: %v", expected, requestRecords)

	expected = "POST /api/v4/projects/project/repository/files/prefix/env/team/service/latency95p.yaml"
	_, ok = requestRecords[expected]
	assert.Check(t, ok == true, "Request: %s is not called. Actual requests: %v", expected, requestRecords)
}

func newMockGitlabServer() (mockGitlab *httptest.Server, requestRecords map[string]string) {
	records := make(map[string]string)
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		record := fmt.Sprintf("%s %s", r.Method, r.URL.Path)
		body, err := io.ReadAll(r.Body)
		if body != nil && err == nil {
			records[record] = string(body)
		} else {
			records[record] = ""
		}

		if strings.HasPrefix(r.URL.Path, "/api/v4/projects/project/repository/files/") && r.Method != http.MethodGet {
			fmt.Fprintf(w, `{"file_path":"file_path.yaml","branch":"master"}`)
		} else {
			fmt.Fprintf(w, "")
		}
	}))
	return server, records
}

func newMockSQL(t *testing.T) (*gorm.DB, *sql.DB, sqlmock.Sqlmock) {
	mockDB, mocksql, err := sqlmock.New()
	if err != nil {
		t.Fatalf("failed to open stub database connection: %s", err)
	}
	gormDB, err := gorm.Open(
		pg.New(pg.Config{Conn: mockDB}),
		&gorm.Config{
			Logger: logger.Default.LogMode(logger.Silent),
		},
	)
	if err != nil {
		t.Fatalf("failed to open go-orm database connection: %s", err)
	}
	return gormDB, mockDB, mocksql
}

func TestGitlabOpsAlertServiceGetDashboardURL(t *testing.T) {
	tests := map[string]struct {
		template      string
		alert         *models.Alert
		project       *mlp.Project
		environment   *merlin.Environment
		router        *models.Router
		routerVersion *models.RouterVersion
		want          string
	}{
		"specific router version": {
			template: "http://dashboard?var-environment={{.Environment}}&var-project={{.Project}}" +
				"&var-cluster={{.Cluster}}&var-router={{.Router}}&var-revision={{.Version}}",
			alert:         &models.Alert{Environment: "environment"},
			project:       &mlp.Project{Name: "project"},
			environment:   &merlin.Environment{Cluster: "cluster"},
			router:        &models.Router{Name: "router"},
			routerVersion: &models.RouterVersion{Version: 1},
			want: "http://dashboard?var-environment=environment&var-project=project" +
				"&var-cluster=cluster&var-router=router&var-revision=1",
		},
		"no router version": {
			template: "http://dashboard?var-environment={{.Environment}}&var-project={{.Project}}" +
				"&var-cluster={{.Cluster}}&var-router={{.Router}}&var-revision={{.Version}}",
			alert:         &models.Alert{Environment: "environment"},
			project:       &mlp.Project{Name: "project"},
			environment:   &merlin.Environment{Cluster: "cluster"},
			router:        &models.Router{Name: "router"},
			routerVersion: nil,
			want: "http://dashboard?var-environment=environment&var-project=project" +
				"&var-cluster=cluster&var-router=router&var-revision=$__all",
		},
		"empty template in alert config": {
			template:    "",
			alert:       &models.Alert{Environment: "environment"},
			project:     &mlp.Project{Name: "project"},
			environment: &merlin.Environment{Cluster: "cluster"},
			router:      &models.Router{Name: "router"},
			want:        "",
		},
	}
	for name, tt := range tests {
		t.Run(name, func(t *testing.T) {
			service, err := NewGitlabOpsAlertService(nil, config.AlertConfig{
				GitLab:               &config.GitlabConfig{},
				DashboardURLTemplate: tt.template,
			})
			assert.NilError(t, err)
			got, err := service.GetDashboardURL(tt.alert, tt.project, tt.environment, tt.router, tt.routerVersion)
			assert.NilError(t, err)
			if got != tt.want {
				t.Errorf("GetDashboardURL() got = %v, want %v", got, tt.want)
			}
		})
	}
}
