package schema

import (
	"context"
	"errors"
	"fmt"
	"slices"
	"strings"

	core "github.com/authzed/spicedb/pkg/proto/core/v1"
)

// Graph is a struct holding reachability information.
type Graph struct {
	arrowSet         *ArrowSet
	ts               *TypeSystem
	referenceInfoMap map[nsAndRel][]RelationReferenceInfo
}

// BuildGraph builds the graph of all reachable information in the schema.
func BuildGraph(ctx context.Context, r *CompiledSchemaResolver) (*Graph, error) {
	arrowSet, err := buildArrowSet(ctx, r)
	if err != nil {
		return nil, err
	}

	ts := NewTypeSystem(r)
	referenceInfoMap, err := preComputeRelationReferenceInfo(ctx, arrowSet, r, ts)
	if err != nil {
		return nil, err
	}

	return &Graph{
		ts:               ts,
		arrowSet:         arrowSet,
		referenceInfoMap: referenceInfoMap,
	}, nil
}

// Arrows returns the set of arrows found in the reachability graph.
func (g *Graph) Arrows() *ArrowSet {
	return g.arrowSet
}

// RelationsReferencing returns all relations/permissions in the schema that reference the specified
// relation in some manner.
func (g *Graph) RelationsReferencing(namespaceName string, relationName string) []RelationReferenceInfo {
	return g.referenceInfoMap[nsAndRel{
		Namespace: namespaceName,
		Relation:  relationName,
	}]
}

// ReferenceType is an enum describing what kind of relation reference we hold in a RelationReferenceInfo.
type ReferenceType int

const (
	RelationInExpression ReferenceType = iota
	RelationIsSubjectType
	RelationIsTuplesetForArrow
	RelationIsComputedUsersetForArrow
)

// RelationReferenceInfo holds the relation and metadata for a relation found in the full reachability graph.
type RelationReferenceInfo struct {
	Relation *core.RelationReference
	Type     ReferenceType
	Arrow    *ArrowInformation
}

func relationsReferencing(ctx context.Context, arrowSet *ArrowSet, res FullSchemaResolver, ts *TypeSystem, namespaceName string, relationName string) ([]RelationReferenceInfo, error) {
	foundReferences := make([]RelationReferenceInfo, 0)

	for _, name := range res.AllDefinitionNames() {
		def, err := ts.GetValidatedDefinition(ctx, name)
		if err != nil {
			return nil, err
		}
		for _, relation := range def.Namespace().Relation {
			// Check for the use of the relation directly as part of any permissions in the same namespace.
			if def.IsPermission(relation.Name) && name == namespaceName {
				hasReference, err := expressionReferencesRelation(ctx, relation.GetUsersetRewrite(), relationName)
				if err != nil {
					return nil, err
				}

				if hasReference {
					foundReferences = append(foundReferences, RelationReferenceInfo{
						Relation: &core.RelationReference{
							Namespace: name,
							Relation:  relation.Name,
						},
						Type: RelationInExpression,
					})
				}
				continue
			}

			// Check for the use of the relation as a subject type on any relation in the entire schema.
			isAllowed, err := def.IsAllowedDirectRelation(relation.Name, namespaceName, relationName)
			if err != nil {
				return nil, err
			}

			if isAllowed == DirectRelationValid {
				foundReferences = append(foundReferences, RelationReferenceInfo{
					Relation: &core.RelationReference{
						Namespace: name,
						Relation:  relation.Name,
					},
					Type: RelationIsSubjectType,
				})
			}
		}
	}

	// Add any arrow references.
	key := namespaceName + "#" + relationName
	foundArrows, _ := arrowSet.arrowsByFullTuplesetRelation.Get(key)
	for _, arrow := range foundArrows {
		arrow := arrow
		foundReferences = append(foundReferences, RelationReferenceInfo{
			Relation: &core.RelationReference{
				Namespace: namespaceName,
				Relation:  arrow.ParentRelationName,
			},
			Type:  RelationIsTuplesetForArrow,
			Arrow: &arrow,
		})
	}

	for _, tuplesetRelationKey := range arrowSet.reachableComputedUsersetRelationsByTuplesetRelation.Keys() {
		values, ok := arrowSet.reachableComputedUsersetRelationsByTuplesetRelation.Get(tuplesetRelationKey)
		if !ok {
			continue
		}

		if slices.Contains(values, key) {
			pieces := strings.Split(tuplesetRelationKey, "#")
			foundReferences = append(foundReferences, RelationReferenceInfo{
				Relation: &core.RelationReference{
					Namespace: pieces[0],
					Relation:  pieces[1],
				},
				Type: RelationIsComputedUsersetForArrow,
			})
		}
	}

	return foundReferences, nil
}

type nsAndRel struct {
	Namespace string
	Relation  string
}

func preComputeRelationReferenceInfo(ctx context.Context, arrowSet *ArrowSet, res FullSchemaResolver, ts *TypeSystem) (map[nsAndRel][]RelationReferenceInfo, error) {
	nsAndRelToInfo := make(map[nsAndRel][]RelationReferenceInfo)

	for _, namespaceName := range res.AllDefinitionNames() {
		outerTS, err := ts.GetValidatedDefinition(ctx, namespaceName)
		if err != nil {
			return nil, err
		}
		for _, outerRelation := range outerTS.Namespace().Relation {
			referenceInfos, err := relationsReferencing(ctx, arrowSet, res, ts, namespaceName, outerRelation.Name)
			if err != nil {
				return nil, err
			}

			nsAndRel := nsAndRel{
				Namespace: namespaceName,
				Relation:  outerRelation.Name,
			}
			nsAndRelToInfo[nsAndRel] = referenceInfos
		}
	}

	return nsAndRelToInfo, nil
}

func expressionReferencesRelation(ctx context.Context, rewrite *core.UsersetRewrite, relationName string) (bool, error) {
	// TODO(jschorr): Precompute this and maybe create a visitor pattern to stop repeating this everywhere
	switch rw := rewrite.RewriteOperation.(type) {
	case *core.UsersetRewrite_Union:
		return setOperationReferencesRelation(ctx, rw.Union, relationName)
	case *core.UsersetRewrite_Intersection:
		return setOperationReferencesRelation(ctx, rw.Intersection, relationName)
	case *core.UsersetRewrite_Exclusion:
		return setOperationReferencesRelation(ctx, rw.Exclusion, relationName)
	default:
		return false, errors.New("userset rewrite operation not implemented in expressionReferencesRelation")
	}
}

func setOperationReferencesRelation(ctx context.Context, so *core.SetOperation, relationName string) (bool, error) {
	for _, childOneof := range so.Child {
		switch child := childOneof.ChildType.(type) {
		case *core.SetOperation_Child_ComputedUserset:
			if child.ComputedUserset.Relation == relationName {
				return true, nil
			}

		case *core.SetOperation_Child_UsersetRewrite:
			result, err := expressionReferencesRelation(ctx, child.UsersetRewrite, relationName)
			if result || err != nil {
				return result, err
			}

		case *core.SetOperation_Child_TupleToUserset:
			// Nothing to do, handled above via arrow set

		case *core.SetOperation_Child_FunctionedTupleToUserset:
			// Nothing to do, handled above via arrow set

		case *core.SetOperation_Child_XThis:
			// Nothing to do

		case *core.SetOperation_Child_XNil:
			// Nothing to do

		default:
			return false, fmt.Errorf("unknown set operation child `%T` in setOperationReferencesRelation", child)
		}
	}

	return false, nil
}
