package schema

import (
	"context"
	"fmt"

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

type reachabilityOption int

const (
	reachabilityFull reachabilityOption = iota
	reachabilityFirst
)

func computeReachability(ctx context.Context, def *Definition, relationName string, option reachabilityOption) (*core.ReachabilityGraph, error) {
	targetRelation, ok := def.relationMap[relationName]
	if !ok {
		return nil, fmt.Errorf("relation `%s` not found under type `%s` missing when computing reachability", relationName, def.nsDef.Name)
	}

	if !def.HasTypeInformation(relationName) && targetRelation.GetUsersetRewrite() == nil {
		return nil, fmt.Errorf("relation `%s` missing type information when computing reachability for namespace `%s`", relationName, def.nsDef.Name)
	}

	graph := &core.ReachabilityGraph{
		EntrypointsBySubjectType:     map[string]*core.ReachabilityEntrypoints{},
		EntrypointsBySubjectRelation: map[string]*core.ReachabilityEntrypoints{},
	}

	usersetRewrite := targetRelation.GetUsersetRewrite()
	if usersetRewrite != nil {
		return graph, computeRewriteReachability(ctx, graph, usersetRewrite, core.ReachabilityEntrypoint_DIRECT_OPERATION_RESULT, targetRelation, def, option)
	}

	// If there is no userRewrite, then we have a relation and its entrypoints will all be
	// relation entrypoints.
	return graph, addSubjectLinks(graph, core.ReachabilityEntrypoint_DIRECT_OPERATION_RESULT, targetRelation, def)
}

func computeRewriteReachability(ctx context.Context, graph *core.ReachabilityGraph, rewrite *core.UsersetRewrite, operationResultState core.ReachabilityEntrypoint_EntrypointResultStatus, targetRelation *core.Relation, def *Definition, option reachabilityOption) error {
	switch rw := rewrite.RewriteOperation.(type) {
	case *core.UsersetRewrite_Union:
		return computeRewriteOpReachability(ctx, rw.Union.Child, operationResultState, graph, targetRelation, def, option)

	case *core.UsersetRewrite_Intersection:
		// If optimized mode is set, only return the first child of the intersection.
		if option == reachabilityFirst {
			return computeRewriteOpReachability(ctx, rw.Intersection.Child[0:1], core.ReachabilityEntrypoint_REACHABLE_CONDITIONAL_RESULT, graph, targetRelation, def, option)
		}

		return computeRewriteOpReachability(ctx, rw.Intersection.Child, core.ReachabilityEntrypoint_REACHABLE_CONDITIONAL_RESULT, graph, targetRelation, def, option)

	case *core.UsersetRewrite_Exclusion:
		// If optimized mode is set, only return the first child of the exclusion.
		if option == reachabilityFirst {
			return computeRewriteOpReachability(ctx, rw.Exclusion.Child[0:1], core.ReachabilityEntrypoint_REACHABLE_CONDITIONAL_RESULT, graph, targetRelation, def, option)
		}

		return computeRewriteOpReachability(ctx, rw.Exclusion.Child, core.ReachabilityEntrypoint_REACHABLE_CONDITIONAL_RESULT, graph, targetRelation, def, option)

	default:
		return fmt.Errorf("unknown kind of userset rewrite in reachability computation: %T", rw)
	}
}

func computeRewriteOpReachability(ctx context.Context, children []*core.SetOperation_Child, operationResultState core.ReachabilityEntrypoint_EntrypointResultStatus, graph *core.ReachabilityGraph, targetRelation *core.Relation, def *Definition, option reachabilityOption) error {
	rr := &core.RelationReference{
		Namespace: def.nsDef.Name,
		Relation:  targetRelation.Name,
	}

	for _, childOneof := range children {
		switch child := childOneof.ChildType.(type) {
		case *core.SetOperation_Child_XThis:
			return fmt.Errorf("use of _this is unsupported; please rewrite your schema")

		case *core.SetOperation_Child_ComputedUserset:
			// A computed userset adds an entrypoint indicating that the relation is rewritten.
			err := addSubjectEntrypoint(graph, def.nsDef.Name, child.ComputedUserset.Relation, &core.ReachabilityEntrypoint{
				Kind:                    core.ReachabilityEntrypoint_COMPUTED_USERSET_ENTRYPOINT,
				TargetRelation:          rr,
				ComputedUsersetRelation: child.ComputedUserset.Relation,
				ResultStatus:            operationResultState,
			})
			if err != nil {
				return err
			}

		case *core.SetOperation_Child_UsersetRewrite:
			err := computeRewriteReachability(ctx, graph, child.UsersetRewrite, operationResultState, targetRelation, def, option)
			if err != nil {
				return err
			}

		case *core.SetOperation_Child_TupleToUserset:
			tuplesetRelation := child.TupleToUserset.Tupleset.Relation
			computedUsersetRelation := child.TupleToUserset.ComputedUserset.Relation
			if err := computeTTUReachability(ctx, graph, tuplesetRelation, computedUsersetRelation, operationResultState, rr, def); err != nil {
				return err
			}

		case *core.SetOperation_Child_FunctionedTupleToUserset:
			tuplesetRelation := child.FunctionedTupleToUserset.Tupleset.Relation
			computedUsersetRelation := child.FunctionedTupleToUserset.ComputedUserset.Relation

			switch child.FunctionedTupleToUserset.Function {
			case core.FunctionedTupleToUserset_FUNCTION_ANY:
				// Nothing to change.

			case core.FunctionedTupleToUserset_FUNCTION_ALL:
				// Mark as a conditional result.
				operationResultState = core.ReachabilityEntrypoint_REACHABLE_CONDITIONAL_RESULT

			default:
				return spiceerrors.MustBugf("unknown function type `%T` in reachability graph building", child.FunctionedTupleToUserset.Function)
			}

			if err := computeTTUReachability(ctx, graph, tuplesetRelation, computedUsersetRelation, operationResultState, rr, def); err != nil {
				return err
			}

		case *core.SetOperation_Child_XNil:
			// nil has no entrypoints.
			return nil

		default:
			return spiceerrors.MustBugf("unknown set operation child `%T` in reachability graph building", child)
		}
	}

	return nil
}

func computeTTUReachability(
	ctx context.Context,
	graph *core.ReachabilityGraph,
	tuplesetRelation string,
	computedUsersetRelation string,
	operationResultState core.ReachabilityEntrypoint_EntrypointResultStatus,
	rr *core.RelationReference,
	def *Definition,
) error {
	directRelationTypes, err := def.AllowedDirectRelationsAndWildcards(tuplesetRelation)
	if err != nil {
		return err
	}

	for _, allowedRelationType := range directRelationTypes {
		// For each namespace allowed to be found on the right hand side of the
		// tupleset relation, include the *computed userset* relation as an entrypoint.
		//
		// For example, given a schema:
		//
		// ```
		// definition user {}
		//
		// definition parent1 {
		//   relation somerel: user
		// }
		//
		// definition parent2 {
		//   relation somerel: user
		// }
		//
		// definition child {
		//   relation parent: parent1 | parent2
		//   permission someperm = parent->somerel
		// }
		// ```
		//
		// We will add an entrypoint for the arrow itself, keyed to the relation type
		// included from the computed userset.
		//
		// Using the above example, this will add entrypoints for `parent1#somerel`
		// and `parent2#somerel`, which are the subjects reached after resolving the
		// right side of the arrow.

		// Check if the relation does exist on the allowed type, and only add the entrypoint if present.
		relDef, err := def.ts.GetDefinition(ctx, allowedRelationType.Namespace)
		if err != nil {
			return err
		}

		if relDef.HasRelation(computedUsersetRelation) {
			err := addSubjectEntrypoint(graph, allowedRelationType.Namespace, computedUsersetRelation, &core.ReachabilityEntrypoint{
				Kind:                    core.ReachabilityEntrypoint_TUPLESET_TO_USERSET_ENTRYPOINT,
				TargetRelation:          rr,
				ResultStatus:            operationResultState,
				ComputedUsersetRelation: computedUsersetRelation,
				TuplesetRelation:        tuplesetRelation,
			})
			if err != nil {
				return err
			}
		}
	}

	return nil
}

func addSubjectEntrypoint(graph *core.ReachabilityGraph, namespaceName string, relationName string, entrypoint *core.ReachabilityEntrypoint) error {
	key := tuple.JoinRelRef(namespaceName, relationName)
	if relationName == "" {
		return spiceerrors.MustBugf("found empty relation name for subject entrypoint")
	}

	if graph.EntrypointsBySubjectRelation[key] == nil {
		graph.EntrypointsBySubjectRelation[key] = &core.ReachabilityEntrypoints{
			Entrypoints: []*core.ReachabilityEntrypoint{},
			SubjectRelation: &core.RelationReference{
				Namespace: namespaceName,
				Relation:  relationName,
			},
		}
	}

	graph.EntrypointsBySubjectRelation[key].Entrypoints = append(
		graph.EntrypointsBySubjectRelation[key].Entrypoints,
		entrypoint,
	)

	return nil
}

func addSubjectLinks(graph *core.ReachabilityGraph, operationResultState core.ReachabilityEntrypoint_EntrypointResultStatus, relation *core.Relation, def *Definition) error {
	typeInfo := relation.GetTypeInformation()
	if typeInfo == nil {
		return fmt.Errorf("missing type information for relation %s#%s", def.nsDef.Name, relation.Name)
	}

	rr := &core.RelationReference{
		Namespace: def.nsDef.Name,
		Relation:  relation.Name,
	}

	allowedDirectRelations := typeInfo.GetAllowedDirectRelations()
	for _, directRelation := range allowedDirectRelations {
		// If the allowed relation is a wildcard, add it as a subject *type* entrypoint, rather than
		// a subject relation.
		if directRelation.GetPublicWildcard() != nil {
			if graph.EntrypointsBySubjectType[directRelation.Namespace] == nil {
				graph.EntrypointsBySubjectType[directRelation.Namespace] = &core.ReachabilityEntrypoints{
					Entrypoints: []*core.ReachabilityEntrypoint{},
					SubjectType: directRelation.Namespace,
				}
			}

			graph.EntrypointsBySubjectType[directRelation.Namespace].Entrypoints = append(
				graph.EntrypointsBySubjectType[directRelation.Namespace].Entrypoints,
				&core.ReachabilityEntrypoint{
					Kind:           core.ReachabilityEntrypoint_RELATION_ENTRYPOINT,
					TargetRelation: rr,
					ResultStatus:   operationResultState,
				},
			)
			continue
		}

		err := addSubjectEntrypoint(graph, directRelation.Namespace, directRelation.GetRelation(), &core.ReachabilityEntrypoint{
			Kind:           core.ReachabilityEntrypoint_RELATION_ENTRYPOINT,
			TargetRelation: rr,
			ResultStatus:   operationResultState,
		})
		if err != nil {
			return err
		}
	}

	return nil
}
