//go:generate generate_permissions permissions.yaml permissions.go posthog

package posthog

import (
	"bytes"
	_ "embed"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"

	"github.com/fatih/color"
	"github.com/jedib0t/go-pretty/v6/table"
	"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
	"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
	"github.com/trufflesecurity/trufflehog/v3/pkg/context"
)

var _ analyzers.Analyzer = (*Analyzer)(nil)

const (
	USDomain = "https://us.posthog.com"
	EUDomain = "https://eu.posthog.com"
)

type Analyzer struct {
	Cfg *config.Config
}

func (Analyzer) Type() analyzers.AnalyzerType { return analyzers.AnalyzerTypePosthog }

func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
	key, ok := credInfo["key"]
	if !ok {
		return nil, analyzers.NewAnalysisError(a.Type().String(), analyzers.OperationValidateCredentials, analyzers.ServiceConfig, "", errors.New("missing key in credInfo"),
		)
	}
	info, err := AnalyzePermissions(a.Cfg, key)
	if err != nil {
		return nil, analyzers.NewAnalysisError(a.Type().String(), analyzers.OperationAnalyzePermissions, analyzers.ServiceAPI, "", err,
		)
	}
	return secretInfoToAnalyzerResult(info), nil
}

func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult {
	if info == nil {
		return nil
	}
	result := analyzers.AnalyzerResult{
		AnalyzerType: analyzers.AnalyzerTypePosthog,
		Metadata:     nil,
		Bindings:     make([]analyzers.Binding, 0),
	}

	if info.orgPermissions == nil {
		// no permissions to check
		return &result
	}

	if info.user != nil {
		// for user resource
		userResource := analyzers.Resource{
			Name:               info.user.FirstName + " " + info.user.LastName,
			FullyQualifiedName: info.user.UUID,
			Type:               "user",
		}
		analyzerPermission := analyzers.Permission{
			Value: PermissionStrings[UserRead],
		}
		result.Bindings = append(result.Bindings, analyzers.Binding{
			Resource:   userResource,
			Permission: analyzerPermission,
		})
	}

	// for organization permissions, we need to bind the permissions to the organization resource
	organizationResource := analyzers.Resource{
		Name:               info.organization.Name,
		FullyQualifiedName: info.organization.ID,
		Type:               "organization",
	}
	for _, permission := range info.orgPermissions {
		if value, ok := PermissionStrings[permission]; ok {
			analyzerPermission := analyzers.Permission{
				Value: value,
			}
			result.Bindings = append(result.Bindings, analyzers.Binding{
				Resource:   organizationResource,
				Permission: analyzerPermission,
			})
		}
	}

	// for project permissions, we need to bind the permissions to the project resource and organization as the parent resource
	for _, projectPermission := range info.projectPermissions {
		projectResource := analyzers.Resource{
			Name:               projectPermission.Project.Name,
			FullyQualifiedName: strconv.FormatInt(projectPermission.Project.ID, 10),
			Type:               "project",
			Parent:             &organizationResource,
		}
		for _, permission := range projectPermission.Permissions {
			permissionStr, _ := permission.ToString()
			analyzerPermission := analyzers.Permission{
				Value: permissionStr,
			}
			result.Bindings = append(result.Bindings, analyzers.Binding{
				Resource:   projectResource,
				Permission: analyzerPermission,
			})
		}
	}

	return &result
}

//go:embed scopes.json
var scopesConfigBytes []byte

type HttpStatusTest struct {
	Endpoint        string      `json:"endpoint"`
	Method          string      `json:"method"`
	Payload         interface{} `json:"payload"`
	ValidStatuses   []int       `json:"valid_status_code"`
	InvalidStatuses []int       `json:"invalid_status_code"`
}

func StatusContains(status int, vals []int) bool {
	for _, v := range vals {
		if status == v {
			return true
		}
	}
	return false
}

func (h *HttpStatusTest) RunTest(cfg *config.Config, client *http.Client, domain string, headers map[string]string, args ...any) (bool, error) {
	// If body data, marshal to JSON
	var data io.Reader
	if h.Payload != nil {
		jsonData, err := json.Marshal(h.Payload)
		if err != nil {
			return false, err
		}
		data = bytes.NewBuffer(jsonData)
	}

	req, err := http.NewRequest(h.Method, fmt.Sprintf(domain+h.Endpoint, args...), data)
	if err != nil {
		return false, err
	}

	// Add custom headers if provided
	for key, value := range headers {
		req.Header.Set(key, value)
	}

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

	// Check response status code
	switch {
	case StatusContains(resp.StatusCode, h.ValidStatuses):
		return true, nil
	case StatusContains(resp.StatusCode, h.InvalidStatuses):
		return false, nil
	default:
		fmt.Println(h.Method, h.Endpoint)
		return false, errors.New("error checking response status code")
	}
}

type ScopesConfig struct {
	GeneralScopes      []Scope `json:"general_scopes"`
	OrganizationScopes []Scope `json:"organization_scopes"`
	ProjectScopes      []Scope `json:"project_scopes"`
}

type Scope struct {
	Name string    `json:"name"`
	Test ScopeTest `json:"test"`
}

type ScopeTest struct {
	Read  *HttpStatusTest `json:"read"`
	Write *HttpStatusTest `json:"write"`
}

func readInScopesConfig() (*ScopesConfig, error) {
	var scopesConfig ScopesConfig
	if err := json.Unmarshal(scopesConfigBytes, &scopesConfig); err != nil {
		return nil, err
	}

	return &scopesConfig, nil
}

func checkPermissions(cfg *config.Config, client *http.Client, domain string, key string, scopes []Scope, args ...any) ([]Permission, error) {

	permissions := make([]Permission, 0)
	headers := map[string]string{"Authorization": "Bearer " + key}
	for _, scope := range scopes {
		var status bool
		var err error
		if scope.Test.Write != nil {
			status, err = scope.Test.Write.RunTest(cfg, client, domain, headers, args...)
			if err != nil {
				return nil, fmt.Errorf("running test: %w", err)
			}
		}
		if status {
			if permission, ok := StringToPermission[scope.Name+":write"]; ok {
				permissions = append(permissions, permission)
			}
			// if write exists, read also exists
			if permission, ok := StringToPermission[scope.Name+":read"]; ok {
				permissions = append(permissions, permission)
			}
		} else {
			status, err = scope.Test.Read.RunTest(cfg, client, domain, headers, args...)
			if err != nil {
				return nil, fmt.Errorf("running test: %w", err)
			}
			if status {
				if permission, ok := StringToPermission[scope.Name+":read"]; ok {
					permissions = append(permissions, permission)
				}
			}
		}

	}

	return permissions, nil
}

type ProjectPermissions struct {
	Project     *Project
	Permissions []Permission
}

type SecretInfo struct {
	user               *User
	organization       *Organization
	orgPermissions     []Permission
	projectPermissions []ProjectPermissions
	// generalPermissions      []Permission
	unverifiedPermissions map[Permission]struct{}
}

func AnalyzeAndPrintPermissions(cfg *config.Config, key string) {
	info, err := AnalyzePermissions(cfg, key)
	if err != nil {
		color.Red("[x] Error : %s", err.Error())
		return
	}

	color.Green("[!] Valid Posthog API key")
	color.Yellow("[i] Expires: Never")
	if info.user != nil {
		printUser(*info.user)
	}

	if info.organization == nil {
		color.Yellow("\n[i] No permissions were verified for this key because the key does not have one of the necessary permissions (user:read or organization:read) required to verify other permissions.")
	}

	if info.orgPermissions != nil {
		printOrganizationPermissions(*info.organization, info.orgPermissions)
	}
	if len(info.projectPermissions) > 0 {
		printProjectPermissions(info.projectPermissions)
	}
	printUnverifiedPermissions(info.unverifiedPermissions)

}

func AnalyzePermissions(cfg *config.Config, key string) (*SecretInfo, error) {
	var info = &SecretInfo{}

	// These are permissions that cannot be verified due to no endpoint available
	info.unverifiedPermissions = map[Permission]struct{}{
		ErrorTrackingRead:         {},
		ErrorTrackingWrite:        {},
		SharingConfigurationRead:  {},
		SharingConfigurationWrite: {},
		WebhookRead:               {},
		WebhookWrite:              {},
	}

	client := analyzers.NewAnalyzeClient(cfg)

	// we need to determine if the key is for US or EU domain
	domain, user, err := resolveDomainAndUser(cfg, client, key)
	if err != nil {
		return nil, fmt.Errorf("Invalid API Key: %w", err)
	}

	info.user = user

	// Most posthog API scopes are bound to projects and organization, so to determine the scopes we need to first get the organization and projects.
	// If the key has user:read scope, we will get the user above which contains the organizations and projects.
	// If the key does not have user:read scope, we can call the /organizations/@current endpoint to get the
	// organization and projects. If the key does not have organization:read scope as well, we cannot determine any scope.
	var org *Organization
	if user == nil {
		org, err = getOrganization(cfg, client, domain, key)
		if err != nil {
			return nil, err
		}
		if org == nil {
			// can't determine any scopes
			for permission := range PermissionStrings {
				info.unverifiedPermissions[permission] = struct{}{}
			}
			return info, nil
		}
	} else {
		org = &user.Organization
	}

	// set the organization in the info struct
	info.organization = org

	// read in scopes
	scopesConfig, err := readInScopesConfig()
	if err != nil {
		return nil, err
	}

	// check organization permissions
	organizationPermissions, err := checkOrganizationPermissions(cfg, client, domain, key, scopesConfig, org)
	if err != nil {
		return nil, err
	}

	// check general permissions
	generalOrganizationPermissions, err := checkGeneralPermissions(cfg, client, domain, key, scopesConfig)
	if err != nil {
		return nil, err
	}

	// merge general permissions with organization permissions
	info.orgPermissions = organizationPermissions
	info.orgPermissions = append(info.orgPermissions, generalOrganizationPermissions...)

	// check project permissions
	projectPermissions, err := checkProjectPermissions(cfg, client, domain, key, scopesConfig, org)
	if err != nil {
		return nil, err
	}
	info.projectPermissions = projectPermissions

	return info, nil
}

func checkGeneralPermissions(cfg *config.Config, client *http.Client, domain, key string, scopesConfig *ScopesConfig) ([]Permission, error) {
	return checkPermissions(cfg, client, domain, key, scopesConfig.GeneralScopes)
}

func checkOrganizationPermissions(
	cfg *config.Config,
	client *http.Client,
	domain,
	key string,
	scopesConfig *ScopesConfig,
	org *Organization,
) ([]Permission, error) {
	return checkPermissions(cfg, client, domain, key, scopesConfig.OrganizationScopes, org.ID)
}

func checkProjectPermissions(
	cfg *config.Config,
	client *http.Client,
	domain,
	key string,
	scopesConfig *ScopesConfig,
	org *Organization,
) ([]ProjectPermissions, error) {
	projectPermissions := make([]ProjectPermissions, 0)
	for _, project := range org.Projects {
		projectPermission := ProjectPermissions{
			Project: &project,
		}
		permissions, err := checkPermissions(cfg, client, domain, key, scopesConfig.ProjectScopes, project.ID)
		if err != nil {
			return nil, err
		}
		projectPermission.Permissions = permissions
		projectPermissions = append(projectPermissions, projectPermission)
	}
	return projectPermissions, nil
}

type User struct {
	UUID         string       `json:"uuid"`
	FirstName    string       `json:"first_name"`
	LastName     string       `json:"last_name"`
	Email        string       `json:"email"`
	Organization Organization `json:"organization"`
}

type Organization struct {
	ID       string    `json:"id"`
	Name     string    `json:"name"`
	Projects []Project `json:"projects"`
}

type Project struct {
	ID   int64  `json:"id"`
	Name string `json:"name"`
}

// resolves the domain and user (if permission exists) by calling the /users/@me method for both US and EU domains
// if the response is 200 OK, it means the domain is valid and user:read permission is also there
// if the response is 403 Forbidden, it means the domain is valid but user:read permission is not there
// if the response is 401 Unauthorized, it means the domain is invalid
func resolveDomainAndUser(cfg *config.Config, client *http.Client, key string) (string, *User, error) {

	domains := []string{USDomain, EUDomain}
	for _, domain := range domains {
		req, err := http.NewRequest(http.MethodGet, domain+"/api/users/@me/", nil)
		if err != nil {
			return "", nil, err
		}
		req.Header.Set("Authorization", "Bearer "+key)

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

		switch resp.StatusCode {
		case http.StatusOK:
			// domain is valid and user permission also exists
			var userInfo User
			if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
				return "", nil, err
			}
			return domain, &userInfo, nil
		case http.StatusForbidden:
			// domain is valid but user permission does not exist
			return domain, nil, nil
		case http.StatusUnauthorized:
			// Key might not be valid of this domain
			// Try the other domain
			continue
		default:
			// unexpected status code
			return "", nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
		}
	}
	return "", nil, fmt.Errorf("invalid Posthog API key")
}

func getOrganization(cfg *config.Config, client *http.Client, domain string, key string) (*Organization, error) {
	req, err := http.NewRequest(http.MethodGet, domain+"/api/organizations/@current/", nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+key)

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

	switch resp.StatusCode {
	case http.StatusOK:
		var org Organization
		if err := json.NewDecoder(resp.Body).Decode(&org); err != nil {
			return nil, err
		}
		return &org, nil
	case http.StatusForbidden:
		return nil, nil
	default:
		return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
	}
}

func printUser(user User) {
	color.Yellow("\n[i] User Info:")
	color.Green("[i] Name: %s %s", user.FirstName, user.LastName)
	color.Green("[i] Email: %s", user.Email)
	color.Green("[i] ID: %s", user.UUID)
}

func printOrganizationPermissions(organization Organization, permissions []Permission) {
	color.Yellow("\n[i] Organization Permissions:")
	t := table.NewWriter()
	t.SetOutputMirror(os.Stdout)
	t.AppendHeader(table.Row{"Organization", "Permission"})
	permissionsString := make([]string, len(permissions))
	for i, permission := range permissions {
		permissionsString[i], _ = permission.ToString()
	}
	t.AppendRow(table.Row{
		color.GreenString(organization.Name),
		color.GreenString(strings.Join(permissionsString, "\n")),
	})
	t.Render()
}

func printProjectPermissions(projectPermissions []ProjectPermissions) {
	color.Yellow("\n[i] Project Permissions:")
	t := table.NewWriter()
	t.SetOutputMirror(os.Stdout)
	t.AppendHeader(table.Row{"Project", "Permission"})
	for _, projectPermission := range projectPermissions {
		permissionsString := make([]string, len(projectPermission.Permissions))
		for i, permission := range projectPermission.Permissions {
			permissionsString[i], _ = permission.ToString()

		}
		t.AppendRow(table.Row{
			color.GreenString(projectPermission.Project.Name),
			color.GreenString(strings.Join(permissionsString, "\n")),
		})
	}
	t.Render()
}

func printUnverifiedPermissions(permissions map[Permission]struct{}) {
	color.Yellow("\n[i] Unverified Permissions:")
	t := table.NewWriter()
	t.SetOutputMirror(os.Stdout)
	t.AppendHeader(table.Row{"Permission"})
	for permission := range permissions {
		permissionStr, _ := permission.ToString()
		t.AppendRow(table.Row{color.YellowString(permissionStr)})
	}
	t.Render()
}
