package aws

import (
	"context"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"github.com/BishopFox/cloudfox/internal"
	"github.com/BishopFox/cloudfox/internal/aws/policy"
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/service/sts"
	"github.com/dominikbraun/graph"
	"github.com/neo4j/neo4j-go-driver/v5/neo4j"
	"github.com/sirupsen/logrus"
)

type PmapperModule struct {
	// General configuration data
	Caller        sts.GetCallerIdentityOutput
	AWSRegions    []string
	AWSOutputType string
	AWSTableCols  string

	Goroutines int
	AWSProfile string
	WrapTable  bool

	// Main module data
	PmapperDataBasePath string
	pmapperGraph        graph.Graph[string, string]
	Nodes               []Node
	Edges               []Edge
	CommandCounter      internal.CommandCounter
	// Used to store output data for pretty printing
	output internal.OutputData2
	modLog *logrus.Entry
}

type PmapperOutputRow struct {
	Start string
	End   string
	Paths []string
}

type Edge struct {
	Source      string `json:"source"`
	Destination string `json:"destination"`
	Reason      string `json:"reason"`
	ShortReason string `json:"short_reason"`
}

type Node struct {
	Arn                       string `json:"arn"`
	Type                      string
	AccountID                 string
	Name                      string
	IDValue                   string             `json:"id_value"`
	AttachedPolicies          []AttachedPolicies `json:"attached_policies"`
	GroupMemberships          []interface{}      `json:"group_memberships"`
	TrustPolicy               interface{}        `json:"trust_policy"`
	TrustsDoc                 policy.TrustPolicyDocument
	TrustedPrincipals         []TrustedPrincipal
	TrustedServices           []TrustedService
	TrustedFederatedProviders []TrustedFederatedProvider
	InstanceProfile           interface{} `json:"instance_profile"`
	ActivePassword            bool        `json:"active_password"`
	AccessKeys                int         `json:"access_keys"`
	IsAdmin                   bool        `json:"is_admin"`
	PathToAdmin               bool
	PermissionsBoundary       interface{} `json:"permissions_boundary"`
	HasMfa                    bool        `json:"has_mfa"`
	Tags                      Tags        `json:"tags"`
	CanPrivEscToAdminString   string
	IsAdminString             string
	VendorName                string
}

type TrustedPrincipal struct {
	TrustedPrincipal               string
	ExternalID                     string
	VendorName                     string
	AccountIsInAnalyzedAccountList bool
	//IsAdmin           bool
	//CanPrivEscToAdmin bool
}

type TrustedService struct {
	TrustedService string
	AccountID      string
	//IsAdmin           bool
	//CanPrivEscToAdmin bool
}

type TrustedFederatedProvider struct {
	TrustedFederatedProvider string
	ProviderAccountId        string
	ProviderShortName        string
	TrustedSubjects          []string
	//IsAdmin                  bool
	//CanPrivEscToAdmin        bool
}

type AttachedPolicies struct {
	Arn  string `json:"arn"`
	Name string `json:"name"`
}
type Tags struct {
}

func (m *PmapperModule) initPmapperGraph() error {
	// Parse mapper nodes and edges and populate the m.Nodes and m.Edges slices in the method struct
	err := m.readPmapperData(m.Caller.Account)
	if err != nil {
		return err
	}

	m.pmapperGraph = m.createAndPopulateGraph()

	for i := range m.Nodes {
		if m.doesNodeHavePathToAdmin(m.Nodes[i]) {
			m.Nodes[i].PathToAdmin = true
			m.Nodes[i].CanPrivEscToAdminString = "Yes"
			//fmt.Println(m.Nodes[i].Arn, m.Nodes[i].IsAdmin, m.Nodes[i].PathToAdmin)
		} else {
			m.Nodes[i].PathToAdmin = false
			m.Nodes[i].CanPrivEscToAdminString = "No"
		}
		if m.Nodes[i].IsAdmin {
			m.Nodes[i].IsAdminString = "Yes"
		} else {
			m.Nodes[i].IsAdminString = "No"
		}
	}

	return nil
}

func (m *PmapperModule) createAndPopulateGraph() graph.Graph[string, string] {

	pmapperGraph := graph.New(graph.StringHash, graph.Directed())

	// having issues with caching the graph. will have to swing back and try again later

	// gob.Register(pmapperGraph)
	// cacheKey := fmt.Sprintf("%s-pmapperGraph", aws.ToString(m.Caller.Account))
	// cached, found := internal.Cache.Get(cacheKey)
	// if found {
	// 	return cached.(graph.Graph[string, string])
	// }

	for _, node := range m.Nodes {
		_ = pmapperGraph.AddVertex(node.Arn)

	}

	for _, edge := range m.Edges {
		err := pmapperGraph.AddEdge(
			edge.Source,
			edge.Destination,
			graph.EdgeAttribute(edge.ShortReason, edge.Reason),
		)
		if err != nil {
			if err == graph.ErrEdgeAlreadyExists {
				// update the edge by copying the existing graph.Edge with attributes and add the new attributes
				//fmt.Println("Edge already exists, but adding a new one!")

				// get the existing edge
				existingEdge, _ := pmapperGraph.Edge(edge.Source, edge.Destination)
				// get the map of attributes
				existingProperties := existingEdge.Properties
				// add the new attributes to attributes map within the properties struct
				// Check if the Attributes map is initialized, if not, initialize it
				if existingProperties.Attributes == nil {
					existingProperties.Attributes = make(map[string]string)
				}

				// Add or update the attribute
				existingProperties.Attributes[edge.ShortReason] = edge.Reason
				//Update the edge
				pmapperGraph.UpdateEdge(
					edge.Source,
					edge.Destination,
					graph.EdgeAttributes(existingProperties.Attributes),
				)

			}
			//fmt.Println(edge.Reason)
		}

	}

	//internal.Cache.Set(cacheKey, pmapperGraph, cache.DefaultExpiration)
	return pmapperGraph

}

func (m *PmapperModule) DoesPrincipalHavePathToAdmin(principal string) bool {
	for i := range m.Nodes {
		if m.Nodes[i].Arn == principal {
			if m.Nodes[i].PathToAdmin {
				m.Nodes[i].CanPrivEscToAdminString = "Yes"
				return true
			} else {
				m.Nodes[i].CanPrivEscToAdminString = "No"
				return false
			}
		}

	}
	return false
}

func (m *PmapperModule) DoesPrincipalHaveAdmin(principal string) bool {
	for i := range m.Nodes {
		if m.Nodes[i].Arn == principal {
			if m.Nodes[i].IsAdmin {
				m.Nodes[i].IsAdminString = "Yes"
				return true
			} else {
				m.Nodes[i].IsAdminString = "No"
				return false
			}
		}

	}
	return false
}

func (m *PmapperModule) PrintPmapperData(outputDirectory string, verbosity int) {
	// These struct values are used by the output module
	m.output.Verbosity = verbosity
	m.output.Directory = outputDirectory
	m.output.CallingModule = "pmapper"
	m.output.FullFilename = m.output.CallingModule
	m.modLog = internal.TxtLog.WithFields(logrus.Fields{
		"module": m.output.CallingModule,
	})
	if m.AWSProfile == "" {
		m.AWSProfile = internal.BuildAWSPath(m.Caller)
	}
	m.output.FilePath = filepath.Join(outputDirectory, "cloudfox-output", "aws", fmt.Sprintf("%s-%s", m.AWSProfile, aws.ToString(m.Caller.Account)))
	fmt.Printf("[%s][%s] Looking for pmapper data for this account and building a PrivEsc graph in golang if it exists.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile))
	pmapperError := m.initPmapperGraph()
	if pmapperError != nil {
		fmt.Printf("[%s][%s] No pmapper data found for this account. \n\t\t\t1. Generate pmapper data by running `pmapper --profile %s graph create`\n\t\t\t2. After that completes, cloudfox will attempt to enrich this command and others with pmapper privesc data\n\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), m.AWSProfile)
		fmt.Printf("[%s][%s] For more info and troubleshooting steps: https://github.com/BishopFox/cloudfox/wiki/AWS-Commands#%s\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), m.output.CallingModule)
		m.modLog.Error(pmapperError)
		return

	} else {
		fmt.Printf("[%s][%s] Parsing pmapper data for account %s.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), aws.ToString(m.Caller.Account))

	}

	m.output.Headers = []string{
		"Account",
		"Principal Arn",
		"IsAdmin?",
		"CanPrivEscToAdmin?",
	}

	// If the user specified table columns, use those.
	// If the user specified -o wide, use the wide default cols for this module.
	// Otherwise, use the hardcoded default cols for this module.
	var tableCols []string
	// If the user specified table columns, use those.
	if m.AWSTableCols != "" {
		// If the user specified wide as the output format, use these columns.
		// remove any spaces between any commas and the first letter after the commas
		m.AWSTableCols = strings.ReplaceAll(m.AWSTableCols, ", ", ",")
		m.AWSTableCols = strings.ReplaceAll(m.AWSTableCols, ",  ", ",")
		tableCols = strings.Split(m.AWSTableCols, ",")
	} else if m.AWSOutputType == "wide" {
		tableCols = []string{
			"Account",
			"Principal Arn",
			"IsAdmin?",
			"CanPrivEscToAdmin?",
		}
		// Otherwise, use the default columns.
	} else {
		tableCols = []string{
			"Principal Arn",
			"IsAdmin?",
			"CanPrivEscToAdmin?",
		}
	}

	//Table rows
	var isAdmin, pathToAdmin string
	for i := range m.Nodes {
		if m.Nodes[i].PathToAdmin || m.Nodes[i].IsAdmin {
			if m.Nodes[i].IsAdmin {
				isAdmin = "YES"
			} else {
				isAdmin = "No"
			}
			if m.Nodes[i].PathToAdmin {
				pathToAdmin = "YES"
			} else {
				pathToAdmin = "No"
			}

			m.output.Body = append(
				m.output.Body,
				[]string{
					aws.ToString(m.Caller.Account),
					m.Nodes[i].Arn,
					isAdmin,
					pathToAdmin,
				},
			)
		}
	}

	if len(m.output.Body) > 0 {
		m.output.FilePath = filepath.Join(outputDirectory, "cloudfox-output", "aws", fmt.Sprintf("%s-%s", m.AWSProfile, aws.ToString(m.Caller.Account)))
		o := internal.OutputClient{
			Verbosity:     verbosity,
			CallingModule: m.output.CallingModule,
			Table: internal.TableClient{
				Wrap: m.WrapTable,
			},
			Loot: internal.LootClient{
				DirectoryName: m.output.FilePath,
			},
		}
		o.Table.TableFiles = append(o.Table.TableFiles, internal.TableFile{
			Header:    m.output.Headers,
			Body:      m.output.Body,
			TableCols: tableCols,
			Name:      m.output.CallingModule,
		})
		o.PrefixIdentifier = m.AWSProfile
		o.Table.DirectoryName = filepath.Join(outputDirectory, "cloudfox-output", "aws", fmt.Sprintf("%s-%s", m.AWSProfile, aws.ToString(m.Caller.Account)))

		header, body := m.createPmapperTableData(outputDirectory)
		o.Table.TableFiles = append(o.Table.TableFiles, internal.TableFile{
			Header:            header,
			Body:              body,
			Name:              "pmapper-privesc-paths-enhanced",
			SkipPrintToScreen: true,
		})

		loot := m.writeLoot(o.Table.DirectoryName, verbosity)
		o.Loot.LootFiles = append(o.Loot.LootFiles, internal.LootFile{
			Name:     m.output.CallingModule,
			Contents: loot,
		})
		o.WriteFullOutput(o.Table.TableFiles, o.Loot.LootFiles)
		//m.writeLoot(o.Table.DirectoryName, verbosity)

		fmt.Printf("[%s][%s] %s principals who are admin or have a path to admin identified.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), strconv.Itoa(len(m.output.Body)))

	} else {
		fmt.Printf("[%s][%s] No principals who are admin or have a path to admin identified. skipping the creation of an output file.\n", cyan(m.output.CallingModule), cyan(m.AWSProfile))
	}
	fmt.Printf("[%s][%s] For context and next steps: https://github.com/BishopFox/cloudfox/wiki/AWS-Commands#%s\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), m.output.CallingModule)
}

func (m *PmapperModule) doesNodeHavePathToAdmin(startNode Node) bool {
	if startNode.IsAdmin {
		return true
	} else {
		for _, destNode := range m.Nodes {
			if destNode.IsAdmin {
				path, _ := graph.ShortestPath(m.pmapperGraph, startNode.Arn, destNode.Arn)
				for _, p := range path {
					if p != "" {
						if startNode.Arn != destNode.Arn {
							return true
						}

					}
				}
			}
		}
	}
	return false
}

func (m *PmapperModule) createPmapperTableData(outputDirectory string) ([]string, [][]string) {
	var header []string
	var body [][]string

	header = []string{
		"Start",
		"End",
		"Path(s)",
	}

	var paths string
	var admins, privescPathsBody [][]string

	for _, startNode := range m.Nodes {
		if startNode.IsAdmin {
			admins = append(admins, []string{startNode.Arn, "", "ADMIN"})

		} else {
			for _, destNode := range m.Nodes {
				if destNode.IsAdmin {
					path, _ := graph.ShortestPath(m.pmapperGraph, startNode.Arn, destNode.Arn)
					// if we have a path,

					if len(path) > 0 {
						if startNode.Arn != destNode.Arn {
							paths = ""
							// if we got herethere's a path. Lets print the reason and the short reason for each edge in the path to the screen
							for i := 0; i < len(path)-1; i++ {
								for _, edge := range m.Edges {
									if edge.Source == path[i] && edge.Destination == path[i+1] {

										//Some pmapper reasons have commas in them so lets get rid of them in the csvOutputdata
										edge.Reason = strings.ReplaceAll(edge.Reason, ",", " and")
										paths += fmt.Sprintf("%s %s %s\n", path[i], edge.Reason, path[i+1])
									}

								}
							}
							//trim the last newline from csvPaths
							paths = strings.TrimSuffix(paths, "\n")
							privescPathsBody = append(privescPathsBody, []string{startNode.Arn, destNode.Arn, paths})

						}

					}
				}
			}
		}
	}

	// create body by first adding the admins and then the privesc paths
	body = append(body, admins...)
	body = append(body, privescPathsBody...)
	return header, body

}

func (m *PmapperModule) writeLoot(outputDirectory string, verbosity int) string {
	path := filepath.Join(outputDirectory, "loot")
	err := os.MkdirAll(path, os.ModePerm)
	if err != nil {
		m.modLog.Error(err.Error())
		m.CommandCounter.Error++
		panic(err.Error())
	}
	lootFilePath := filepath.Join(path, "pmapper.txt")

	var admins, out string

	for _, startNode := range m.Nodes {
		if startNode.IsAdmin {
			admins += fmt.Sprintf("ADMIN FOUND: %s\n", startNode.Arn)
		} else {
			for _, destNode := range m.Nodes {
				if destNode.IsAdmin {
					path, _ := graph.ShortestPath(m.pmapperGraph, startNode.Arn, destNode.Arn)
					// if we have a path,

					if len(path) > 0 {
						if startNode.Arn != destNode.Arn {
							// if we got here there is a path
							out += fmt.Sprintf("PATH TO ADMIN FOUND\n   Start: %s\n     End: %s\n Path(s):\n", startNode.Arn, destNode.Arn)
							//fmt.Println(path)
							// if we got herethere's a path. Lets print the reason and the short reason for each edge in the path to the screen
							for i := 0; i < len(path)-1; i++ {
								for _, edge := range m.Edges {
									if edge.Source == path[i] && edge.Destination == path[i+1] {
										// print it like this: [start node] [reason] [end node]
										out += fmt.Sprintf("     %s %s %s\n", path[i], edge.Reason, path[i+1])
									}
									// shortest path only finds the shortest path. We want to find all paths. So we need to find all paths that have the same start and end nodes from the path, but going back to the main edges slice
									//for _, edge := range GlobalPmapperEdges {
									// 	if edge.Source == path[i] && edge.Destination == path[i+1] {
									// 		// print it like this: [start node] [reason] [end node]
									// 		out += fmt.Sprintf("   %s %s %s\n", path[i], edge.Reason, path[i+1])
									// 	}
									// }
								}
							}
							out += fmt.Sprintf("\n")

						}

					}
				}
			}
		}
	}
	out = admins + "\n\n" + out

	if verbosity > 2 {
		fmt.Println()
		fmt.Printf("[%s][%s] %s \n", cyan(m.output.CallingModule), cyan(m.AWSProfile), green("Beginning of loot file"))
		fmt.Print(out)
		fmt.Printf("[%s][%s] %s \n\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), green("End of loot file"))
	}
	fmt.Printf("[%s][%s] %s \n", cyan(m.output.CallingModule), cyan(m.AWSProfile), magenta(fmt.Sprintf("Loot file with ALL potential paths written to: [%s]", lootFilePath)))
	return out

}

func (m *PmapperModule) readPmapperData(accountID *string) error {

	var e, n string
	if m.PmapperDataBasePath == "" {
		e, n = generatePmapperDataBasePaths(accountID)
	} else {
		e = filepath.Join(m.PmapperDataBasePath, aws.ToString(accountID), "graph", "edges.json")
		n = filepath.Join(m.PmapperDataBasePath, aws.ToString(accountID), "graph", "nodes.json")
	}

	nodesFile, err := os.Open(n)
	if err != nil {
		return err
	}
	defer nodesFile.Close()
	nodesByteValue, _ := ioutil.ReadAll(nodesFile)
	json.Unmarshal([]byte(nodesByteValue), &m.Nodes)

	edgesFile, err := os.Open(e)
	if err != nil {
		return err
	}
	defer edgesFile.Close()
	edgesByteValue, _ := ioutil.ReadAll(edgesFile)
	json.Unmarshal([]byte(edgesByteValue), &m.Edges)

	return nil

}

func (m *PmapperModule) GenerateCypherStatements(goCtx context.Context, driver neo4j.DriverWithContext) error {
	// Insert nodes
	for i, node := range m.Nodes {
		query, params := m.generateNodeCreateStatement(node, i)
		if err := m.executeCypherQuery(goCtx, driver, query, params); err != nil {
			return err
		}
	}

	// Insert edges
	for i, edge := range m.Edges {
		query, params := m.generateEdgeCreateStatement(edge, i)
		if err := m.executeCypherQuery(goCtx, driver, query, params); err != nil {
			return err
		}
	}

	return nil
}

func (m *PmapperModule) generateNodeCreateStatement(node Node, i int) (string, map[string]interface{}) {
	var ptype, label, query string
	var params map[string]any

	if strings.Contains(node.Arn, "role") {
		label = GetResourceNameFromArn(node.Arn)
		ptype = "Role"
		params = map[string]any{
			"Id":          node.Arn,
			"ARN":         node.Arn,
			"Name":        GetResourceNameFromArn(node.Arn),
			"IdValue":     node.IDValue,
			"IsAdminP":    node.IsAdmin,
			"PathToAdmin": node.PathToAdmin,
		}

	} else if strings.Contains(node.Arn, "user") {
		label = GetResourceNameFromArn(node.Arn)
		ptype = "User"
		//node.TrustPolicy = ""
		params = map[string]any{
			"Id":          node.Arn,
			"ARN":         node.Arn,
			"Name":        GetResourceNameFromArn(node.Arn),
			"IdValue":     node.IDValue,
			"IsAdminP":    node.IsAdmin,
			"PathToAdmin": node.PathToAdmin,
		}

	} else if strings.Contains(node.Arn, "group") {
		label = GetResourceNameFromArn(node.Arn)
		ptype = "Group"
	}
	label = strings.ReplaceAll(label, "-", "_")
	label = strings.ReplaceAll(label, ".", "_")

	query = `MERGE (%s:%s {Id: $Id, ARN: $ARN, Name: $Name, IdValue: $IdValue, IsAdminP: $IsAdminP, PathToAdmin: $PathToAdmin})`

	//sanitizedArn := sanitizeArnForNeo4jLabel(node.Arn)
	//id := fmt.Sprintf("%s_%s", sanitizedArn, ptype)

	fmt.Println(fmt.Sprintf(query, label, ptype), params)
	return fmt.Sprintf(query, label, ptype), params
}

func (m *PmapperModule) generateEdgeCreateStatement(edge Edge, i int) (string, map[string]interface{}) {
	// Sanitize ARNs for matching nodes
	//srcArnSanitized := sanitizeArnForNeo4jLabel(edge.Source)
	//destArnSanitized := sanitizeArnForNeo4jLabel(edge.Destination)

	query := `MATCH (a {ARN: $srcArn}), (b {ARN: $destArn}) CREATE (a)-[:CAN_ACCESS {reason: $reason, shortReason: $shortReason}]->(b)`
	params := map[string]any{
		"srcArn":      edge.Source,
		"destArn":     edge.Destination,
		"reason":      edge.Reason,
		"shortReason": edge.ShortReason,
	}
	fmt.Println(query, params)
	return query, params
}

func (m *PmapperModule) executeCypherQuery(ctx context.Context, driver neo4j.DriverWithContext, query string, params map[string]interface{}) error {
	_, err := neo4j.ExecuteQuery(ctx, driver, query, params, neo4j.EagerResultTransformer, neo4j.ExecuteQueryWithDatabase("neo4j"))
	if err != nil {
		sharedLogger.Errorf("Error executing query: %s -- %v", err, params)
		return err
	}
	return nil
}

func sanitizeArnForNeo4jLabel(arn string) string {
	// Replace non-allowed characters with underscores or other allowed characters
	sanitized := strings.ReplaceAll(arn, ":", "_")
	sanitized = strings.ReplaceAll(sanitized, "-", "_")
	// Add more replacements if needed
	return sanitized
}
