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

package huggingface

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"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"
)

const (
	FINEGRAINED = "fineGrained"
	WRITE       = "write"
	READ        = "read"
)

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

type Analyzer struct {
	Cfg *config.Config
}

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

func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
	key, ok := credInfo["key"]
	if !ok || key == "" {
		return nil, analyzers.NewAnalysisError(a.Type().String(), analyzers.OperationValidateCredentials, analyzers.ServiceConfig, "", fmt.Errorf("key not found in credentialInfo"))
	}

	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 bakeUnboundedResources(tokenJSON HFTokenJSON) []analyzers.Resource {
	unboundedResources := make([]analyzers.Resource, len(tokenJSON.Orgs))
	for idx, org := range tokenJSON.Orgs {
		unboundedResources[idx] = analyzers.Resource{
			Name:               org.Name,
			FullyQualifiedName: "huggingface.com/user/" + tokenJSON.Username + "/organization/" + org.Name,
			Type:               "organization",
			Metadata: map[string]interface{}{
				"role":          org.Role,
				"is_enterprise": org.IsEnterprise,
			},
		}
	}
	return unboundedResources
}

func bakeUnfineGrainedBindings(allModels []Model, tokenJSON HFTokenJSON) []analyzers.Binding {
	bindings := make([]analyzers.Binding, len(allModels))

	for idx, model := range allModels {
		// Add Read Privs to All Models
		modelResource := analyzers.Resource{
			Name:               model.Name,
			FullyQualifiedName: "huggingface.com/model/" + model.ID,
			Type:               "model",
			Metadata: map[string]interface{}{
				"private": model.Private,
			},
		}

		// means both read & write permission for the model
		accessLevel := string(analyzers.READ)
		if tokenJSON.Auth.AccessToken.Type == WRITE {
			accessLevel = string(analyzers.WRITE)
		}
		bindings[idx] = analyzers.Binding{
			Resource: modelResource,
			Permission: analyzers.Permission{
				Value: string(accessLevel),
			},
		}
	}
	return bindings
}

// finegrained scopes are grouped by org, user or model.
func bakefineGrainedBindings(allModels []Model, tokenJSON HFTokenJSON) []analyzers.Binding {
	// this section will extract the relevant permissions for each entity and store them in a map
	var nameToPermissions = make(map[string]analyzers.Permission)
	for _, permission := range tokenJSON.Auth.AccessToken.FineGrained.Scoped {
		privs := analyzers.Permission{
			Value: string(analyzers.NONE),
		}
		for _, perm := range permission.Permissions {
			switch perm {
			case "repo.content.read":
				privs.Value = string(analyzers.READ)
			case "repo.write":
				privs.Value = string(analyzers.WRITE)
			}
		}
		switch permission.Entity.Type {
		case "user", "org":
			nameToPermissions[permission.Entity.Name] = privs
		case "model":
			nameToPermissions[modelNameLookup(allModels, permission.Entity.ID)] = privs
		}
	}

	bindings := make([]analyzers.Binding, len(allModels))
	for idx, model := range allModels {

		// Add Read Privs to All Models
		modelResource := analyzers.Resource{
			Name:               model.Name,
			FullyQualifiedName: "huggingface.com/model/" + model.ID,
			Type:               "model",
			Metadata: map[string]interface{}{
				"private": model.Private,
			},
		}

		var perm analyzers.Permission
		// get username/orgname for each model and apply those permissions
		modelUsername := strings.Split(model.Name, "/")[0]
		if permissions, ok := nameToPermissions[modelUsername]; ok {
			perm = permissions
		}
		// override model permissions with repo-specific permissions
		if permissions, ok := nameToPermissions[model.Name]; ok {
			perm = permissions
		}

		bindings[idx] = analyzers.Binding{
			Resource:   modelResource,
			Permission: perm,
		}
	}
	return bindings
}

func bakeOrganizationBindings(tokenJSON HFTokenJSON) []analyzers.Binding {
	// check if there are any org permissions
	// if so, save them as a map. Only need to do this once
	// even if multiple orgs b/c as of 6/6/24, users can only define one set of scopes
	// for all orgs referenced on an access token
	orgPermissions := map[string]struct{}{}
	var orgResource *analyzers.Resource = nil
	for _, permission := range tokenJSON.Auth.AccessToken.FineGrained.Scoped {
		if permission.Entity.Type == "org" {
			orgResource = &analyzers.Resource{
				Name:               permission.Entity.Name,
				FullyQualifiedName: "hugggingface.com/organization/" + permission.Entity.ID,
				Type:               "organization",
			}
			for _, perm := range permission.Permissions {
				orgPermissions[perm] = struct{}{}
			}
			break
		}
	}

	bindings := make([]analyzers.Binding, 0)
	// check if there are any org permissions
	if orgResource == nil {
		return bindings
	}

	for _, permission := range org_scopes_order {
		for key, value := range org_scopes[permission] {
			if _, ok := orgPermissions[key]; ok {
				bindings = append(bindings, analyzers.Binding{
					Resource: *orgResource,
					Permission: analyzers.Permission{
						Value: value,
					},
				})
			}
		}
	}

	return bindings
}

func bakeUserBindings(tokenJSON HFTokenJSON) []analyzers.Binding {
	bindings := make([]analyzers.Binding, 0)
	// build a map of all user permissions
	users := map[string]struct{}{}
	userPermissions := map[string]struct{}{}
	for _, permission := range tokenJSON.Auth.AccessToken.FineGrained.Scoped {
		if permission.Entity.Type == "user" {
			users[permission.Entity.Name] = struct{}{}
			for _, perm := range permission.Permissions {
				userPermissions[perm] = struct{}{}
			}
		}
	}

	// global permissions only apply to user tokens as of 6/6/24
	// but there would be a naming collision in the scopes document
	// so we prepend "global." to the key and then add to the map
	for _, permission := range tokenJSON.Auth.AccessToken.FineGrained.Global {
		userPermissions["global."+permission] = struct{}{}
	}

	// check if there are any user permissions
	if len(userPermissions) == 0 {
		return bindings
	}

	userResource := analyzers.Resource{
		Name:               tokenJSON.Name,
		FullyQualifiedName: "huggingface.com/user/" + tokenJSON.Username,
		Type:               "user",
	}
	for _, permission := range user_scopes_order {
		for key, value := range user_scopes[permission] {
			if _, ok := userPermissions[key]; ok {
				bindings = append(bindings, analyzers.Binding{
					Resource: userResource,
					Permission: analyzers.Permission{
						Value: value,
					},
				})
			}
		}
	}

	return bindings
}

func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult {
	if info == nil {
		return nil
	}

	result := analyzers.AnalyzerResult{
		AnalyzerType: analyzers.AnalyzerTypeHuggingFace,
		Metadata: map[string]interface{}{
			"username":   info.Token.Username,
			"name":       info.Token.Name,
			"token_name": info.Token.Auth.AccessToken.Name,
			"token_type": info.Token.Auth.AccessToken.Type,
		},
	}

	if len(info.Token.Orgs) > 0 {
		result.UnboundedResources = bakeUnboundedResources(info.Token)
	}

	result.Bindings = make([]analyzers.Binding, 0)

	if info.Token.Auth.AccessToken.Type == FINEGRAINED {
		result.Bindings = append(result.Bindings, bakefineGrainedBindings(info.Models, info.Token)...)
		result.Bindings = append(result.Bindings, bakeOrganizationBindings(info.Token)...)
		result.Bindings = append(result.Bindings, bakeUserBindings(info.Token)...)
	} else {
		result.Bindings = append(result.Bindings, bakeUnfineGrainedBindings(info.Models, info.Token)...)
	}

	return &result
}

// HFTokenJSON is the struct for the HF /whoami-v2 API JSON response
type HFTokenJSON struct {
	Username string `json:"name"`
	Name     string `json:"fullname"`
	Orgs     []struct {
		Name         string `json:"name"`
		Role         string `json:"roleInOrg"`
		IsEnterprise bool   `json:"isEnterprise"`
	} `json:"orgs"`
	Auth struct {
		AccessToken struct {
			Name        string `json:"displayName"`
			Type        string `json:"role"`
			CreatedAt   string `json:"createdAt"`
			FineGrained struct {
				Global []string `json:"global"`
				Scoped []struct {
					Entity struct {
						Type string `json:"type"`
						Name string `json:"name"`
						ID   string `json:"_id"`
					} `json:"entity"`
					Permissions []string `json:"permissions"`
				} `json:"scoped"`
			} `json:"fineGrained"`
		}
	} `json:"auth"`
}

type Permissions struct {
	Read  bool
	Write bool
}

type Model struct {
	Name        string `json:"id"`
	ID          string `json:"_id"`
	Private     bool   `json:"private"`
	Permissions Permissions
}

// getModelsByAuthor calls the HF API /models endpoint with the author query param
// returns a list of models and an error
func getModelsByAuthor(cfg *config.Config, key string, author string) ([]Model, error) {
	var modelsJSON []Model

	// create a new request
	client := analyzers.NewAnalyzeClient(cfg)
	req, err := http.NewRequest("GET", "https://huggingface.co/api/models", nil)
	if err != nil {
		return modelsJSON, err
	}

	// Add bearer token
	req.Header.Add("Authorization", "Bearer "+key)

	// Add author param
	q := req.URL.Query()
	q.Add("author", author)
	req.URL.RawQuery = q.Encode()

	// send the request
	resp, err := client.Do(req)
	if err != nil {
		return modelsJSON, err
	}

	// defer the response body closing
	defer func() { _ = resp.Body.Close() }()

	// read response
	if err := json.NewDecoder(resp.Body).Decode(&modelsJSON); err != nil {
		return modelsJSON, err
	}
	return modelsJSON, nil
}

// getTokenInfo calls the HF API /whoami-v2 endpoint to get the token info
// returns the token info, a boolean indicating token validity, and an error
func getTokenInfo(cfg *config.Config, key string) (HFTokenJSON, bool, error) {
	var tokenJSON HFTokenJSON

	// create a new request
	client := analyzers.NewAnalyzeClient(cfg)
	req, err := http.NewRequest("GET", "https://huggingface.co/api/whoami-v2", nil)
	if err != nil {
		return tokenJSON, false, err
	}

	// Add bearer token
	req.Header.Add("Authorization", "Bearer "+key)

	// send the request
	resp, err := client.Do(req)
	if err != nil {
		return tokenJSON, false, err
	}

	// check if the response is 200
	if resp.StatusCode != 200 {
		return tokenJSON, false, nil
	}

	// defer the response body closing
	defer func() { _ = resp.Body.Close() }()

	// read response
	if err := json.NewDecoder(resp.Body).Decode(&tokenJSON); err != nil {
		return tokenJSON, true, err
	}
	return tokenJSON, true, nil
}

type SecretInfo struct {
	Token  HFTokenJSON
	Models []Model
}

func AnalyzePermissions(cfg *config.Config, key string) (*SecretInfo, error) {
	// get token info
	token, success, err := getTokenInfo(cfg, key)
	if err != nil {
		return nil, err
	}

	if !success {
		return nil, fmt.Errorf("invalid HuggingFace Access Token")
	}

	// get all models by username
	var allModels []Model
	userModels, err := getModelsByAuthor(cfg, key, token.Username)
	if err != nil {
		return nil, err
	}
	allModels = append(allModels, userModels...)

	// get all models from all orgs
	for _, org := range token.Orgs {
		orgModels, err := getModelsByAuthor(cfg, key, org.Name)
		if err != nil {
			return nil, err
		}
		allModels = append(allModels, orgModels...)
	}

	return &SecretInfo{
		Token:  token,
		Models: allModels,
	}, nil
}

// AnalyzeAndPrintPermissions prints the permissions of a HuggingFace API key
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 HuggingFace Access Token\n\n")

	// print user info
	color.Yellow("[i] Username: " + info.Token.Username)
	color.Yellow("[i] Name: " + info.Token.Name)
	color.Yellow("[i] Token Name: " + info.Token.Auth.AccessToken.Name)
	color.Yellow("[i] Token Type: " + info.Token.Auth.AccessToken.Type)

	// print org info
	printOrgs(info.Token)

	// print accessible models
	printAccessibleModels(info.Models, info.Token)

	if info.Token.Auth.AccessToken.Type == FINEGRAINED {
		// print org permissions
		printOrgPermissions(info.Token)

		// print user permissions
		printUserPermissions(info.Token)
	}
}

// printUserPermissions prints the user permissions
// only applies to fine-grained tokens
func printUserPermissions(tokenJSON HFTokenJSON) {
	color.Green("\n[i] User Permissions:")

	// build a map of all user permissions
	userPermissions := map[string]struct{}{}
	for _, permission := range tokenJSON.Auth.AccessToken.FineGrained.Scoped {
		if permission.Entity.Type == "user" {
			for _, perm := range permission.Permissions {
				userPermissions[perm] = struct{}{}
			}
		}
	}

	// global permissions only apply to user tokens as of 6/6/24
	// but there would be a naming collision in the scopes document
	// so we prepend "global." to the key and then add to the map
	for _, permission := range tokenJSON.Auth.AccessToken.FineGrained.Global {
		userPermissions["global."+permission] = struct{}{}
	}

	// check if there are any user permissions
	if len(userPermissions) == 0 {
		color.Red("\tNo user permissions scoped.")
		return
	}

	// print the user permissions
	t := table.NewWriter()
	t.SetOutputMirror(os.Stdout)
	t.AppendHeader(table.Row{"Category", "Permission", "In-Scope"})

	for _, permission := range user_scopes_order {
		t.AppendRow([]interface{}{permission, "---", "---"})
		for key, value := range user_scopes[permission] {
			if _, ok := userPermissions[key]; ok {
				t.AppendRow([]interface{}{"", color.GreenString(value), color.GreenString("True")})
			} else {
				t.AppendRow([]interface{}{"", value, "False"})
			}
		}
	}
	t.Render()
}

// printOrgPermissions prints the organization permissions
// only applies to fine-grained tokens
func printOrgPermissions(tokenJSON HFTokenJSON) {
	color.Green("\n[i] Organization Permissions:")

	// check if there are any org permissions
	// if so, save them as a map. Only need to do this once
	// even if multiple orgs b/c as of 6/6/24, users can only define one set of scopes
	// for all orgs referenced on an access token
	orgScoped := false
	orgPermissions := map[string]struct{}{}
	for _, permission := range tokenJSON.Auth.AccessToken.FineGrained.Scoped {
		if permission.Entity.Type == "org" {
			orgScoped = true
			for _, perm := range permission.Permissions {
				orgPermissions[perm] = struct{}{}
			}
			break
		}
	}

	// check if there are any org permissions
	if !orgScoped {
		color.Red("\tNo organization permissions scoped.")
		return
	}

	// print the org permissions
	t := table.NewWriter()
	t.SetOutputMirror(os.Stdout)
	t.AppendHeader(table.Row{"Category", "Permission", "In-Scope"})

	for _, permission := range org_scopes_order {
		t.AppendRow([]interface{}{permission, "---", "---"})
		for key, value := range org_scopes[permission] {
			if _, ok := orgPermissions[key]; ok {
				t.AppendRow([]interface{}{"", color.GreenString(value), color.GreenString("True")})
			} else {
				t.AppendRow([]interface{}{"", value, "False"})
			}
		}
	}
	t.Render()
}

// printOrgs prints the organizations the user is a member of
func printOrgs(tokenJSON HFTokenJSON) {
	color.Green("\n[i] Organizations:")

	if len(tokenJSON.Orgs) == 0 {
		color.Yellow("\tNo organizations found.")
		return
	}

	t := table.NewWriter()
	t.SetOutputMirror(os.Stdout)
	t.AppendHeader(table.Row{"Name", "Role", "Is Enterprise"})
	for _, org := range tokenJSON.Orgs {
		enterprise := ""
		role := ""
		if org.IsEnterprise {
			enterprise = color.New(color.FgGreen).Sprint("True")
		} else {
			enterprise = "False"
		}
		if org.Role == "admin" {
			role = color.New(color.FgGreen).Sprint("Admin")
		} else {
			role = org.Role
		}
		t.AppendRow([]interface{}{color.GreenString(org.Name), role, enterprise})
	}
	t.Render()
}

// modelNameLookup is a helper function to lookup model name by _id
func modelNameLookup(models []Model, id string) string {
	for _, model := range models {
		if model.ID == id {
			return model.Name
		}
	}
	return ""
}

// printAccessibleModels adds permissions as needed to each model
//
//	and then calls the printModelsTable function
func printAccessibleModels(allModels []Model, tokenJSON HFTokenJSON) {
	color.Green("\n[i] Accessible Models:")

	if tokenJSON.Auth.AccessToken.Type != FINEGRAINED {
		// Add Read Privs to All Models
		for idx := range allModels {
			allModels[idx].Permissions.Read = true
		}
		// Add Write Privs to All Models if Write Access
		if tokenJSON.Auth.AccessToken.Type == WRITE {
			for idx := range allModels {
				allModels[idx].Permissions.Write = true
			}
		}
		// Print Models Table
		printModelsTable(allModels)
		return
	}

	// finegrained scopes are grouped by org, user or model.
	// this section will extract the relevant permissions for each entity and store them in a map
	var nameToPermissions = make(map[string]Permissions)
	for _, permission := range tokenJSON.Auth.AccessToken.FineGrained.Scoped {
		read := false
		write := false
		for _, perm := range permission.Permissions {
			switch perm {
			case "repo.content.read":
				read = true
			case "repo.write":
				write = true
			}
		}
		switch permission.Entity.Type {
		case "user", "org":
			nameToPermissions[permission.Entity.Name] = Permissions{Read: read, Write: write}
		case "model":
			nameToPermissions[modelNameLookup(allModels, permission.Entity.ID)] = Permissions{Read: read, Write: write}
		}
	}

	// apply permissions to all models
	for idx := range allModels {
		// get username/orgname for each model and apply those permissions
		modelUsername := strings.Split(allModels[idx].Name, "/")[0]
		if permissions, ok := nameToPermissions[modelUsername]; ok {
			allModels[idx].Permissions = permissions
		}
		// override model permissions with repo-specific permissions
		if permissions, ok := nameToPermissions[allModels[idx].Name]; ok {
			allModels[idx].Permissions = permissions
		}
	}

	// Print Models Table
	printModelsTable(allModels)
}

// printModelsTable prints the models table
func printModelsTable(models []Model) {
	t := table.NewWriter()
	t.SetOutputMirror(os.Stdout)
	t.AppendHeader(table.Row{"Model", "Private", "Read", "Write"})
	for _, model := range models {
		var name, read, write, private string
		if model.Permissions.Read {
			read = color.New(color.FgGreen).Sprint("True")
		} else {
			read = "False"
		}
		if model.Permissions.Write {
			write = color.New(color.FgGreen).Sprint("True")
		} else {
			write = "False"
		}
		if model.Private {
			private = color.New(color.FgGreen).Sprint("True")
			name = color.New(color.FgGreen).Sprint(model.Name)
		} else {
			private = "False"
			name = model.Name
		}
		t.AppendRow([]interface{}{name, private, read, write})
	}
	t.Render()
}
