/* eslint-disable max-lines */
import {
  getScalarType,
  ScalarTypes,
  type ScalarType,
  type TaggedScalar,
} from '@roostorg/coop-types';
import { type ReadonlyDeep } from 'type-fest';

import {
  getUserFromRuleInput,
  isFullSubmission,
  type RuleEvaluationContext,
  type RuleInput,
} from '../rule_engine/RuleEvaluator.js';
import { evaluateAggregationRuntimeArgsForItem } from '../services/aggregationsService/index.js';
import {
  getFieldValueForRole,
  getFieldValueOrValues,
  getValuesFromFields,
  type ItemSubmission,
} from '../services/itemProcessingService/index.js';
import {
  ConditionCompletionOutcome,
  ConditionFailureOutcome,
  CoopInput,
  isTaggedItemData,
  ValueComparator,
  type ConditionInput,
  type ConditionResult,
  type ConditionSignalInfo,
  type LeafCondition,
  type TaggedItemData,
} from '../services/moderationConfigService/index.js';
import {
  isSignalErrorResult,
  type SignalOutputType,
  type SignalResult,
} from '../services/signalsService/index.js';
import { jsonParse } from '../utils/encoding.js';
import { CoopError, isCoopError } from '../utils/errors.js';
import { assertUnreachable, hasOwn } from '../utils/misc.js';
import { outcomeToNullableBool } from './condition.js';

function outcomeFromBool(bool: boolean) {
  return bool
    ? ConditionCompletionOutcome.PASSED
    : ConditionCompletionOutcome.FAILED;
}

/**
 * Takes a LeafCondition, plus optional content to check it against, and
 * "evaluates" it, to return a promise for a ConditionOutcome.
 *
 * Evaluating a LeafCondition has a few steps, which this function tries to make
 * as clear as possible. They are:
 *
 * 1. A LeafCondition usually doesn't look at every field in the content. So,
 *    the first step is to extract the content values we'll need to analyze.
 *    For this step, you can think of each LeafCondition's definition object as
 *    containing a "selector spec", which, based on the Condition's `input` key.
 *    The data is usually a specific field's value, but could also be a special
 *    CoopInput value, which extracts values from multiple fields based on
 *    their type (and, in the case of ProtectedInput.ALL_TEXT, combines them).
 *
 *    The key point here is that multiple values can get selected at this step,
 *    e.g., because the targeted field holds an array of values, and/or because
 *    the CoopInput picks out values from multiple fields.
 *
 *    NB: sometimes, a LeafCondition doesn't look at the content at all -- e.g.,
 *    in the case of user rules, there is no content submission -- but we still
 *    have a step wherein we extract values (likely, the user id from the
 *    evaluation context) to use in the steps that follow.
 *
 * 2. After the values to analyze are extracted, the second step is to run each
 *    of these values through the condition's signal. A good way to think of a
 *    signal is as an arbitrary transformation function from a content-derived
 *    value to some other value. For example, the input could be text and the
 *    output could be a number representing the confidence that that text is
 *    hate speech. Right now, our signals always take single scalar values as
 *    input, and return scalar values, but that's sort of arbitrary and might
 *    change. If a condition doesn't specify a signal, you can just think of it
 *    as specifying a no-op/identity transformation signal.
 *
 *    NB: the details of how to actually run a signal are totally abstracted
 *    from this function, which just receives a function as an argument that can
 *    run arbitrary signals. That allows that passed-in function to handle any
 *    details around caching signal results, and makes unit testing a breeze.
 *
 * 3. Next, for each signal result (called a `score`), we can try to determine
 *    what outcome it would imply for the overall LeafCondition if it were the
 *    only score. To do that, we use standard, signal-agnostic logic to compare
 *    the signal's result to a `threshold`, using a `comparator`, both of which
 *    are defined in the condition. The point of this step is to turn the
 *    signal's result into a passed/failed boolean, using some expression.
 *    For now, that expression is implicitly defined as "the result of comparing
 *    signal's score to condition's threshold with comparator". Conceptually,
 *    though, any boolean-producing expression would do here, though it's a
 *    little hard to imagine what other kinds of expressions might be useful
 *    here (at least while each signal is only producing a scalar, and there's
 *    no way to compare multiple signal results in this expression).
 *
 * 4. From all of the "candidate condition outcomes" generated by the step
 *    above, we coalesce them so that the condition passed iff any of the
 *    signal-derived outcomes were passing. See code for how/why. Then, we
 *    return that result (with a bit of backcompat reformatting).
 *
 * @param condition The condition to evaluate
 * @param content The content to check the condition against.
 * @param evaluationContext A context tied to this rule execution, which
 *   supports running the condition's signals etc.
 */
export async function runLeafCondition(
  condition: ReadonlyDeep<LeafCondition>,
  evaluationContext: RuleEvaluationContext,
): Promise<ConditionResult> {
  const { input: conditionInput, signal } = condition;
  const { input: ruleInput } = evaluationContext;

  // Figure out what data we're going to extract to pass to the signal.
  const selectedValueOrValues = await (conditionInput.type !==
  'CONTENT_DERIVED_FIELD'
    ? getSignalInputValueOrValues(conditionInput, ruleInput)
    : evaluationContext.getDerivedFieldValue(conditionInput.spec));

  // We got an error computing the value for a derived field.
  if (isCoopError(selectedValueOrValues)) {
    return condition.comparator === ValueComparator.IS_UNAVAILABLE
      ? { outcome: ConditionCompletionOutcome.PASSED }
      : {
          outcome: ConditionFailureOutcome.ERRORED,
          error: selectedValueOrValues,
        };
  }

  // If the comparator is whether a value is provided or not, we can return
  // early without actually 'evaluating' anything other than whether the value
  // exists or not.
  if (condition.comparator === ValueComparator.IS_NOT_PROVIDED) {
    return selectedValueOrValues === undefined
      ? { outcome: ConditionCompletionOutcome.PASSED }
      : { outcome: ConditionCompletionOutcome.FAILED };
  }

  // If user tried to run a condition on part of the content, but there was no
  // content submission or a relevant field was missing, mark inapplicable.
  // This prevents us from trying to run a signal on an invalid input value.
  if (selectedValueOrValues === undefined) {
    return { outcome: ConditionCompletionOutcome.INAPPLICABLE };
  }

  // Sometimes, we'll have extracted an array of values (e.g., all videos);
  // sometimes, just one. So, we normalize to an array for simplicity below,
  // where we'll run the signal and check the condition for each value extracted
  // here, and returned ConditionCompletionOutcome.PASSED if _any_ passed.
  const signalInputValues: (TaggedScalar<ScalarType> | TaggedItemData)[] =
    Array.isArray(selectedValueOrValues)
      ? selectedValueOrValues
      : [selectedValueOrValues];

  // Now, transform the extracted content values by running them through the
  // signal. If the signal is null, we just treat it as the identity function
  // and leave the extracted values as-is. (A null/identity signal often happens
  // when we're dealing booleans and numbers, as there's often no meaning that
  // the signal needs to extract from a single bool or number that can't
  // be captured just by comparing the bool/number against a static threshold).
  const signalOutputValues = signal
    ? await Promise.all(
        signalInputValues.map(async (value) => {
          return evaluationContext.runSignal({
            signal: jsonParse(signal.id),
            subcategory: condition.signal?.subcategory ?? undefined,
            value,
            matchingValues: condition.matchingValues ?? undefined,
            contentId: isFullSubmission(ruleInput)
              ? ruleInput.itemId
              : undefined,
            userId: isFullSubmission(ruleInput)
              ? ruleInput.creator?.id
              : undefined,
            orgId: evaluationContext.org.id,
            contextId: isFullSubmission(ruleInput)
              ? ruleInput.data['spectrum-context-id']
                ? String(ruleInput.data['spectrum-context-id'])
                : ruleInput.data['spectrum_context_id']
                  ? String(ruleInput.data['spectrum_context_id'])
                  : ruleInput.itemType.kind === 'CONTENT'
                    ? getFieldValueForRole(
                        ruleInput.itemType.schema,
                        ruleInput.itemType.schemaFieldRoles,
                        'threadId',
                        ruleInput.data,
                      )?.id
                    : undefined
              : undefined,
            contentType: isFullSubmission(ruleInput)
              ? ruleInput.itemType.name
              : undefined,
            args: signal.args,
            runtimeArgs: isFullSubmission(ruleInput)
              ? await getSignalRuntimeArgs(evaluationContext, ruleInput, signal)
              : undefined,
          });
        }),
      )
    : signalInputValues.map((it): SignalResult<SignalOutputType> => {
        // If the condition specified no signal, then we should act as though
        // the "identity" signal was used; i.e., the value that the condition
        // picked out (with `condition.input`) should be returned as-is.
        // However, if that value was a full content object, not a scalar, we
        // don't support that yet.
        if (isTaggedItemData(it)) {
          throw new Error(
            'Conditions that use the full content object with no signal ' +
              'are not yet supported',
          );
        }
        return { score: it.value, outputType: { scalarType: it.type } };
      });

  const signalResultsWithOutcomes = signalOutputValues.map((it) => {
    // The signal could've returned an error or a result, both of which would be
    // in the misnamed `it.score`). Handle errors first.

    // Here, it.score is either an error, or its not something Comparable
    // (whether or not a threshold was given). In the latter case, we have no
    // way to convert the result to a ConditionOutcome, so it's also an error.
    // Note that every user-facing signal's return value so far (as of
    // 2022-07-01) is a Comparable; only signals used for 'chaining' in the
    // middle of a condition -- like transcribing a video -- will return scalars
    // that aren't comparable with the built-in comparators (or, at least they
    // might eventually). So, this throws for those, as they're not supposed to
    // be used as a condition's main signal.
    if (isSignalErrorResult(it) || !isComparable(it.score)) {
      return {
        outcome:
          condition.comparator === ValueComparator.IS_UNAVAILABLE &&
          isCoopError(it.score)
            ? ConditionCompletionOutcome.PASSED
            : ConditionFailureOutcome.ERRORED,
        matchedValue: undefined,
        score: undefined,
        error: it.score instanceof CoopError ? it.score : undefined,
      };
    }

    // If the condition returned a result (which we know is comparable), we need
    // to finally convert it to a ConditionOutcome. We can usually do that with
    // a comparison against the threshold, but, some legacy conditions (for
    // which the signal previously returned a ConditionOutcome directly) don't
    // have a threshold specified. So, for those, we assume the result is a bool
    // and treat true as passed and false as failed.
    return {
      score: it.score,
      matchedValue: it.matchedValue,
      outcome: outcomeFromBool(
        condition.comparator && condition.threshold != null
          ? evaluateCondition(
              it.score,
              condition.threshold,
              condition.comparator,
              it.outputType,
            )
          : (it as SignalResult<{ scalarType: ScalarTypes['BOOLEAN'] }>).score,
      ),
    };
  });

  // Process the results on an "any pass" assumption, like [].some().
  // i.e., if there's a single result that passes, it's gonna return a result
  // with a passing outcome; and otherwise a result with a failing outcome (or
  // inapplicable). This makes sense for our use case, because the rule is gonna
  // have a condition like: "if [a field with a bunch of images] scores higher
  // than X on some model, return true", and that should mean "if _any_ of the
  // images in the field score higher than X on the model, return PASSED, and
  // return FAILED if none of them score higher than X (using INAPPLICABLE as a
  // more descriptive stand-in for when there are no images in the field).
  if (signalResultsWithOutcomes.length === 0) {
    return {
      outcome: ConditionCompletionOutcome.INAPPLICABLE,
      signalInputValues,
    };
  }

  // If we have results, prefer to return the first passing condition or, if
  // all failed, then the first failure with some descriptive info (e.g.
  // matchedValue or score).
  const bestResult =
    signalResultsWithOutcomes.find(
      (result) =>
        outcomeToNullableBool(result.outcome) === true ||
        result.matchedValue != null ||
        result.score != null,
    ) ?? signalResultsWithOutcomes[0];

  // convert the best signalResultWithOutcome to our ConditionResult.
  // We write it like this, which is a tad awkward, to help TS narrow properly.
  return bestResult.score !== undefined
    ? {
        ...bestResult,
        score: String(bestResult.score),
        signalInputValues,
      }
    : { ...bestResult, signalInputValues };
}

/**
 * Computes and returns the data that a condition (through its ConditionInput)
 * is requesting be passed to the condition's signal. If it returns an array,
 * the signal should be called for each item. If it returns undefined, the field
 * that the condition indicated could not be found in the ruleInput.
 *
 * @param conditionInput The condition's ConditionInput, describing how to find
 *   the data to feed to the signal.
 * @param ruleInput The rule's input, from which we'll extract some input to
 *   pass to the signal for this condition.
 * @returns
 */
export async function getSignalInputValueOrValues(
  conditionInput: Exclude<ConditionInput, { type: 'CONTENT_DERIVED_FIELD' }>,
  ruleInput: RuleInput,
): Promise<
  | TaggedScalar<ScalarType>
  | TaggedScalar<ScalarType>[]
  | TaggedItemData
  | undefined
> {
  switch (conditionInput.type) {
    case 'USER_ID': {
      const user = getUserFromRuleInput(ruleInput);
      return user
        ? {
            type: ScalarTypes.USER_ID,
            value: { id: user.id, typeId: user.typeId },
          }
        : undefined;
    }
    case 'CONTENT_FIELD':
    case 'FULL_ITEM':
      return isFullSubmission(ruleInput)
        ? extractContentValueOrValues(ruleInput, conditionInput)
        : undefined;
    case 'CONTENT_COOP_INPUT': {
      if (conditionInput.name === CoopInput.POLICY_ID) {
        const policyIds =
          'policyIds' in ruleInput ? ruleInput.policyIds : undefined;

        // NB: we convert the empty array of policy ids in the ruleInput to
        // `undefined` so that it can be tested with the IS_NOT_PROVIDED
        // comparator. This is a bit of a hack; for other ConditionInputs (e.g.,
        // one pointing to an item field that holds an ARRAY), selecting an
        // empty array from the RuleInput is not the same as getting nothing.
        return policyIds && policyIds.length > 0
          ? policyIds.map((value) => ({ type: ScalarTypes.POLICY_ID, value }))
          : undefined;
      }
      if (conditionInput.name === CoopInput.SOURCE) {
        return 'sourceType' in ruleInput && ruleInput.sourceType !== undefined
          ? { type: ScalarTypes.STRING, value: ruleInput.sourceType }
          : undefined;
      }
      return isFullSubmission(ruleInput)
        ? extractContentValueOrValues(ruleInput, conditionInput)
        : undefined;
    }
    default:
      assertUnreachable(conditionInput);
  }
}

/**
 * When running a condition, the first step is to extract the relevant values to
 * analyze from the overall ItemSubmission. This function does that, on the
 * basis of an `inputSpecifier` and the submission.
 *
 * It does not handle extracting the evaluation context's user id (as that will
 * usually be a user-rule user, rather than anything that's part of the
 * submission) or computing derived content fields, as that requires signals.
 */
export function extractContentValueOrValues(
  submission: ItemSubmission,
  inputSpecifier: Exclude<
    ConditionInput,
    { type: 'USER_ID' } | { type: 'CONTENT_DERIVED_FIELD' }
  >,
):
  | TaggedScalar<ScalarType>
  | TaggedScalar<ScalarType>[]
  | TaggedItemData
  | undefined {
  const { data, itemType } = submission;
  const { schema: schemaFields, id: itemTypeId } = itemType;

  switch (inputSpecifier.type) {
    case 'FULL_ITEM':
      const { contentTypeIds } = inputSpecifier;
      return !contentTypeIds || contentTypeIds.includes(itemTypeId)
        ? { data, itemType }
        : undefined;

    case 'CONTENT_FIELD':
      const matchingField =
        inputSpecifier.contentTypeId === itemTypeId &&
        schemaFields.find((it) => it.name === inputSpecifier.name);

      // Sometimes, a condition might reference a field that doesn't exist on
      // the content object or whose type isn't given in the schema. In that
      // case, we can't extract any values. The main case where this happens is
      // if a user defines a rule to run against multiple different content
      // types, but the rule contains conditions that act on fields that only
      // exist on a subset of those types. (E.g. if the rule runs on posts and
      // comments, and the condition targets a field that only exists on posts,
      // then the condition should just be skipped when the rule is run on a
      // comment.) But it could also happen if a condition (which is validated
      // at creation time against the content type's latest schema) is run
      // in a backtest against an old version of the schema that's missing that
      // field (or vice-versa: condition is created earlier, and then the field
      // is removed on new content objects).
      return !matchingField || !hasOwn(data, inputSpecifier.name)
        ? undefined
        : getFieldValueOrValues(data, matchingField);

    case 'CONTENT_COOP_INPUT': {
      const coopInput = inputSpecifier.name;
      switch (coopInput) {
        case CoopInput.AUTHOR_USER:
          return submission.creator
            ? {
                type: ScalarTypes.USER_ID,
                value: {
                  id: submission.creator.id,
                  typeId: submission.creator.typeId,
                },
              }
            : undefined;

        case CoopInput.ALL_TEXT: {
          const textFields = schemaFields.filter(
            (it) => getScalarType(it) === ScalarTypes.STRING,
          );
          return !textFields.length
            ? undefined
            : {
                type: ScalarTypes.STRING,
                value: getValuesFromFields(data, textFields)
                  .map((it) => it.value)
                  .join(' '),
              };
        }

        case CoopInput.ANY_IMAGE:
          return getValuesFromFields(
            data,
            schemaFields.filter(
              (it) => getScalarType(it) === ScalarTypes.IMAGE,
            ),
          );

        case CoopInput.ANY_GEOHASH:
          return getValuesFromFields(
            data,
            schemaFields.filter(
              (it) => getScalarType(it) === ScalarTypes.GEOHASH,
            ),
          );

        case CoopInput.ANY_VIDEO:
          return getValuesFromFields(
            data,
            schemaFields.filter(
              (it) => getScalarType(it) === ScalarTypes.VIDEO,
            ),
          );

        case CoopInput.POLICY_ID:
        case CoopInput.SOURCE:
          // Policy IDs are extracted from RuleInput, not the ItemSubmission
          return undefined;
        default:
          assertUnreachable(coopInput);
      }
    }

    default:
      assertUnreachable(inputSpecifier);
  }
}

/**
 * This compares the output value from a signal against the condition's
 * threshold. Conditions that check whether the input was not provided can't
 * have a signal (and so this doesn't apply to them).
 */
function evaluateCondition<T extends Comparable>(
  signalOutputValue: T,
  conditionThresholdValue: T,
  comparator: Exclude<ValueComparator, 'IS_NOT_PROVIDED'>,
  outputType: SignalOutputType,
): boolean {
  // TODO: make a symbol-based protocol so that values of any type can implement
  // these comparators, rather than us having to coerce types here. Think
  // through the desired semantics more of type mismatches in signal results and
  // conditionValues. The semantics below mirror what we had previously.
  if (typeof signalOutputValue !== typeof conditionThresholdValue) {
    const castFn = typeof signalOutputValue === 'boolean' ? Boolean : Number;
    return evaluateCondition<Comparable>(
      signalOutputValue,
      castFn(conditionThresholdValue),
      comparator,
      outputType,
    );
  }

  // If we're comparing two string signal outputs that have some
  // fixed ordering, then we translate the strings to their
  // corresponding indices in the ordered array.
  if (
    outputType.scalarType === ScalarTypes.STRING &&
    'enum' in outputType &&
    outputType.ordered
  ) {
    signalOutputValue = outputType.enum.indexOf(
      signalOutputValue as string,
    ) as T;
    conditionThresholdValue = outputType.enum.indexOf(
      conditionThresholdValue as string,
    ) as T;
  }

  switch (comparator) {
    case ValueComparator.EQUALS:
      return signalOutputValue === conditionThresholdValue;
    case ValueComparator.NOT_EQUAL_TO:
      return signalOutputValue !== conditionThresholdValue;
    case ValueComparator.GREATER_THAN:
      return signalOutputValue > conditionThresholdValue;
    case ValueComparator.LESS_THAN:
      return signalOutputValue < conditionThresholdValue;
    case ValueComparator.GREATER_THAN_OR_EQUALS:
      return signalOutputValue >= conditionThresholdValue;
    case ValueComparator.LESS_THAN_OR_EQUALS:
      return signalOutputValue <= conditionThresholdValue;
    case ValueComparator.IS_UNAVAILABLE:
      return false;
    default:
      return assertUnreachable(comparator);
  }
}

async function getSignalRuntimeArgs(
  evaluationContext: RuleEvaluationContext,
  itemSubmission: ItemSubmission,
  conditionSignalInfo: ReadonlyDeep<ConditionSignalInfo>,
) {
  if (conditionSignalInfo.type === 'AGGREGATION') {
    const args = conditionSignalInfo.args;
    if (args != null) {
      return evaluateAggregationRuntimeArgsForItem(
        evaluationContext,
        itemSubmission,
        args.aggregationClause,
      );
    }
  }
  return undefined;
}

export type Comparable = number | boolean | string;
export function isComparable(v: unknown): v is Comparable {
  return (
    typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'
  );
}
