// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0

package atom

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
)

type Client struct {
	baseURL       string
	token         string
	adminUsername string
	adminSecret   string
	userAgent     string
	httpClient    *http.Client
}

func NewClient(cfg Config) *Client {
	timeout := cfg.Timeout
	if timeout == 0 {
		timeout = defaultTimeout
	}
	return &Client{
		baseURL:       strings.TrimRight(cfg.URL, "/"),
		token:         cfg.Token,
		adminUsername: cfg.AdminUsername,
		adminSecret:   cfg.AdminSecret,
		userAgent:     cfg.UserAgent,
		httpClient: &http.Client{
			Timeout: timeout,
		},
	}
}

func (c *Client) UpsertTenant(ctx context.Context, tenant Tenant) error {
	if tenant.ID == "" {
		_, err := c.CreateTenant(ctx, tenant)
		return err
	}
	if _, err := c.CreateTenant(ctx, tenant); err == nil || !IsConflict(err) {
		return err
	}
	_, err := c.UpdateTenant(ctx, tenant.ID, tenant)
	return err
}

func (c *Client) CreateTenant(ctx context.Context, tenant Tenant) (Tenant, error) {
	var out struct {
		CreateTenant Tenant `json:"createTenant"`
	}
	err := c.graphQL(ctx, `mutation CreateTenant($input: CreateTenantInput!) {
		createTenant(input: $input) { id name route: alias status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt }
	}`, map[string]any{atomInputKeyInput: tenantCreateInput(tenant)}, &out)
	return out.CreateTenant, err
}

func (c *Client) GetTenant(ctx context.Context, id string) (Tenant, error) {
	var out struct {
		Tenant Tenant `json:"tenant"`
	}
	err := c.graphQL(ctx, `query Tenant($id: ID!) {
		tenant(id: $id) { id name route: alias status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt }
	}`, map[string]any{"id": id}, &out)
	return out.Tenant, err
}

func (c *Client) UpdateTenant(ctx context.Context, id string, tenant Tenant) (Tenant, error) {
	var out struct {
		UpdateTenant Tenant `json:"updateTenant"`
	}
	err := c.graphQL(ctx, `mutation UpdateTenant($id: ID!, $input: UpdateTenantInput!) {
		updateTenant(id: $id, input: $input) { id name route: alias status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt }
	}`, map[string]any{"id": id, atomInputKeyInput: tenantUpdateInput(tenant)}, &out)
	return out.UpdateTenant, err
}

func (c *Client) ChangeTenantStatus(ctx context.Context, id, action string) (Tenant, error) {
	field := map[string]string{
		"enable":  "enableTenant",
		"disable": "disableTenant",
		"freeze":  "freezeTenant",
	}[action]
	if field == "" {
		return Tenant{}, Error{StatusCode: http.StatusBadRequest, Message: "unsupported tenant status action: " + action}
	}
	var out map[string]Tenant
	err := c.graphQL(ctx, fmt.Sprintf(`mutation ChangeTenantStatus($id: ID!) {
		%s(id: $id) { id name route: alias status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt }
	}`, field), map[string]any{"id": id}, &out)
	if err != nil {
		return Tenant{}, err
	}
	return out[field], nil
}

func (c *Client) UpsertEntity(ctx context.Context, entity Entity) error {
	if entity.ID == "" {
		_, err := c.CreateEntity(ctx, entity)
		return err
	}
	if _, err := c.CreateEntity(ctx, entity); err == nil || !IsConflict(err) {
		return err
	}
	_, err := c.UpdateEntity(ctx, entity.ID, entity)
	return err
}

func (c *Client) CreateEntity(ctx context.Context, entity Entity) (Entity, error) {
	var out struct {
		CreateEntity Entity `json:"createEntity"`
	}
	err := c.graphQL(ctx, `mutation CreateEntity($input: CreateEntityInput!) {
		createEntity(input: $input) { id kind name tenant_id: tenantId status attributes created_at: createdAt updated_at: updatedAt }
	}`, map[string]any{atomInputKeyInput: entityCreateInput(entity)}, &out)
	return out.CreateEntity, err
}

func (c *Client) GetEntity(ctx context.Context, id string) (Entity, error) {
	var out struct {
		Entity Entity `json:"entity"`
	}
	err := c.graphQL(ctx, `query Entity($id: ID!) {
		entity(id: $id) { id kind name tenant_id: tenantId status attributes created_at: createdAt updated_at: updatedAt }
	}`, map[string]any{"id": id}, &out)
	return out.Entity, err
}

func (c *Client) UpdateEntity(ctx context.Context, id string, entity Entity) (Entity, error) {
	var out struct {
		UpdateEntity Entity `json:"updateEntity"`
	}
	err := c.graphQL(ctx, `mutation UpdateEntity($id: ID!, $input: UpdateEntityInput!) {
		updateEntity(id: $id, input: $input) { id kind name tenant_id: tenantId status attributes created_at: createdAt updated_at: updatedAt }
	}`, map[string]any{"id": id, atomInputKeyInput: entityUpdateInput(entity)}, &out)
	return out.UpdateEntity, err
}

func (c *Client) UpsertGroup(ctx context.Context, group Group) error {
	if group.ID == "" {
		_, err := c.CreateGroup(ctx, group)
		return err
	}
	if _, err := c.CreateGroup(ctx, group); err == nil || !IsConflict(err) {
		return err
	}
	_, err := c.UpdateGroup(ctx, group.ID, group)
	return err
}

func (c *Client) CreateGroup(ctx context.Context, group Group) (Group, error) {
	var out struct {
		CreateGroup Group `json:"createGroup"`
	}
	err := c.graphQL(ctx, `mutation CreateGroup($input: CreateGroupInput!) {
		createGroup(input: $input) { id name tenant_id: tenantId description parent_id: parentId status attributes created_at: createdAt updated_at: updatedAt }
	}`, map[string]any{atomInputKeyInput: groupCreateInput(group)}, &out)
	return out.CreateGroup, err
}

func (c *Client) GetGroup(ctx context.Context, id string) (Group, error) {
	var out struct {
		Group Group `json:"group"`
	}
	err := c.graphQL(ctx, `query Group($id: ID!) {
		group(id: $id) { id name tenant_id: tenantId description parent_id: parentId status attributes created_at: createdAt updated_at: updatedAt }
	}`, map[string]any{"id": id}, &out)
	return out.Group, err
}

func (c *Client) UpdateGroup(ctx context.Context, id string, group Group) (Group, error) {
	var out struct {
		UpdateGroup Group `json:"updateGroup"`
	}
	err := c.graphQL(ctx, `mutation UpdateGroup($id: ID!, $input: UpdateGroupInput!) {
		updateGroup(id: $id, input: $input) { id name tenant_id: tenantId description parent_id: parentId status attributes created_at: createdAt updated_at: updatedAt }
	}`, map[string]any{"id": id, atomInputKeyInput: groupUpdateInput(group)}, &out)
	return out.UpdateGroup, err
}

func (c *Client) UpsertResource(ctx context.Context, resource Resource) error {
	if resource.ID == "" {
		_, err := c.CreateResource(ctx, resource)
		return err
	}
	if _, err := c.CreateResource(ctx, resource); err == nil || !IsConflict(err) {
		return err
	}
	_, err := c.UpdateResource(ctx, resource.ID, resource)
	return err
}

func (c *Client) CreateResource(ctx context.Context, resource Resource) (Resource, error) {
	var out struct {
		CreateResource Resource `json:"createResource"`
	}
	err := c.graphQL(ctx, `mutation CreateResource($input: CreateResourceInput!) {
		createResource(input: $input) { id kind name tenant_id: tenantId owner_id: ownerId attributes created_at: createdAt updated_at: updatedAt }
	}`, map[string]any{atomInputKeyInput: resourceCreateInput(resource)}, &out)
	return out.CreateResource, err
}

func (c *Client) GetResource(ctx context.Context, id string) (Resource, error) {
	var out struct {
		Resource Resource `json:"resource"`
	}
	err := c.graphQL(ctx, `query Resource($id: ID!) {
		resource(id: $id) { id kind name tenant_id: tenantId owner_id: ownerId attributes created_at: createdAt updated_at: updatedAt }
	}`, map[string]any{"id": id}, &out)
	return out.Resource, err
}

func (c *Client) UpdateResource(ctx context.Context, id string, resource Resource) (Resource, error) {
	var out struct {
		UpdateResource Resource `json:"updateResource"`
	}
	err := c.graphQL(ctx, `mutation UpdateResource($id: ID!, $input: UpdateResourceInput!) {
		updateResource(id: $id, input: $input) { id kind name tenant_id: tenantId owner_id: ownerId attributes created_at: createdAt updated_at: updatedAt }
	}`, map[string]any{"id": id, atomInputKeyInput: resourceUpdateInput(resource)}, &out)
	return out.UpdateResource, err
}

func (c *Client) DeleteTenant(ctx context.Context, id string) error {
	return c.graphQL(ctx, `mutation DeleteTenant($id: ID!) { deleteTenant(id: $id) }`, map[string]any{"id": id}, nil)
}

func (c *Client) ListTenants(ctx context.Context, q Query) (TenantList, error) {
	var out struct {
		Tenants TenantList `json:"tenants"`
	}
	err := c.graphQL(ctx, `query Tenants($q: String, $name: String, $alias: String, $status: TenantStatus, $limit: Int, $offset: Int) {
		tenants(q: $q, name: $name, alias: $alias, status: $status, limit: $limit, offset: $offset) {
			total
			items { id name route: alias status tags attributes created_by: createdBy updated_by: updatedBy created_at: createdAt updated_at: updatedAt }
		}
	}`, queryVariables(q), &out)
	return out.Tenants, err
}

func (c *Client) CheckAuthz(ctx context.Context, req AuthzRequest) (AuthzResponse, error) {
	var out struct {
		AuthzCheck AuthzResponse `json:"authzCheck"`
	}
	err := c.graphQL(ctx, `mutation AuthzCheck($input: AuthzCheckInput!) {
		authzCheck(input: $input) { allowed reason }
	}`, map[string]any{atomInputKeyInput: authzInput(req)}, &out)
	return out.AuthzCheck, err
}

func (c *Client) CheckAuthzWithToken(ctx context.Context, token string, req AuthzRequest) (AuthzResponse, error) {
	var out struct {
		AuthzCheck AuthzResponse `json:"authzCheck"`
	}
	err := c.graphQLWithToken(ctx, `mutation AuthzCheck($input: AuthzCheckInput!) {
		authzCheck(input: $input) { allowed reason }
	}`, map[string]any{atomInputKeyInput: authzInput(req)}, &out, token)
	return out.AuthzCheck, err
}

func (c *Client) ListCapabilities(ctx context.Context) (CapabilityList, error) {
	var out struct {
		Actions CapabilityList `json:"actions"`
	}
	err := c.graphQL(ctx, `query Actions($limit: Int!) {
		actions(limit: $limit) { total items { id name description } }
	}`, map[string]any{"limit": 100}, &out)
	return out.Actions, err
}

func (c *Client) CapabilityID(ctx context.Context, name string) (string, error) {
	list, err := c.ListCapabilities(ctx)
	if err != nil {
		return "", err
	}
	for _, capability := range list.Items {
		if capability.Name == name {
			return capability.ID, nil
		}
	}
	return "", Error{StatusCode: http.StatusNotFound, Message: "capability " + name + " not found"}
}

func (c *Client) CreateCapability(ctx context.Context, name, description string) (Capability, error) {
	var out struct {
		CreateAction Capability `json:"createAction"`
	}
	input := map[string]any{atomInputKeyName: name}
	setIfNotEmpty(input, "description", description)
	err := c.graphQL(ctx, `mutation CreateAction($input: CreateActionInput!) {
		createAction(input: $input) { id name description }
	}`, map[string]any{atomInputKeyInput: input}, &out)
	return out.CreateAction, err
}

func (c *Client) AddCapabilityApplicability(ctx context.Context, actionID, objectKind, objectType string) (CapabilityApplicability, error) {
	var out struct {
		AddActionApplicability CapabilityApplicability `json:"addActionApplicability"`
	}
	input := map[string]any{
		"actionId":             actionID,
		atomInputKeyObjectKind: objectKind,
	}
	setIfNotEmpty(input, "objectType", objectType)
	err := c.graphQL(ctx, `mutation AddActionApplicability($input: AddActionApplicabilityInput!) {
		addActionApplicability(input: $input) {
			action_id: actionId
			action_name: actionName
			description
			object_kind: objectKind
			object_type: objectType
		}
	}`, map[string]any{atomInputKeyInput: input}, &out)
	return out.AddActionApplicability, err
}

func (c *Client) ListActionAssignmentRules(ctx context.Context, spec ActionAssignmentRuleSpec) (ActionAssignmentRuleList, error) {
	var out struct {
		ActionAssignmentRules ActionAssignmentRuleList `json:"actionAssignmentRules"`
	}
	vars := map[string]any{"limit": 100, "offset": 0}
	setIfNotEmpty(vars, "tenantId", spec.TenantID)
	setIfNotEmpty(vars, "entityKind", spec.EntityKind)
	setIfNotEmpty(vars, "actionName", spec.ActionName)
	setIfNotEmpty(vars, atomInputKeyObjectKind, spec.ObjectKind)
	setIfNotEmpty(vars, "objectType", spec.ObjectType)
	setIfNotEmpty(vars, "decision", spec.Decision)
	err := c.graphQL(ctx, `query ActionAssignmentRules(
		$tenantId: ID,
		$entityKind: EntityKind,
		$actionName: String,
		$objectKind: String,
		$objectType: String,
		$decision: ActionAssignmentRuleDecision,
		$limit: Int!,
		$offset: Int!
	) {
		actionAssignmentRules(
			tenantId: $tenantId,
			entityKind: $entityKind,
			actionName: $actionName,
			objectKind: $objectKind,
			objectType: $objectType,
			decision: $decision,
			limit: $limit,
			offset: $offset
		) {
			total
			items {
				id
				tenant_id: tenantId
				entity_kind: entityKind
				action_name: actionName
				object_kind: objectKind
				object_type: objectType
				decision
				is_absolute: isAbsolute
				created_at: createdAt
			}
		}
	}`, vars, &out)
	return out.ActionAssignmentRules, err
}

func (c *Client) CreateActionAssignmentRule(ctx context.Context, spec ActionAssignmentRuleSpec) (ActionAssignmentRule, error) {
	var out struct {
		CreateActionAssignmentRule ActionAssignmentRule `json:"createActionAssignmentRule"`
	}
	input := map[string]any{
		"entityKind":           spec.EntityKind,
		"actionName":           spec.ActionName,
		atomInputKeyObjectKind: spec.ObjectKind,
		"decision":             spec.Decision,
		"isAbsolute":           spec.IsAbsolute,
	}
	setIfNotEmpty(input, "tenantId", spec.TenantID)
	setIfNotEmpty(input, "objectType", spec.ObjectType)
	err := c.graphQL(ctx, `mutation CreateActionAssignmentRule($input: CreateActionAssignmentRuleInput!) {
		createActionAssignmentRule(input: $input) {
			id
			tenant_id: tenantId
			entity_kind: entityKind
			action_name: actionName
			object_kind: objectKind
			object_type: objectType
			decision
			is_absolute: isAbsolute
			created_at: createdAt
		}
	}`, map[string]any{atomInputKeyInput: input}, &out)
	return out.CreateActionAssignmentRule, err
}

func (c *Client) CreatePermissionBlock(ctx context.Context, block CreatePermissionBlock) (PermissionBlock, error) {
	var out struct {
		CreatePermissionBlock PermissionBlock `json:"createPermissionBlock"`
	}
	err := c.graphQL(ctx, `mutation CreatePermissionBlock($input: CreatePermissionBlockInput!) {
		createPermissionBlock(input: $input) {
			id tenant_id: tenantId scope_mode: scopeMode object_kind: objectKind object_type: objectType object_id: objectId group_id: groupId effect conditions
			actions { id name description }
		}
	}`, map[string]any{atomInputKeyInput: permissionBlockInput(block)}, &out)
	return out.CreatePermissionBlock, err
}

func (c *Client) CreateDirectPolicy(ctx context.Context, policy CreateDirectPolicy) (DirectPolicy, error) {
	var out struct {
		CreateDirectPolicy DirectPolicy `json:"createDirectPolicy"`
	}
	err := c.graphQL(ctx, `mutation CreateDirectPolicy($input: CreateDirectPolicyInput!) {
		createDirectPolicy(input: $input) {
			id tenant_id: tenantId subject_kind: subjectKind subject_id: subjectId permission_block_id: permissionBlockId created_at: createdAt
			permission_block: permissionBlock {
				id tenant_id: tenantId scope_mode: scopeMode object_kind: objectKind object_type: objectType object_id: objectId group_id: groupId effect conditions
				actions { id name description }
			}
		}
	}`, map[string]any{atomInputKeyInput: directPolicyInput(policy)}, &out)
	return out.CreateDirectPolicy, err
}

func (c *Client) ListDirectPolicies(ctx context.Context, q DirectPolicyQuery) (DirectPolicyList, error) {
	var out struct {
		DirectPolicies DirectPolicyList `json:"directPolicies"`
	}
	err := c.graphQL(ctx, `query DirectPolicies($tenantId: ID, $subjectKind: SubjectKind, $subjectId: ID, $limit: Int, $offset: Int) {
		directPolicies(tenantId: $tenantId, subjectKind: $subjectKind, subjectId: $subjectId, limit: $limit, offset: $offset) {
			total
			items {
				id tenant_id: tenantId subject_kind: subjectKind subject_id: subjectId permission_block_id: permissionBlockId created_at: createdAt
				permission_block: permissionBlock {
					id tenant_id: tenantId scope_mode: scopeMode object_kind: objectKind object_type: objectType object_id: objectId group_id: groupId effect conditions
					actions { id name description }
				}
			}
		}
	}`, directPolicyQueryVariables(q), &out)
	return out.DirectPolicies, err
}

func (c *Client) DeleteDirectPolicy(ctx context.Context, id string) error {
	return c.graphQL(ctx, `mutation DeleteDirectPolicy($id: ID!) { deleteDirectPolicy(id: $id) }`, map[string]any{"id": id}, nil)
}

func (c *Client) AuthorizedObjectIDs(ctx context.Context, q AuthorizedObjectIDsQuery) (AuthorizedObjectIDs, error) {
	var out struct {
		AuthorizedObjectIDs AuthorizedObjectIDs `json:"authorizedObjectIds"`
	}
	err := c.graphQL(ctx, `query AuthorizedObjectIDs($input: AuthorizedObjectIdsInput!) {
		authorizedObjectIds(input: $input) {
			ids
			total
		}
	}`, authorizedObjectIDVariables(q), &out)
	return out.AuthorizedObjectIDs, err
}

func (c *Client) LoginPassword(ctx context.Context, identifier, secret string) (LoginResponse, error) {
	return c.LoginCredential(ctx, identifier, secret, "password")
}

func (c *Client) LoginSharedKey(ctx context.Context, identifier, secret string) (LoginResponse, error) {
	return c.LoginCredential(ctx, identifier, secret, "shared_key")
}

func (c *Client) LoginCredential(ctx context.Context, identifier, secret, kind string) (LoginResponse, error) {
	var out LoginResponse
	err := c.doWithToken(ctx, http.MethodPost, "/auth/login", LoginRequest{
		Identifier: identifier,
		Secret:     secret,
		Kind:       kind,
	}, &out, "")
	return out, err
}

func (c *Client) Introspect(ctx context.Context, token string) (IntrospectionResponse, error) {
	var out IntrospectionResponse
	err := c.doWithToken(ctx, http.MethodGet, atomAuthIntrospectPath, nil, &out, token)
	return out, err
}

func (c *Client) DeleteEntity(ctx context.Context, id string) error {
	return c.graphQL(ctx, `mutation DeleteEntity($id: ID!) { deleteEntity(id: $id) }`, map[string]any{"id": id}, nil)
}

func (c *Client) CreatePassword(ctx context.Context, entityID, password string) error {
	return c.graphQL(ctx, `mutation CreatePassword($entityId: ID!, $password: String!) {
		createPassword(entityId: $entityId, password: $password)
	}`, map[string]any{atomInputKeyEntityID: entityID, "password": password}, nil)
}

func (c *Client) CreateUnscopedAccessToken(ctx context.Context, entityID, name, description string) (AccessTokenResponse, error) {
	var out struct {
		CreateAccessToken AccessTokenResponse `json:"createAccessToken"`
	}
	err := c.graphQL(ctx, `mutation CreateAccessToken($input: CreateAccessTokenInput!) {
		createAccessToken(input: $input) {
			credentialId
			token
			name
			expiresAt
		}
	}`, map[string]any{
		atomInputKeyInput: map[string]any{
			atomInputKeyName:      name,
			"description":         description,
			atomInputKeySubjectID: entityID,
			"scoped":              false,
			"permissions":         []any{},
		},
	}, &out)
	return out.CreateAccessToken, err
}

func (c *Client) CreateSharedKey(ctx context.Context, entityID, key, description string) (SharedKeyResponse, error) {
	var out struct {
		CreateSharedKey SharedKeyResponse `json:"createSharedKey"`
	}
	input := map[string]any{}
	setIfNotEmpty(input, "key", key)
	setIfNotEmpty(input, "description", description)
	err := c.graphQL(ctx, `mutation CreateSharedKey($entityId: ID!, $input: CreateSharedKeyInput!) {
		createSharedKey(entityId: $entityId, input: $input) {
			credentialId
			key
			expiresAt
		}
	}`, map[string]any{
		atomInputKeyEntityID: entityID,
		atomInputKeyInput:    input,
	}, &out)
	return out.CreateSharedKey, err
}

func (c *Client) RevealSharedKey(ctx context.Context, entityID, credentialID string) (SharedKeyResponse, error) {
	var out struct {
		RevealSharedKey SharedKeyResponse `json:"revealSharedKey"`
	}
	err := c.graphQL(ctx, `mutation RevealSharedKey($entityId: ID!, $credentialId: ID!) {
		revealSharedKey(entityId: $entityId, credentialId: $credentialId) {
			credentialId
			key
			expiresAt
		}
	}`, map[string]any{
		atomInputKeyEntityID:     entityID,
		atomInputKeyCredentialID: credentialID,
	}, &out)
	return out.RevealSharedKey, err
}

func (c *Client) ListCredentials(ctx context.Context, entityID string) (CredentialList, error) {
	var out struct {
		Credentials CredentialList `json:"credentials"`
	}
	err := c.graphQL(ctx, `query Credentials($entityId: ID!) {
		credentials(entityId: $entityId) {
			total
			items {
				id
				entity_id: entityId
				kind
				identifier
				status
				expires_at: expiresAt
				created_at: createdAt
			}
		}
	}`, map[string]any{atomInputKeyEntityID: entityID}, &out)
	return out.Credentials, err
}

func (c *Client) RevokeCredential(ctx context.Context, entityID, credentialID string) error {
	return c.graphQL(ctx, `mutation RevokeCredential($entityId: ID!, $credentialId: ID!) {
		revokeCredential(entityId: $entityId, credentialId: $credentialId)
	}`, map[string]any{atomInputKeyEntityID: entityID, atomInputKeyCredentialID: credentialID}, nil)
}

func (c *Client) ListEntities(ctx context.Context, q Query) (EntityList, error) {
	var out struct {
		Entities EntityList `json:"entities"`
	}
	err := c.graphQL(ctx, `query Entities($q: String, $kind: EntityKind, $tenantId: ID, $status: EntityStatus, $limit: Int, $offset: Int) {
		entities(q: $q, kind: $kind, tenantId: $tenantId, status: $status, limit: $limit, offset: $offset) {
			total
			items { id kind name tenant_id: tenantId status attributes created_at: createdAt updated_at: updatedAt }
		}
	}`, objectQueryVariables(q), &out)
	return out.Entities, err
}

func (c *Client) DeleteGroup(ctx context.Context, id string) error {
	return c.graphQL(ctx, `mutation DeleteGroup($id: ID!) { deleteGroup(id: $id) }`, map[string]any{"id": id}, nil)
}

func (c *Client) ListGroups(ctx context.Context, q Query) (GroupList, error) {
	var out struct {
		Groups GroupList `json:"groups"`
	}
	err := c.graphQL(ctx, `query Groups($q: String, $tenantId: ID, $status: EntityStatus, $limit: Int, $offset: Int) {
		groups(q: $q, tenantId: $tenantId, status: $status, limit: $limit, offset: $offset) {
			total
			items { id name tenant_id: tenantId description parent_id: parentId status attributes created_at: createdAt updated_at: updatedAt }
		}
	}`, objectQueryVariables(q), &out)
	return out.Groups, err
}

func (c *Client) DeleteResource(ctx context.Context, id string) error {
	return c.graphQL(ctx, `mutation DeleteResource($id: ID!) { deleteResource(id: $id) }`, map[string]any{"id": id}, nil)
}

func (c *Client) ListResources(ctx context.Context, q Query) (ResourceList, error) {
	var out struct {
		Resources ResourceList `json:"resources"`
	}
	vars := objectQueryVariables(q)
	if q.Name != "" && q.Q == "" {
		vars["q"] = q.Name
	}
	err := c.graphQL(ctx, `query Resources($q: String, $kind: String, $tenantId: ID, $limit: Int, $offset: Int) {
		resources(q: $q, kind: $kind, tenantId: $tenantId, limit: $limit, offset: $offset) {
			total
			items { id kind name tenant_id: tenantId owner_id: ownerId attributes created_at: createdAt updated_at: updatedAt }
		}
	}`, vars, &out)
	return out.Resources, err
}

type graphQLRequest struct {
	Query     string         `json:"query"`
	Variables map[string]any `json:"variables,omitempty"`
}

type graphQLErrorItem struct {
	Message string `json:"message"`
}

type graphQLResponse struct {
	Data   json.RawMessage    `json:"data"`
	Errors []graphQLErrorItem `json:"errors,omitempty"`
}

func (c *Client) graphQL(ctx context.Context, query string, variables map[string]any, out any) error {
	var response graphQLResponse
	if err := c.do(ctx, http.MethodPost, "/graphql", graphQLRequest{Query: query, Variables: variables}, &response); err != nil {
		return err
	}
	return decodeGraphQLResponse(response, out)
}

func (c *Client) graphQLWithToken(ctx context.Context, query string, variables map[string]any, out any, token string) error {
	var response graphQLResponse
	if err := c.doWithToken(ctx, http.MethodPost, "/graphql", graphQLRequest{Query: query, Variables: variables}, &response, token); err != nil {
		return err
	}
	return decodeGraphQLResponse(response, out)
}

func decodeGraphQLResponse(response graphQLResponse, out any) error {
	if len(response.Errors) > 0 {
		return graphQLErr(response.Errors)
	}
	if out == nil {
		return nil
	}
	if len(response.Data) == 0 {
		return Error{StatusCode: http.StatusInternalServerError, Message: "atom GraphQL response did not include data"}
	}
	return json.Unmarshal(response.Data, out)
}

func graphQLErr(errors []graphQLErrorItem) error {
	messages := make([]string, 0, len(errors))
	for _, err := range errors {
		if err.Message != "" {
			messages = append(messages, err.Message)
		}
	}
	message := strings.Join(messages, "; ")
	lower := strings.ToLower(message)
	status := http.StatusBadRequest
	switch {
	case strings.Contains(lower, "duplicate") || strings.Contains(lower, "already exists") || strings.Contains(lower, "unique"):
		status = http.StatusConflict
	case strings.Contains(lower, "not found"):
		status = http.StatusNotFound
	case strings.Contains(lower, "unauthenticated") || strings.Contains(lower, "authentication"):
		status = http.StatusUnauthorized
	case strings.Contains(lower, "forbidden") || strings.Contains(lower, "authorization") || strings.Contains(lower, "access denied"):
		status = http.StatusForbidden
	}
	return Error{StatusCode: status, Message: message}
}

func tenantCreateInput(tenant Tenant) map[string]any {
	input := map[string]any{atomInputKeyName: tenant.Name}
	setIfNotEmpty(input, "id", tenant.ID)
	setIfNotEmpty(input, "alias", tenant.Route)
	if tenant.Tags != nil {
		input[atomAttributeTags] = tenant.Tags
	}
	if tenant.Attributes != nil {
		input["attributes"] = tenant.Attributes
	}
	return input
}

func tenantUpdateInput(tenant Tenant) map[string]any {
	input := map[string]any{}
	setIfNotEmpty(input, atomInputKeyName, tenant.Name)
	setIfNotEmpty(input, "alias", tenant.Route)
	if tenant.Tags != nil {
		input[atomAttributeTags] = tenant.Tags
	}
	if tenant.Attributes != nil {
		input["attributes"] = tenant.Attributes
	}
	return input
}

func entityCreateInput(entity Entity) map[string]any {
	input := map[string]any{atomInputKeyName: entity.Name}
	setIfNotEmpty(input, "id", entity.ID)
	setIfNotEmpty(input, atomInputKeyKind, entity.Kind)
	setIfNotEmpty(input, "tenantId", entity.TenantID)
	if entity.Attributes != nil {
		input["attributes"] = entity.Attributes
	} else {
		input["attributes"] = map[string]any{}
	}
	return input
}

func entityUpdateInput(entity Entity) map[string]any {
	input := map[string]any{}
	setIfNotEmpty(input, atomInputKeyName, entity.Name)
	setIfNotEmpty(input, atomAttributeStatus, entity.Status)
	if entity.Attributes != nil {
		input["attributes"] = entity.Attributes
	}
	return input
}

func groupCreateInput(group Group) map[string]any {
	input := map[string]any{atomInputKeyName: group.Name}
	setIfNotEmpty(input, "id", group.ID)
	setIfNotEmpty(input, "tenantId", group.TenantID)
	setIfNotEmpty(input, "description", group.Description)
	if group.Attributes != nil {
		input["attributes"] = group.Attributes
	}
	return input
}

func groupUpdateInput(group Group) map[string]any {
	input := map[string]any{}
	setIfNotEmpty(input, atomInputKeyName, group.Name)
	setIfNotEmpty(input, "description", group.Description)
	setIfNotEmpty(input, atomAttributeStatus, group.Status)
	if group.Attributes != nil {
		input["attributes"] = group.Attributes
	}
	return input
}

func resourceCreateInput(resource Resource) map[string]any {
	input := map[string]any{atomInputKeyKind: resource.Kind}
	setIfNotEmpty(input, "id", resource.ID)
	setIfNotEmpty(input, atomInputKeyName, resource.Name)
	setIfNotEmpty(input, "tenantId", resource.TenantID)
	setIfNotEmpty(input, "ownerId", resource.OwnerID)
	if resource.Attributes != nil {
		input["attributes"] = resource.Attributes
	}
	return input
}

func resourceUpdateInput(resource Resource) map[string]any {
	input := map[string]any{}
	setIfNotEmpty(input, atomInputKeyName, resource.Name)
	if resource.Attributes != nil {
		input["attributes"] = resource.Attributes
	}
	return input
}

func authzInput(req AuthzRequest) map[string]any {
	input := map[string]any{
		atomInputKeySubjectID: req.SubjectID,
		atomInputKeyAction:    req.Action,
	}
	setIfNotEmpty(input, "resourceId", req.ResourceID)
	setIfNotEmpty(input, atomInputKeyObjectKind, req.ObjectKind)
	setIfNotEmpty(input, "objectId", req.ObjectID)
	if req.Context != nil {
		input["context"] = req.Context
	}
	return input
}

func permissionBlockInput(block CreatePermissionBlock) map[string]any {
	input := map[string]any{
		"scopeMode": block.ScopeMode,
		"actionIds": block.ActionIDs,
	}
	setIfNotEmpty(input, "tenantId", block.TenantID)
	setIfNotEmpty(input, atomInputKeyObjectKind, block.ObjectKind)
	setIfNotEmpty(input, "objectType", block.ObjectType)
	setIfNotEmpty(input, "objectId", block.ObjectID)
	setIfNotEmpty(input, "groupId", block.GroupID)
	setIfNotEmpty(input, "effect", block.Effect)
	if block.Conditions != nil {
		input["conditions"] = block.Conditions
	}
	return input
}

func directPolicyInput(policy CreateDirectPolicy) map[string]any {
	input := map[string]any{
		"subjectKind":         policy.SubjectKind,
		atomInputKeySubjectID: policy.SubjectID,
		"permissionBlockId":   policy.PermissionBlockID,
	}
	setIfNotEmpty(input, "tenantId", policy.TenantID)
	return input
}

func directPolicyQueryVariables(q DirectPolicyQuery) map[string]any {
	vars := map[string]any{}
	setIfNotEmpty(vars, "tenantId", q.TenantID)
	setIfNotEmpty(vars, "subjectKind", q.SubjectKind)
	setIfNotEmpty(vars, atomInputKeySubjectID, q.SubjectID)
	if q.Limit > 0 {
		vars["limit"] = int(q.Limit)
	}
	if q.Offset > 0 {
		vars["offset"] = int(q.Offset)
	}
	return vars
}

func authorizedObjectIDVariables(q AuthorizedObjectIDsQuery) map[string]any {
	input := map[string]any{
		atomInputKeySubjectID:  q.SubjectID,
		atomInputKeyAction:     q.Action,
		atomInputKeyObjectKind: q.ObjectKind,
	}
	setIfNotEmpty(input, "objectType", q.ObjectType)
	setIfNotEmpty(input, "tenantId", q.TenantID)
	setIfNotEmpty(input, "q", q.Q)
	if q.Limit > 0 {
		input["limit"] = int(q.Limit)
	}
	if q.Offset > 0 {
		input["offset"] = int(q.Offset)
	}
	return map[string]any{atomInputKeyInput: input}
}

func queryVariables(q Query) map[string]any {
	vars := map[string]any{}
	setIfNotEmpty(vars, "q", q.Q)
	setIfNotEmpty(vars, atomInputKeyName, q.Name)
	setIfNotEmpty(vars, "alias", q.Route)
	setIfNotEmpty(vars, atomInputKeyKind, q.Kind)
	setIfNotEmpty(vars, "tenantId", q.TenantID)
	setIfNotEmpty(vars, atomAttributeStatus, q.Status)
	if q.Limit > 0 {
		vars["limit"] = int(q.Limit)
	}
	if q.Offset > 0 {
		vars["offset"] = int(q.Offset)
	}
	return vars
}

func objectQueryVariables(q Query) map[string]any {
	vars := map[string]any{}
	setIfNotEmpty(vars, "q", q.Q)
	setIfNotEmpty(vars, atomInputKeyKind, q.Kind)
	setIfNotEmpty(vars, "tenantId", q.TenantID)
	setIfNotEmpty(vars, atomAttributeStatus, q.Status)
	if q.Limit > 0 {
		vars["limit"] = int(q.Limit)
	}
	if q.Offset > 0 {
		vars["offset"] = int(q.Offset)
	}
	return vars
}

func setIfNotEmpty(values map[string]any, key, value string) {
	if value != "" {
		values[key] = value
	}
}

func (c *Client) do(ctx context.Context, method, path string, in, out any) error {
	token := c.token
	if token == "" && c.adminSecret != "" {
		adminToken, err := c.loginAdmin(ctx)
		if err != nil {
			return err
		}
		token = adminToken
	}
	return c.doWithToken(ctx, method, path, in, out, token)
}

func (c *Client) loginAdmin(ctx context.Context) (string, error) {
	username := c.adminUsername
	if username == "" {
		username = defaultAdminUsername
	}
	resp, err := c.LoginPassword(ctx, username, c.adminSecret)
	if err != nil {
		return "", err
	}
	return resp.Token, nil
}

func (c *Client) doWithToken(ctx context.Context, method, path string, in, out any, token string) error {
	if c.baseURL == "" {
		return Error{StatusCode: 0, Message: "atom URL is empty"}
	}

	var body io.Reader
	if in != nil {
		data, err := json.Marshal(in)
		if err != nil {
			return err
		}
		body = bytes.NewReader(data)
	}

	req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
	if err != nil {
		return err
	}
	if in != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	req.Header.Set("Accept", "application/json")
	if c.userAgent != "" {
		req.Header.Set("User-Agent", c.userAgent)
	}
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
		msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		return Error{StatusCode: resp.StatusCode, Message: strings.TrimSpace(string(msg))}
	}
	if out == nil || resp.StatusCode == http.StatusNoContent {
		return nil
	}
	return json.NewDecoder(resp.Body).Decode(out)
}

type Error struct {
	StatusCode int
	Message    string
}

func (e Error) Error() string {
	if e.Message == "" {
		return fmt.Sprintf("atom request failed with status %d", e.StatusCode)
	}
	return fmt.Sprintf("atom request failed with status %d: %s", e.StatusCode, e.Message)
}

func IsConflict(err error) bool {
	ae, ok := err.(Error)
	return ok && ae.StatusCode == http.StatusConflict
}

func IsNotFound(err error) bool {
	ae, ok := err.(Error)
	return ok && ae.StatusCode == http.StatusNotFound
}
