/* eslint-disable max-lines */

import { GraphQLError } from 'graphql';
import { type JsonObject, type JsonValue } from 'type-fest';

import {
  parseStoredParameters,
  validateActionParameterValues,
} from '../../services/moderationConfigService/index.js';
import { filterDecisionsToFailedSubmissions } from '../../services/ncmecService/index.js';
import { UserPermission } from '../../services/userManagementService/index.js';
import { __throw } from '../../utils/misc.js';
import { isValidUrl } from '../../utils/url.js';
import {
  type GQLIntegrationConfig,
  type GQLMatchingBanksResolvers,
  type GQLMutationResolvers,
  type GQLOrgResolvers,
  type GQLPendingInvite,
  type GQLQueryResolvers,
} from '../generated.js';
import { type Context } from '../resolvers.js';
import {
  forbiddenError,
  unauthenticatedError,
  userInputError,
} from '../utils/errors.js';
import { gqlSuccessResult } from '../utils/gqlResult.js';

const typeDefs = /* GraphQL */ `
  type Org {
    id: ID!
    name: String!
    email: String!
    websiteUrl: String!
    onCallAlertEmail: String
    users: [User!]!
    pendingInvites: [PendingInvite!]!
    rules: [Rule!]!
    routingRules: [RoutingRule!]!
    appealsRoutingRules: [RoutingRule!]!
    reportingRules: [ReportingRule!]!
    signals(customOnly: Boolean = false): [Signal!]!
    # Deprecated field. Actually returns all item types, whether they're
    # content item types or not, but in this legacy ContentType shape.
    contentTypes: [ContentType!]!
    itemTypes: [ItemType!]!
    actions: [Action!]!
    banks: MatchingBanks
    policies: [Policy!]!
    mrtQueues: [ManualReviewQueue!]!
    apiKey: String!
    publicSigningKey: String!
    integrationConfigs: [IntegrationConfig!]!
    hasReportingRulesEnabled: Boolean!
    hasNCMECReportingEnabled: Boolean!
    """
    How much media a reviewer must classify before they can send an NCMEC
    report for this org. Readable by any org member (not just MANAGE_ORG) so the
    NCMEC review UI can enforce the policy. Defaults to ALL when unset.
    """
    ncmecMediaReviewRequirement: NcmecMediaReviewRequirement!
    """
    Minimum number of media items that must be reviewed before sending an NCMEC
    report when ncmecMediaReviewRequirement is MINIMUM. Defaults to 1.
    """
    ncmecMinMediaToReview: Int!
    hasAppealsEnabled: Boolean!
    ncmecReports: [NCMECReport!]!
    """
    NCMEC decisions that did not produce a successful CyberTip report. Returned
    alongside ncmecReports on the dashboard so reviewers can see the full
    submission status (successful + failed) in one place and retry failures.
    """
    failedNcmecSubmissions: [NcmecFailedSubmission!]!
    requiresPolicyForDecisionsInMrt: Boolean!
    requiresDecisionReasonInMrt: Boolean!
    requiresDecisionReasonOnIgnoreInMrt: Boolean!
    previewJobsViewEnabled: Boolean!
    allowMultiplePoliciesPerAction: Boolean!
    hideSkipButtonForNonAdmins: Boolean!
    usersWhoCanReviewEveryQueue: [User!]!
    defaultInterfacePreferences: UserInterfacePreferences!
    userStrikeThresholds: [UserStrikeThreshold!]!
    userStrikeTTL: Int!
    isDemoOrg: Boolean!
    samlEnabled: Boolean!
    ssoUrl: String
    ssoCert: String
    ignoreCallbackUrl: String
    hasPartialItemsEndpoint: Boolean!
    partialItemsEndpoint: String
    partialItemsRequestHeaders: JSONObject
  }

  input AppealSettingsInput {
    appealsCallbackUrl: String
    appealsCallbackHeaders: JSONObject
    appealsCallbackBody: JSONObject
  }

  type AppealSettings {
    appealsCallbackUrl: String
    appealsCallbackHeaders: JSONObject
    appealsCallbackBody: JSONObject
  }

  type UserStrikeThreshold {
    id: String!
    threshold: Int!
    actions: [ID!]!
    # Per-action configured parameter values: actionId -> name -> value.
    actionParameters: JSONObject!
  }

  input SetUserStrikeThresholdInput {
    threshold: Int!
    actions: [String!]!
    actionParameters: JSONObject
  }

  input SetAllUserStrikeThresholdsInput {
    thresholds: [SetUserStrikeThresholdInput!]!
  }

  input UpdateUserStrikeTTLInput {
    ttlDays: Int!
  }

  type Query {
    org(id: ID!): Org
    appealSettings: AppealSettings
  }

  type SetAllUserStrikeThresholdsSuccessResponse {
    _: Boolean
  }

  type UpdateUserStrikeTTLSuccessResponse {
    _: Boolean
  }

  input UpdateSSOCredentialsInput {
    ssoUrl: String!
    ssoCert: String!
  }

  input UpdateOrgInfoInput {
    name: String
    email: String
    websiteUrl: String
    onCallAlertEmail: String
  }

  type UpdateOrgInfoSuccessResponse {
    _: Boolean
  }

  input UpdatePartialItemsSettingsInput {
    partialItemsEndpoint: String
    partialItemsRequestHeaders: JSONObject
  }

  type Mutation {
    updateAppealSettings(input: AppealSettingsInput!): AppealSettings!
    setAllUserStrikeThresholds(
      input: SetAllUserStrikeThresholdsInput!
    ): SetAllUserStrikeThresholdsSuccessResponse!
    updateUserStrikeTTL(
      input: UpdateUserStrikeTTLInput!
    ): UpdateUserStrikeTTLSuccessResponse!
    setOrgDefaultSafetySettings(
      orgDefaultSafetySettings: ModeratorSafetySettingsInput!
    ): SetModeratorSafetySettingsSuccessResponse
    updateSSOCredentials(input: UpdateSSOCredentialsInput!): Boolean!
    updateOrgInfo(input: UpdateOrgInfoInput!): UpdateOrgInfoSuccessResponse!
    updateHasAppealsEnabled(enabled: Boolean!): Boolean!
    updateHasReportingRulesEnabled(enabled: Boolean!): Boolean!
    updateAllowMultiplePoliciesPerAction(enabled: Boolean!): Boolean!
    updateSamlEnabled(enabled: Boolean!): Boolean!
    updateRequiresPolicyForDecisions(enabled: Boolean!): Boolean!
    updateRequiresDecisionReason(enabled: Boolean!): Boolean!
    updateRequiresDecisionReasonOnIgnore(enabled: Boolean!): Boolean!
    updateHideSkipButtonForNonAdmins(enabled: Boolean!): Boolean!
    updatePreviewJobsViewEnabled(enabled: Boolean!): Boolean!
    updateIgnoreCallbackUrl(url: String): Boolean!
    updatePartialItemsSettings(
      input: UpdatePartialItemsSettingsInput!
    ): Boolean!
  }
`;

const Query: GQLQueryResolvers = {
  async org(_, { id }, context) {
    const user = context.getUser();
    if (user == null || user.orgId !== id) {
      throw unauthenticatedError('Authenticated user required');
    }

    return context.dataSources.orgAPI.getGraphQLOrgFromId(id);
  },
  async appealSettings(_, __, context) {
    const user = context.getUser();
    if (user == null || !user.orgId) {
      throw unauthenticatedError('Authenticated user required');
    }
    const settings =
      await context.services.OrgSettingsService.getAppealSettings(user.orgId);
    return {
      appealsCallbackUrl: settings.appealCallbackUrl ?? null,
      appealsCallbackHeaders: settings.appealCallbackHeaders ?? null,
      appealsCallbackBody: settings.appealCallbackBody ?? null,
    };
  },
};

// Narrowed to only the context fields this resolver actually uses, so tests
// can build a minimal mock without casting. `Context` (the full resolver
// context) is structurally assignable to this, so production usage is unchanged.
type ResolveOrgActionsContext = {
  getUser: () => { orgId: string } | null | undefined;
  services: {
    ModerationConfigService: Pick<
      Context['services']['ModerationConfigService'],
      'getActions'
    >;
    NcmecService: Pick<
      Context['services']['NcmecService'],
      'hasNCMECReportingEnabled'
    >;
  };
};

export async function resolveOrgActions(
  org: { id: string },
  _: unknown,
  context: ResolveOrgActionsContext,
) {
  const user = context.getUser();
  if (!user || user.orgId !== org.id) {
    throw unauthenticatedError('User required.');
  }
  const [actions, hasNcmecEnabled] = await Promise.all([
    context.services.ModerationConfigService.getActions({
      orgId: org.id,
      readFromReplica: true,
    }),
    context.services.NcmecService.hasNCMECReportingEnabled(org.id),
  ]);
  return hasNcmecEnabled
    ? actions
    : actions.filter((it) => it.actionType !== 'ENQUEUE_TO_NCMEC');
}

// Validates each threshold's configured parameter values against the actions'
// specs, dropping values for actions no longer attached. Throws (surfaced to
// the admin) on any invalid value.
async function validateUserStrikeThresholdActionParameters(
  context: Context,
  orgId: string,
  thresholds: readonly {
    threshold: number;
    actions: readonly string[];
    actionParameters?: JsonObject | null;
  }[],
): Promise<
  { threshold: number; actions: string[]; actionParameters: JsonObject }[]
> {
  const allActionIds = [...new Set(thresholds.flatMap((t) => [...t.actions]))];
  const actions =
    allActionIds.length > 0
      ? await context.services.ModerationConfigService.getActions({
          orgId,
          ids: allActionIds,
        })
      : [];
  const specByActionId = new Map(
    actions.map((a) => [
      a.id,
      parseStoredParameters(
        a.actionType === 'CUSTOM_ACTION' ? a.customMrtApiParams : null,
      ),
    ]),
  );

  return thresholds.map((t) => {
    const raw: JsonObject = t.actionParameters ?? {};
    const validated: JsonObject = {};
    // Iterate the attached actions (not just keys in `raw`) so required-value
    // checks run even for actions the client omitted from `actionParameters`.
    for (const actionId of t.actions) {
      const spec = specByActionId.get(actionId) ?? [];
      const rawValues = Object.prototype.hasOwnProperty.call(raw, actionId)
        ? raw[actionId]
        : undefined;
      const values = validateActionParameterValues(spec, rawValues);
      if (Object.keys(values).length > 0) {
        // Validated values are JSON by construction (the validator only emits
        // coerced primitives/arrays).
        validated[actionId] = values as JsonValue;
      }
    }
    return {
      threshold: t.threshold,
      actions: [...t.actions],
      actionParameters: validated,
    };
  });
}

const Org: GQLOrgResolvers = {
  actions: resolveOrgActions,
  async contentTypes(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    return context.dataSources.orgAPI.getContentTypesForOrg(org.id);
  },
  async itemTypes(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    return context.services.ModerationConfigService.getItemTypes({
      orgId: org.id,
    });
  },
  async users(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    return context.dataSources.orgAPI.getOrgUsersForGraphQL(org.id);
  },
  async pendingInvites(org, _, context): Promise<GQLPendingInvite[]> {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }

    if (!user.getPermissions().includes(UserPermission.MANAGE_USERS)) {
      throw forbiddenError(
        'User does not have permission to view pending invites',
      );
    }

    const invites =
      await context.services.UserManagementService.getPendingInvites(org.id);
    return invites as GQLPendingInvite[];
  },
  async rules(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    return context.dataSources.ruleAPI.getGraphQLRulesForOrg(org.id);
  },
  async routingRules(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required');
    }

    return context.services.ManualReviewToolService.getRoutingRules({
      orgId: org.id,
      directives: {
        maxAge: 0,
      },
    });
  },
  async appealsRoutingRules(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required');
    }

    return context.services.ManualReviewToolService.getAppealsRoutingRules({
      orgId: org.id,
      directives: {
        maxAge: 0,
      },
    });
  },
  async reportingRules(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required');
    }

    return context.services.ReportingService.getReportingRules({
      orgId: org.id,
      directives: {
        maxAge: 0,
      },
    });
  },
  async mrtQueues(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required');
    }
    return context.services.ManualReviewToolService.getAllQueuesForOrgAndDangerouslyBypassPermissioning(
      {
        orgId: user.orgId,
      },
    );
  },
  async apiKey(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to view the org API key',
      );
    }
    const apiKey = await context.dataSources.orgAPI.getActivatedApiKeyForOrg(
      org.id,
    );

    // API Keys are required in prod, but no reason to throw outside prod (like
    // on engineers' local machines)
    if (!apiKey) {
      return process.env.NODE_ENV !== 'production'
        ? ''
        : __throw(new GraphQLError('API Key not found'));
    }

    return apiKey.key;
  },
  async integrationConfigs(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to view integration configs',
      );
    }

    return context.dataSources.integrationAPI.getAllIntegrationConfigs(
      org.id,
    ) as Promise<GQLIntegrationConfig[]>;
  },
  // customOnly param fetches only the org's custom signals
  async signals(org, { customOnly }, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }

    return customOnly
      ? []
      : context.services.SignalsService.getSignalsForOrg({ orgId: org.id });
  },
  async userStrikeThresholds(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }

    return context.services.ModerationConfigService.getUserStrikeThresholdsForOrg(
      user.orgId,
    );
  },
  async policies(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }

    return context.services.ModerationConfigService.getPolicies({
      orgId: user.orgId,
      readFromReplica: true,
    });
  },
  async banks(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    return org;
  },
  async hasReportingRulesEnabled(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    return context.services.OrgSettingsService.hasReportingRulesEnabled(org.id);
  },
  async hasAppealsEnabled(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    return context.services.OrgSettingsService.hasAppealsEnabled(org.id);
  },
  async userStrikeTTL(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    return context.services.OrgSettingsService.userStrikeTTLInDays(org.id);
  },
  async publicSigningKey(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to view the webhook signing key',
      );
    }
    return context.dataSources.orgAPI.getPublicSigningKeyPem(org.id);
  },
  async hasNCMECReportingEnabled(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    return context.services.NcmecService.hasNCMECReportingEnabled(org.id);
  },
  async ncmecMediaReviewRequirement(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    const settings = await context.services.NcmecService.getNcmecOrgSettings(
      org.id,
    );
    return settings?.mediaReviewRequirement === 'MINIMUM' ? 'MINIMUM' : 'ALL';
  },
  async ncmecMinMediaToReview(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    const settings = await context.services.NcmecService.getNcmecOrgSettings(
      org.id,
    );
    return settings?.minMediaToReview ?? 1;
  },
  async ncmecReports(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    const reports = await context.services.NcmecService.getNcmecReports({
      orgId: user.orgId,
      reviewerId: user.id,
    });
    return Promise.all(
      reports.map(async (report) => {
        const itemType =
          await context.services.ModerationConfigService.getItemType({
            orgId: user.orgId,
            itemTypeSelector: { id: report.userItemTypeId },
          });

        // The only way the item type would not exist is if the item type had been
        // deleted between the time the report was enqueued and the time the
        // report is viewed in the NCMEC view.
        if (!itemType || itemType.kind !== 'USER') {
          throw Error('NCMEC user item type is not of kind USER');
        }

        return {
          ...report,
          additionalFiles: report.additionalFiles ?? [],
          userItemType: itemType,
          reportedMessages: report.reportedMessages ?? [],
        };
      }),
    );
  },
  async failedNcmecSubmissions(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    if (
      !user.getPermissions().includes(UserPermission.VIEW_CHILD_SAFETY_DATA)
    ) {
      throw forbiddenError(
        'VIEW_CHILD_SAFETY_DATA permission required to view NCMEC submissions.',
      );
    }

    // Pull all SUBMIT_NCMEC_REPORT decisions for the org, then subtract those
    // that already produced a successful report. Whatever remains is "failed"
    // (either retryable, permanently failed, or pending background retry).
    const [decisions, successfulKeys] = await Promise.all([
      context.services.ManualReviewToolService.getNcmecDecisionsForOrg({
        orgId: user.orgId,
      }),
      context.services.NcmecService.getSuccessfulSubmissionKeysForOrg(
        user.orgId,
      ),
    ]);

    const decisionsWithKey = decisions.map((d) => ({
      decision: d,
      userId: d.job_payload.payload.item.itemId,
      userItemTypeId: d.job_payload.payload.item.itemTypeIdentifier.id,
    }));
    const failedDecisions = filterDecisionsToFailedSubmissions(
      decisionsWithKey,
      successfulKeys,
    );

    if (failedDecisions.length === 0) {
      return [];
    }

    const jobIds = failedDecisions.map(
      ({ decision }) => decision.job_payload.id,
    );
    const [errorRows, allItemTypes] = await Promise.all([
      context.services.NcmecService.getNcmecErrorsForJobIds(jobIds),
      context.services.ModerationConfigService.getItemTypes({
        orgId: user.orgId,
      }),
    ]);
    const errorByJobId = new Map(errorRows.map((e) => [e.job_id, e]));
    const itemTypeById = new Map(allItemTypes.map((it) => [it.id, it]));

    return failedDecisions.map(({ decision: d, userId, userItemTypeId }) => {
      const itemType = itemTypeById.get(userItemTypeId);
      if (!itemType || itemType.kind !== 'USER') {
        throw new Error('NCMEC user item type is not of kind USER');
      }
      const errorRow = errorByJobId.get(d.job_payload.id);
      // Map the DB string through a known-set check before returning so a
      // future migration adding a new status value can't take down the
      // entire `failedNcmecSubmissions` field via GraphQL enum validation.
      const status: 'RETRYABLE_ERROR' | 'PERMANENT_ERROR' | 'NEVER_ATTEMPTED' =
        errorRow?.status === 'RETRYABLE_ERROR' ||
        errorRow?.status === 'PERMANENT_ERROR'
          ? errorRow.status
          : 'NEVER_ATTEMPTED';
      return {
        decisionId: d.id,
        ts: d.created_at,
        reviewerId: d.reviewer_id ?? null,
        userId,
        userItemType: itemType,
        status,
        retryCount: errorRow?.retry_count ?? 0,
        lastError: errorRow?.last_error ?? null,
      };
    });
  },
  async requiresPolicyForDecisionsInMrt(org, _, context) {
    return context.services.ManualReviewToolService.getRequiresPolicyForDecisions(
      org.id,
    );
  },
  async requiresDecisionReasonInMrt(org, _, context) {
    return context.services.ManualReviewToolService.getRequiresDecisionReason(
      org.id,
    );
  },
  async requiresDecisionReasonOnIgnoreInMrt(org, _, context) {
    return context.services.ManualReviewToolService.getRequiresDecisionReasonOnIgnore(
      org.id,
    );
  },
  async previewJobsViewEnabled(org, _, context) {
    return context.services.ManualReviewToolService.getPreviewJobsViewEnabled(
      org.id,
    );
  },
  async allowMultiplePoliciesPerAction(org, _, context) {
    return context.services.OrgSettingsService.allowMultiplePoliciesPerAction(
      org.id,
    );
  },
  async hideSkipButtonForNonAdmins(org, _, context) {
    return context.services.ManualReviewToolService.getHideSkipButtonForNonAdmins(
      org.id,
    );
  },
  async usersWhoCanReviewEveryQueue(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    const users = await context.dataSources.orgAPI.getOrgUsersForGraphQL(
      org.id,
    );
    return users.filter((u) =>
      u.getPermissions().includes(UserPermission.EDIT_MRT_QUEUES),
    );
  },
  async defaultInterfacePreferences(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('Authenticated user required');
    }
    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to view org safety settings',
      );
    }
    const orgDefaults =
      await context.services.UserManagementService.getOrgDefaultUserInterfaceSettings(
        org.id,
      );
    return {
      ...orgDefaults,
      // Right now, we don't allow orgs to set custom MRT chart configurations.
      // These would prepopulate every user's custom MRT dashboard with charts
      // set by the org's admin. We can always add that ability later, but we're
      // leaving this empty for now.
      mrtChartConfigurations: [],
    };
  },
  async isDemoOrg(org, _, context) {
    return context.services.OrgSettingsService.isDemoOrg(org.id);
  },
  async samlEnabled(org, _, context) {
    const user = context.getUser();
    if (user == null || user.orgId !== org.id) {
      throw unauthenticatedError('Authenticated user required');
    }

    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to view SSO settings',
      );
    }

    const settings = await context.services.OrgSettingsService.getSamlSettings(
      org.id,
    );
    return settings?.saml_enabled ?? false;
  },
  async ignoreCallbackUrl(org, _, context) {
    const user = context.getUser();
    if (user == null || user.orgId !== org.id) {
      throw unauthenticatedError('Authenticated user required');
    }

    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to view ignore callback settings',
      );
    }

    return context.services.ManualReviewToolService.getIgnoreCallbackUrl(
      org.id,
    );
  },
  async ssoUrl(org, _, context) {
    const user = context.getUser();
    if (user == null || user.orgId !== org.id) {
      throw unauthenticatedError('Authenticated user required');
    }

    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to manage SSO settings',
      );
    }

    const settings = await context.services.OrgSettingsService.getSamlSettings(
      org.id,
    );

    if (!settings) {
      return null;
    }

    return settings.sso_url;
  },
  async ssoCert(org, _, context) {
    const user = context.getUser();
    if (user == null || user.orgId !== org.id) {
      throw unauthenticatedError('Authenticated user required');
    }

    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to manage SSO settings',
      );
    }

    const settings = await context.services.OrgSettingsService.getSamlSettings(
      org.id,
    );

    if (!settings) {
      return null;
    }

    return settings.cert;
  },
  async hasPartialItemsEndpoint(org, _, context) {
    const partialItemsInfo =
      await context.services.OrgSettingsService.partialItemsInfo(org.id);
    const partialItemsEndpoint = partialItemsInfo?.partialItemsEndpoint;

    return partialItemsEndpoint != null;
  },
  async partialItemsEndpoint(org, _, context) {
    const user = context.getUser();
    if (user == null || user.orgId !== org.id) {
      throw unauthenticatedError('Authenticated user required');
    }
    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to view partial items settings',
      );
    }
    const partialItemsInfo =
      await context.services.OrgSettingsService.partialItemsInfo(org.id);
    return partialItemsInfo?.partialItemsEndpoint ?? null;
  },
  async partialItemsRequestHeaders(org, _, context) {
    const user = context.getUser();
    if (user == null || user.orgId !== org.id) {
      throw unauthenticatedError('Authenticated user required');
    }
    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to view partial items settings',
      );
    }
    const partialItemsInfo =
      await context.services.OrgSettingsService.partialItemsInfo(org.id);
    // The cache returns a deeply-readonly value; the resolver only serializes
    // it, so casting to the mutable JSONObject type the schema expects is safe.
    return (
      (partialItemsInfo?.partialItemsRequestHeaders as JsonObject | null) ??
      null
    );
  },
};

const MatchingBanks: GQLMatchingBanksResolvers = {
  async textBanks(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    return context.services.ModerationConfigService.getTextBanks({
      orgId: org.id,
    });
  },
  async locationBanks(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    return context.dataSources.locationBankAPI.getGraphQLLocationBanksForOrg(
      org.id,
    );
  },
  async hashBanks(org, _, context) {
    const user = context.getUser();
    if (!user || user.orgId !== org.id) {
      throw unauthenticatedError('User required.');
    }
    return context.services.HMAHashBankService.listBanks(org.id);
  },
};

const Mutation: GQLMutationResolvers = {
  async updateAppealSettings(_, { input }, context) {
    const user = context.getUser();
    if (!user || !user.orgId) {
      throw unauthenticatedError('User required.');
    }
    const settings =
      await context.services.OrgSettingsService.updateAppealSettings({
        orgId: user.orgId,
        callbackUrl: input.appealsCallbackUrl ?? null,
        callbackHeaders: input.appealsCallbackHeaders ?? null,
        callbackBody: input.appealsCallbackBody ?? null,
      });
    return settings ?? {};
  },

  async setOrgDefaultSafetySettings(_, params, context) {
    const user = context.getUser();
    if (!user) {
      throw unauthenticatedError('User required.');
    }
    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to update org safety settings',
      );
    }
    await context.services.UserManagementService.upsertOrgDefaultUserInterfaceSettings(
      {
        orgId: user.orgId,
        ...params.orgDefaultSafetySettings,
      },
    );
    return gqlSuccessResult({}, 'SetModeratorSafetySettingsSuccessResponse');
  },

  async setAllUserStrikeThresholds(_, params, context) {
    const user = context.getUser();
    if (!user) {
      throw unauthenticatedError('User required.');
    }

    const thresholds = await validateUserStrikeThresholdActionParameters(
      context,
      user.orgId,
      params.input.thresholds,
    );

    await context.services.ModerationConfigService.setAllUserStrikeThresholds({
      orgId: user.orgId,
      thresholds,
    });
    return gqlSuccessResult({}, 'SetAllUserStrikeThresholdsSuccessResponse');
  },

  async updateUserStrikeTTL(_, { input }, context) {
    const user = context.getUser();
    if (!user) {
      throw unauthenticatedError('User required.');
    }
    await context.services.OrgSettingsService.updateUserStrikeTTL({
      orgId: user.orgId,
      ttlDays: input.ttlDays,
    });
    return gqlSuccessResult({}, 'UpdateUserStrikeTTLSuccessResponse');
  },
  async updateSSOCredentials(_, { input }, context) {
    const user = context.getUser();
    if (!user) {
      throw unauthenticatedError('User required.');
    }

    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to manage SSO settings',
      );
    }

    return context.services.OrgSettingsService.updateSamlSettings({
      orgId: user.orgId,
      ssoUrl: input.ssoUrl,
      cert: input.ssoCert,
    });
  },
  async updateOrgInfo(_, { input }, context) {
    const user = context.getUser();
    if (!user) {
      throw unauthenticatedError('User required.');
    }

    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError('User does not have permission to manage org info');
    }

    await context.dataSources.orgAPI.updateOrgInfo(user.orgId, input);

    return gqlSuccessResult({}, 'UpdateOrgInfoSuccessResponse');
  },
  async updateHasAppealsEnabled(_, { enabled }, context) {
    const user = context.getUser();
    if (!user) {
      throw unauthenticatedError('User required.');
    }
    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to update org settings',
      );
    }
    await context.services.OrgSettingsService.updateHasAppealsEnabled({
      orgId: user.orgId,
      enabled,
    });
    return true;
  },
  async updateHasReportingRulesEnabled(_, { enabled }, context) {
    const user = context.getUser();
    if (!user) {
      throw unauthenticatedError('User required.');
    }
    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to update org settings',
      );
    }
    await context.services.OrgSettingsService.updateHasReportingRulesEnabled({
      orgId: user.orgId,
      enabled,
    });
    return true;
  },
  async updateAllowMultiplePoliciesPerAction(_, { enabled }, context) {
    const user = context.getUser();
    if (!user) {
      throw unauthenticatedError('User required.');
    }
    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to update org settings',
      );
    }
    await context.services.OrgSettingsService.updateAllowMultiplePoliciesPerAction(
      {
        orgId: user.orgId,
        enabled,
      },
    );
    return true;
  },
  async updateSamlEnabled(_, { enabled }, context) {
    const user = context.getUser();
    if (!user) {
      throw unauthenticatedError('User required.');
    }
    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to manage SSO settings',
      );
    }
    if (enabled) {
      const samlSettings =
        await context.services.OrgSettingsService.getSamlSettings(user.orgId);
      if (!samlSettings?.sso_url || !samlSettings.cert) {
        throw userInputError(
          'Cannot enable SAML SSO without configuring SSO URL and certificate first',
        );
      }
    }
    await context.services.OrgSettingsService.updateSamlEnabled({
      orgId: user.orgId,
      enabled,
    });
    return true;
  },
  async updateRequiresPolicyForDecisions(_, { enabled }, context) {
    const user = context.getUser();
    if (!user) {
      throw unauthenticatedError('User required.');
    }
    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to update org settings',
      );
    }
    await context.services.ManualReviewToolService.updateRequiresPolicyForDecisions(
      user.orgId,
      enabled,
    );
    return true;
  },
  async updateRequiresDecisionReason(_, { enabled }, context) {
    const user = context.getUser();
    if (!user) {
      throw unauthenticatedError('User required.');
    }
    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to update org settings',
      );
    }
    await context.services.ManualReviewToolService.updateRequiresDecisionReason(
      user.orgId,
      enabled,
    );
    return true;
  },
  async updateRequiresDecisionReasonOnIgnore(_, { enabled }, context) {
    const user = context.getUser();
    if (!user) {
      throw unauthenticatedError('User required.');
    }
    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to update org settings',
      );
    }
    await context.services.ManualReviewToolService.updateRequiresDecisionReasonOnIgnore(
      user.orgId,
      enabled,
    );
    return true;
  },
  async updateHideSkipButtonForNonAdmins(_, { enabled }, context) {
    const user = context.getUser();
    if (!user) {
      throw unauthenticatedError('User required.');
    }
    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to update org settings',
      );
    }
    await context.services.ManualReviewToolService.updateHideSkipButtonForNonAdmins(
      user.orgId,
      enabled,
    );
    return true;
  },
  async updatePreviewJobsViewEnabled(_, { enabled }, context) {
    const user = context.getUser();
    if (!user) {
      throw unauthenticatedError('User required.');
    }
    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to update org settings',
      );
    }
    await context.services.ManualReviewToolService.updatePreviewJobsViewEnabled(
      user.orgId,
      enabled,
    );
    return true;
  },
  async updateIgnoreCallbackUrl(_, { url }, context) {
    const user = context.getUser();
    if (!user) {
      throw unauthenticatedError('User required.');
    }
    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to update org settings',
      );
    }
    const ignoreCallbackUrl = url || null;
    if (ignoreCallbackUrl != null && !isValidUrl(ignoreCallbackUrl)) {
      throw userInputError('Ignore callback URL must be a valid http(s) URL');
    }
    await context.services.ManualReviewToolService.updateIgnoreCallbackUrl(
      user.orgId,
      ignoreCallbackUrl,
    );
    return true;
  },
  async updatePartialItemsSettings(_, { input }, context) {
    const user = context.getUser();
    if (!user) {
      throw unauthenticatedError('User required.');
    }
    if (!user.getPermissions().includes(UserPermission.MANAGE_ORG)) {
      throw forbiddenError(
        'User does not have permission to update org settings',
      );
    }
    const endpoint = input.partialItemsEndpoint || null;
    if (endpoint != null && !isValidUrl(endpoint)) {
      throw userInputError(
        'Partial items endpoint must be a valid http(s) URL',
      );
    }
    await context.services.OrgSettingsService.updatePartialItemsSettings({
      orgId: user.orgId,
      endpoint,
      requestHeaders: input.partialItemsRequestHeaders ?? null,
    });
    return true;
  },
};

const resolvers = {
  Query,
  Mutation,
  Org,
  MatchingBanks,
};

export { typeDefs, resolvers };
