import type { SchemaVersionMapper as SchemaVersion } from '../module.graphql.mappers';
import DataLoader from 'dataloader';
import { parse, print } from 'graphql';
import { Inject, Injectable, Scope } from 'graphql-modules';
import lodash from 'lodash';
import { z } from 'zod';
import { trace, traceFn } from '@hive/service-common';
import type {
  ConditionalBreakingChangeMetadata,
  SchemaChangeType,
  SchemaCheck,
  SchemaCompositionError,
} from '@hive/storage';
import { sortSDL } from '@theguild/federation-composition';
import { SchemaChecksFilter } from '../../../__generated__/types';
import * as GraphQLSchema from '../../../__generated__/types';
import {
  DateRange,
  NativeFederationCompatibilityStatusType,
  Organization,
  Project,
  ProjectType,
  Target,
  User,
} from '../../../shared/entities';
import { HiveError } from '../../../shared/errors';
import { atomic, cache, stringifySelector } from '../../../shared/helpers';
import { isUUID } from '../../../shared/is-uuid';
import { parseGraphQLSource } from '../../../shared/schema';
import { Session } from '../../auth/lib/authz';
import { GitHubIntegrationManager } from '../../integrations/providers/github-integration-manager';
import { ProjectManager } from '../../project/providers/project-manager';
import { CryptoProvider } from '../../shared/providers/crypto';
import { IdTranslator } from '../../shared/providers/id-translator';
import { Logger } from '../../shared/providers/logger';
import {
  OrganizationSelector,
  ProjectSelector,
  Storage,
  TargetSelector,
} from '../../shared/providers/storage';
import { TargetManager } from '../../target/providers/target-manager';
import { BreakingSchemaChangeUsageHelper } from './breaking-schema-changes-helper';
import { SCHEMA_MODULE_CONFIG, type SchemaModuleConfig } from './config';
import { Contracts } from './contracts';
import type { SchemaCoordinatesDiffResult } from './inspector';
import { CompositionOrchestrator } from './orchestrator/composition-orchestrator';
import { ensureCompositeSchemas, removeDescriptions, SchemaHelper } from './schema-helper';

const ENABLE_EXTERNAL_COMPOSITION_SCHEMA = z.object({
  endpoint: z.string().url().nonempty(),
  secret: z.string().nonempty(),
});

const externalSchemaCompositionTestSdl = /* GraphQL */ `
  type Query {
    test: String
  }
`;
const externalSchemaCompositionTestDocument = parse(externalSchemaCompositionTestSdl);

/**
 * Responsible for auth checks.
 * Talks to Storage.
 */
@Injectable({
  scope: Scope.Operation,
  global: true,
})
export class SchemaManager {
  private logger: Logger;
  private latestSchemaVersionLoader: DataLoader<TargetSelector, SchemaVersion>;

  constructor(
    logger: Logger,
    private session: Session,
    private storage: Storage,
    private projectManager: ProjectManager,
    private compositionOrchestrator: CompositionOrchestrator,
    private crypto: CryptoProvider,
    private githubIntegrationManager: GitHubIntegrationManager,
    private targetManager: TargetManager,
    private schemaHelper: SchemaHelper,
    private contracts: Contracts,
    private breakingSchemaChangeUsageHelper: BreakingSchemaChangeUsageHelper,
    private idTranslator: IdTranslator,
    @Inject(SCHEMA_MODULE_CONFIG) private schemaModuleConfig: SchemaModuleConfig,
  ) {
    this.logger = logger.child({ source: 'SchemaManager' });
    this.latestSchemaVersionLoader = new DataLoader(
      selectors => {
        return Promise.all(
          selectors.map(async selector => {
            return {
              ...(await this.storage.getLatestValidVersion(selector)),
              projectId: selector.projectId,
              targetId: selector.targetId,
              organizationId: selector.organizationId,
            };
          }),
        );
      },
      {
        cacheKeyFn(selector) {
          return selector.targetId;
        },
      },
    );
  }

  async hasSchema(target: Target) {
    this.logger.debug('Checking if schema is available (targetId=%s)', target.id);
    return this.storage.hasSchema({
      organizationId: target.orgId,
      projectId: target.projectId,
      targetId: target.id,
    });
  }

  @traceFn('SchemaManager.compose', {
    initAttributes: input => ({
      'hive.organization.slug': input.target?.bySelector?.organizationSlug,
      'hive.project.slug': input.target?.bySelector?.projectSlug,
      'hive.target.slug': input.target?.bySelector?.targetSlug,
      'hive.target.id': input.target?.byId ?? undefined,
      'input.only.composable': input.onlyComposable,
      'input.services.count': input.services.length,
    }),
  })
  async compose(input: {
    onlyComposable: boolean;
    services: ReadonlyArray<{
      sdl: string;
      url?: string | null;
      name: string;
    }>;
    target: GraphQLSchema.TargetReferenceInput | null;
  }) {
    this.logger.debug('Composing schemas (input=%o)', lodash.omit(input, 'services'));

    const selector = await this.idTranslator.resolveTargetReference({
      reference: input.target ?? null,
    });

    if (!selector) {
      this.session.raise('schema:compose');
    }

    trace.getActiveSpan()?.setAttributes({
      'hive.organization.id': selector.organizationId,
      'hive.target.id': selector.targetId,
      'hive.project.id': selector.projectId,
    });

    await this.session.canPerformAction({
      action: 'schema:compose',
      organizationId: selector.organizationId,
      params: {
        organizationId: selector.organizationId,
        projectId: selector.projectId,
        targetId: selector.targetId,
      },
    });

    const [organization, project, target] = await Promise.all([
      this.storage.getOrganization({
        organizationId: selector.organizationId,
      }),
      this.storage.getProject({
        organizationId: selector.organizationId,
        projectId: selector.projectId,
      }),
      this.storage.getTarget({
        organizationId: selector.organizationId,
        projectId: selector.projectId,
        targetId: selector.targetId,
      }),
    ]);

    if (project.type !== ProjectType.FEDERATION) {
      return {
        kind: 'error' as const,
        message: 'Only Federation projects are supported',
      };
    }

    const latestSchemas = await this.getLatestSchemaVersionWithSchemaLogs({
      target,
      onlyComposable: input.onlyComposable,
    });

    const existingServices = ensureCompositeSchemas(latestSchemas ? latestSchemas.schemas : []);
    const services = existingServices
      // remove provided services from the list
      .filter(service => !input.services.some(s => s.name === service.service_name))
      .map(service => ({
        serviceName: service.service_name,
        sdl: service.sdl,
        serviceUrl: service.service_url,
      }))
      // add provided services to the list
      .concat(
        input.services.map(service => ({
          serviceName: service.name,
          sdl: service.sdl,
          serviceUrl: service.url ?? '',
        })),
      )
      .map(service => this.schemaHelper.createSchemaObject(service));

    const compositionResult = await this.compositionOrchestrator.composeAndValidate(
      'federation',
      services,
      {
        external: project.externalComposition,
        native: this.checkProjectNativeFederationSupport({
          project,
          organization,
          targetId: selector.targetId,
        }),
        contracts: null,
      },
    );

    if (compositionResult.errors.length > 0) {
      return {
        kind: 'success' as const,
        errors: compositionResult.errors,
      };
    }

    if (compositionResult.supergraph) {
      return {
        kind: 'success' as const,
        supergraphSDL: compositionResult.supergraph,
      };
    }

    throw new Error('Composition was successful but is missing a supergraph');
  }

  @traceFn('SchemaManager.getSchemasOfVersion', {
    initAttributes: selector => ({
      'hive.target.id': selector.targetId,
      'hive.organization.id': selector.organizationId,
      'hive.project.id': selector.projectId,
      'hive.version.id': selector.versionId,
    }),
  })
  @atomic(stringifySelector)
  async getSchemasOfVersion(
    selector: {
      versionId: string;
      includeMetadata?: boolean;
    } & TargetSelector,
  ) {
    this.logger.debug('Fetching non-empty list of schemas (selector=%o)', selector);
    const schemas = await this.storage.getSchemasOfVersion(selector);

    if (schemas.length === 0) {
      throw new HiveError('No schemas found for this version.');
    }

    return schemas;
  }

  @traceFn('SchemaManager.getSchemasOfVersion', {
    initAttributes: input => ({
      'hive.target.id': input.targetId,
      'hive.organization.id': input.organizationId,
      'hive.project.id': input.projectId,
      'hive.version.id': input.id,
    }),
  })
  @atomic(stringifySelector)
  async getMaybeSchemasOfVersion(schemaVersion: SchemaVersion) {
    this.logger.debug('Fetching schemas (schemaVersionId=%s)', schemaVersion.id);
    return this.storage.getSchemasOfVersion({ versionId: schemaVersion.id });
  }

  async getMatchingServiceSchemaOfVersions(versions: { before: string | null; after: string }) {
    this.logger.debug('Fetching service schema of versions (selector=%o)', versions);
    return this.storage.getMatchingServiceSchemaOfVersions(versions);
  }

  async getMaybeLatestValidVersion(target: Target) {
    this.logger.debug('Fetching maybe latest valid version (targetId=%o)', target.id);
    const version = await this.storage.getMaybeLatestValidVersion({
      targetId: target.id,
    });

    if (!version) {
      return null;
    }

    return {
      ...version,
      projectId: target.projectId,
      targetId: target.id,
      organizationId: target.orgId,
    };
  }

  async getLatestValidVersion(selector: TargetSelector) {
    this.logger.debug('Fetching latest valid version (selector=%o)', selector);
    return this.latestSchemaVersionLoader.load(selector);
  }

  async getMaybeLatestVersion(target: Target) {
    this.logger.debug('Fetching maybe latest version (targetId=%o)', target.id);
    const latest = await this.storage.getMaybeLatestVersion({
      targetId: target.id,
      projectId: target.projectId,
      organizationId: target.orgId,
    });

    if (!latest) {
      return null;
    }

    return {
      ...latest,
      projectId: target.projectId,
      targetId: target.id,
      organizationId: target.orgId,
    };
  }

  async getSchemaVersion(
    selector: TargetSelector & { versionId: string },
  ): Promise<SchemaVersion | null> {
    this.logger.debug('Fetching single schema version (selector=%o)', selector);

    if (isUUID(selector.versionId) === false) {
      this.logger.debug('Invalid UUID provided. (versionId=%s)', selector.versionId);
      return null;
    }

    const result = await this.storage.getMaybeVersion(selector);

    if (!result) {
      return null;
    }

    return {
      projectId: selector.projectId,
      targetId: selector.targetId,
      organizationId: selector.organizationId,
      ...result,
    };
  }

  /**
   * Retrieve the latest schema version including the schema logs.
   */
  async getLatestSchemaVersionWithSchemaLogs(args: { target: Target; onlyComposable?: boolean }) {
    const schemaVersion = await (args.onlyComposable
      ? this.getMaybeLatestValidVersion(args.target)
      : this.getMaybeLatestVersion(args.target));

    if (!schemaVersion) {
      return null;
    }

    const schemas = await this.storage.getSchemasOfVersion({
      versionId: schemaVersion.id,
      includeMetadata: true,
    });

    return {
      version: schemaVersion,
      schemas,
    };
  }

  async getPaginatedSchemaVersionsForTargetId(args: {
    targetId: string;
    organizationId: string;
    projectId: string;
    first: number | null;
    cursor: null | string;
  }) {
    const connection = await this.storage.getPaginatedSchemaVersionsForTargetId(args);

    return {
      ...connection,
      edges: connection.edges.map(edge => ({
        ...edge,
        node: {
          ...edge.node,
          organizationId: args.organizationId,
          projectId: args.projectId,
          targetId: args.targetId,
        },
      })),
    };
  }

  async getPaginatedSchemaChecksForSchemaProposal<
    TransformedSchemaCheck extends SchemaCheck = SchemaCheck,
  >(args: {
    transformNode?: (check: SchemaCheck) => TransformedSchemaCheck;
    proposalId: string;
    first: number | null;
    cursor: string | null;
    latest?: boolean;
  }) {
    const connection = await this.storage.getPaginatedSchemaChecksForSchemaProposal(args);
    return connection;
  }

  async getSchemaLog(selector: { commit: string } & TargetSelector) {
    this.logger.debug('Fetching schema log (selector=%o)', selector);
    return this.storage.getSchemaLog({
      commit: selector.commit,
      targetId: selector.targetId,
    });
  }

  @traceFn('SchemaManager.createVersion', {
    initAttributes: input => ({
      'hive.target.id': input.targetId,
      'hive.organization.id': input.organizationId,
      'hive.project.id': input.projectId,
      'hive.version.commit': input.commit,
      'hive.version.valid': input.valid,
      'hive.version.service': input.service || '',
    }),
  })
  async createVersion(
    input: ({
      commit: string;
      schema: string;
      author: string;
      valid: boolean;
      service?: string | null;
      logIds: string[];
      url?: string | null;
      base_schema: string | null;
      metadata: string | null;
      projectType: ProjectType;
      actionFn(versionId: string): Promise<void>;
      changes: Array<SchemaChangeType>;
      coordinatesDiff: SchemaCoordinatesDiffResult | null;
      previousSchemaVersion: string | null;
      diffSchemaVersionId: string | null;
      github: null | {
        repository: string;
        sha: string;
      };
      contracts: null | Array<{
        contractId: string;
        contractName: string;
        compositeSchemaSDL: string | null;
        supergraphSDL: string | null;
        schemaCompositionErrors: Array<SchemaCompositionError> | null;
        changes: null | Array<SchemaChangeType>;
      }>;
      conditionalBreakingChangeMetadata: null | ConditionalBreakingChangeMetadata;
    } & TargetSelector) &
      (
        | {
            compositeSchemaSDL: null;
            supergraphSDL: null;
            schemaCompositionErrors: Array<SchemaCompositionError>;
            tags: null;
            schemaMetadata: null;
            metadataAttributes: null;
          }
        | {
            compositeSchemaSDL: string;
            supergraphSDL: string | null;
            schemaCompositionErrors: null;
            tags: Array<string> | null;
            schemaMetadata: null | Record<
              string,
              Array<{ name: string; content: string; source: string | null }>
            >;
            metadataAttributes: null | Record<string, string[]>;
          }
      ),
  ) {
    this.logger.info(
      'Creating a new version (input=%o)',
      lodash.pick(input, [
        'commit',
        'author',
        'valid',
        'service',
        'logIds',
        'url',
        'projectType',
        'previousSchemaVersion',
        'diffSchemaVersionId',
        'github',
        'conditionalBreakingChangeMetadata',
      ]),
    );

    return this.storage.createVersion({
      ...input,
      logIds: input.logIds,
    });
  }

  async testExternalSchemaComposition(selector: { projectId: string; organizationId: string }) {
    await this.session.assertPerformAction({
      organizationId: selector.organizationId,
      action: 'project:modifySettings',
      params: {
        organizationId: selector.organizationId,
        projectId: selector.projectId,
      },
    });

    const project = await this.storage.getProject({
      organizationId: selector.organizationId,
      projectId: selector.projectId,
    });

    if (project.type !== ProjectType.FEDERATION) {
      throw new HiveError(
        'Project is not of Federation type. External composition is not available.',
      );
    }

    if (!project.externalComposition.enabled) {
      throw new HiveError('External composition is not enabled.');
    }

    try {
      const { errors } = await this.compositionOrchestrator.composeAndValidate(
        'federation',
        [
          {
            document: externalSchemaCompositionTestDocument,
            raw: externalSchemaCompositionTestSdl,
            source: 'test',
            url: null,
          },
        ],
        {
          external: project.externalComposition,
          // when testing external composition, do not use native composition during the check
          native: false,
          contracts: null,
        },
      );

      if (errors.length > 0) {
        return {
          kind: 'error',
          error: errors[0].message,
        } as const;
      }

      return {
        kind: 'success',
        project,
      } as const;
    } catch (error) {
      return {
        kind: 'error',
        error: error instanceof HiveError ? error.message : 'Unknown error',
      } as const;
    }
  }

  async getBaseSchemaForTarget(target: Target) {
    this.logger.debug('Fetching base schema (selector=%o)', target);

    return await this.storage.getBaseSchema({
      organizationId: target.orgId,
      projectId: target.projectId,
      targetId: target.id,
    });
  }
  async updateBaseSchema(selector: TargetSelector, newBaseSchema: string | null) {
    this.logger.debug('Updating base schema (selector=%o)', selector);
    await this.session.assertPerformAction({
      action: 'target:modifySettings',
      organizationId: selector.organizationId,
      params: {
        organizationId: selector.organizationId,
        projectId: selector.projectId,
        targetId: selector.targetId,
      },
    });
    await this.storage.updateBaseSchema(selector, newBaseSchema);
  }

  countSchemaVersionsOfProject(
    selector: ProjectSelector & {
      period: DateRange | null;
    },
  ): Promise<number> {
    this.logger.debug('Fetching schema versions count of project (selector=%o)', selector);
    return this.storage.countSchemaVersionsOfProject(selector);
  }

  countSchemaVersionsOfTarget(
    selector: TargetSelector & {
      period: DateRange | null;
    },
  ): Promise<number> {
    this.logger.debug('Fetching schema versions count of target (selector=%o)', selector);
    return this.storage.countSchemaVersionsOfTarget(selector);
  }

  async completeGetStartedCheck(
    selector: OrganizationSelector & {
      step: 'publishingSchema' | 'checkingSchema';
    },
  ) {
    try {
      await this.storage.completeGetStartedStep(selector);
    } catch (error) {
      this.logger.error(
        'Failed to complete get started check (selector=%o, error=%s)',
        selector,
        error,
      );
    }
  }

  async updateSchemaComposition(
    input: ProjectSelector &
      (
        | { mode: 'native' }
        | { mode: 'legacy' }
        | {
            mode: 'external';
            endpoint: string;
            secret: string;
          }
      ),
  ) {
    this.logger.debug('Updating schema composition settings (input=%o)', input);
    await this.session.assertPerformAction({
      organizationId: input.organizationId,
      action: 'project:modifySettings',
      params: {
        organizationId: input.organizationId,
        projectId: input.projectId,
      },
    });

    const project = await this.projectManager.getProject({
      organizationId: input.organizationId,
      projectId: input.projectId,
    });

    if (project.type !== ProjectType.FEDERATION) {
      const message = 'Schema composition is supported only by Federation projects';
      if (input.mode === 'native') {
        return { error: { __typename: 'UpdateSchemaCompositionNativeError', message } } as const;
      }
      if (input.mode === 'legacy') {
        return { error: { __typename: 'UpdateSchemaCompositionLegacyError', message } } as const;
      }
      if (input.mode === 'external') {
        return { error: { __typename: 'UpdateSchemaCompositionExternalError', message } } as const;
      }
    }

    switch (input.mode) {
      case 'native': {
        return {
          ok: {
            updatedProject: await this.storage.updateNativeSchemaComposition({
              projectId: input.projectId,
              organizationId: input.organizationId,
              enabled: true,
            }),
          },
        };
      }
      case 'legacy': {
        return {
          ok: {
            updatedProject: await this.storage.updateNativeSchemaComposition({
              projectId: input.projectId,
              organizationId: input.organizationId,
              enabled: false,
            }),
          },
        };
      }
      case 'external': {
        const parseResult = ENABLE_EXTERNAL_COMPOSITION_SCHEMA.safeParse({
          endpoint: input.endpoint,
          secret: input.secret,
        });

        if (!parseResult.success) {
          return {
            error: {
              __typename: 'UpdateSchemaCompositionExternalError' as const,
              message: parseResult.error.message,
              inputErrors: {
                endpoint: parseResult.error.formErrors.fieldErrors.endpoint?.[0],
                secret: parseResult.error.formErrors.fieldErrors.secret?.[0],
              },
            },
          };
        }

        return {
          ok: {
            updatedProject: await this.storage.enableExternalSchemaComposition({
              projectId: input.projectId,
              organizationId: input.organizationId,
              endpoint: parseResult.data.endpoint.trim(),
              encryptedSecret: this.crypto.encrypt(parseResult.data.secret),
            }),
          },
        };
      }
      default: {
        const _: never = input;
        throw new HiveError('Unexpected input');
      }
    }
  }

  async getPaginatedSchemaChecksForTarget<TransformedSchemaCheck extends SchemaCheck>(
    target: Target,
    args: {
      first: number | null;
      cursor: string | null;
      transformNode: (check: SchemaCheck) => TransformedSchemaCheck;
      filters: SchemaChecksFilter | null;
    },
  ) {
    const paginatedResult = await this.storage.getPaginatedSchemaChecksForTarget({
      targetId: target.id,
      first: args.first,
      cursor: args.cursor,
      transformNode: node => args.transformNode(node),
      filters: args.filters,
    });

    return paginatedResult;
  }

  async findSchemaCheckForTarget(target: Target, schemaCheckId: string) {
    this.logger.debug(
      'Find schema check (targetId=%s, schemaCheckId=%s)',
      target.id,
      schemaCheckId,
    );

    if (isUUID(schemaCheckId) === false) {
      this.logger.debug(
        'Invalid ID provided (targetId=%s, schemaCheckId=%s)',
        target.id,
        schemaCheckId,
      );

      return null;
    }

    const schemaCheck = await this.storage.findSchemaCheck({
      targetId: target.id,
      schemaCheckId,
    });

    if (schemaCheck == null) {
      this.logger.debug(
        'Schema check not found (targetId=%s, schemaCheckId=%s)',
        target.id,
        schemaCheckId,
      );
      return null;
    }

    if (schemaCheck.breakingSchemaChanges && schemaCheck.conditionalBreakingChangeMetadata) {
      for (const change of schemaCheck.breakingSchemaChanges) {
        this.breakingSchemaChangeUsageHelper.registerMetadataForBreakingSchemaChange(
          change,
          schemaCheck.conditionalBreakingChangeMetadata,
        );
      }
    }

    return schemaCheck;
  }

  async getSchemaCheckWebUrl(args: {
    schemaCheckId: string;
    targetId: string;
  }): Promise<null | string> {
    if (this.schemaModuleConfig.schemaCheckLink == null) {
      return null;
    }

    const breadcrumb = await this.storage.getTargetBreadcrumbForTargetId({
      targetId: args.targetId,
    });
    if (!breadcrumb) {
      return null;
    }

    return this.schemaModuleConfig.schemaCheckLink({
      organization: {
        slug: breadcrumb.organizationSlug,
      },
      project: {
        slug: breadcrumb.projectSlug,
      },
      target: {
        slug: breadcrumb.targetSlug,
      },
      schemaCheckId: args.schemaCheckId,
    });
  }

  /**
   * Whether a failed schema check can be approved manually.
   */
  @cache<SchemaCheck>(schemaCheck => schemaCheck.id)
  async getFailedSchemaCheckCanBeApproved(schemaCheck: SchemaCheck) {
    this.logger.debug(
      'Check if failed schema check can be approved. (schemaCheckId=%s)',
      schemaCheck.id,
    );

    if (schemaCheck.schemaCompositionErrors !== null) {
      this.logger.debug(
        'Check can not be approved due to composition errors. (schemaCheckId=%s)',
        schemaCheck.id,
      );
      return false;
    }

    this.logger.debug(
      'Check if contracts have composition errors. (schemaCheckId=%s)',
      schemaCheck.id,
    );

    const contracts = await this.contracts.getContractChecksBySchemaCheckId({
      schemaCheckId: schemaCheck.id,
      onlyFailedWithBreakingChanges: false,
    });

    if (contracts === null) {
      this.logger.debug(
        'No contracts found, schema check can be approved. (schemaCheckId=%s)',
        schemaCheck.id,
      );

      return true;
    }

    this.logger.debug(
      '%s contract(s) found, schema check can be approved. (schemaCheckId=%s)',
      schemaCheck.id,
      contracts.length,
    );

    for (const contract of contracts) {
      if (contract.schemaCompositionErrors !== null) {
        this.logger.debug(
          'Contract has composition errors, schema check can not be approved. (schemaCheckId=%s, contractId=%s)',
          schemaCheck.id,
          contract.contractId,
        );
        return false;
      }
    }

    return true;
  }

  async getFailedSchemaCheckCanBeApprovedByViewer(
    schemaCheck: SchemaCheck & {
      selector: {
        organizationId: string;
        projectId: string;
      };
    },
  ) {
    if (!this.session.getViewer()) {
      // TODO: support approving a schema check via non web app user?
      return false;
    }

    const isViewer = this.session.isViewer();

    if (!isViewer) {
      return false;
    }

    const isAllowedToApproveFailedSchemaCheck = await this.session.canPerformAction({
      action: 'schemaCheck:approve',
      organizationId: schemaCheck.selector.organizationId,
      params: {
        organizationId: schemaCheck.selector.organizationId,
        projectId: schemaCheck.selector.projectId,
        targetId: schemaCheck.targetId,
        serviceName: schemaCheck.serviceName ?? null,
      },
    });

    if (isAllowedToApproveFailedSchemaCheck === false) {
      return false;
    }

    return await this.getFailedSchemaCheckCanBeApproved(schemaCheck);
  }

  /**
   * Approve a schema check that failed due to breaking changes.
   * You cannot approve a schema check that failed because composition failed.
   */
  async approveFailedSchemaCheck(args: {
    targetId: string;
    projectId: string;
    organizationId: string;
    schemaCheckId: string;
    comment: string | null | undefined;
    author?: string | null;
  }) {
    this.logger.debug('Manually approve failed schema check (args=%o)', args);

    let viewer: User | null = null;
    try {
      viewer = await this.session.getViewer();
    } catch (error) {
      this.logger.debug('No viewer available (likely using CLI) (args=%o)', args);
    }

    let [schemaCheck, target] = await Promise.all([
      this.storage.findSchemaCheck({
        targetId: args.targetId,
        schemaCheckId: args.schemaCheckId,
      }),
      this.storage.getTarget({
        organizationId: args.organizationId,
        projectId: args.projectId,
        targetId: args.targetId,
      }),
    ]);

    if (schemaCheck == null || schemaCheck.targetId !== args.targetId) {
      this.logger.debug('Schema check not found (args=%o)', args);
      return {
        type: 'error',
        reason: "Schema check doesn't exist.",
      } as const;
    }

    await this.session.assertPerformAction({
      action: 'schemaCheck:approve',
      organizationId: args.organizationId,
      params: {
        organizationId: target.orgId,
        projectId: target.projectId,
        targetId: target.id,
        serviceName: schemaCheck.serviceName ?? null,
      },
    });

    if (schemaCheck.isSuccess) {
      this.logger.debug('Schema check is not failed (args=%o)', args);
      return {
        type: 'error',
        reason: 'Schema check is not failed.',
      } as const;
    }

    if (!(await this.getFailedSchemaCheckCanBeApproved(schemaCheck))) {
      this.logger.debug('Schema check can not be approved. (args=%o)', args);
      return {
        type: 'error',
        reason: 'Schema check has composition errors.',
      } as const;
    }

    if (schemaCheck.githubCheckRunId) {
      this.logger.debug('Attempt updating GitHub schema check. (args=%o).', args);
      const project = await this.projectManager.getProject({
        organizationId: args.organizationId,
        projectId: args.projectId,
      });
      const gitRepository = schemaCheck.githubRepository ?? project.gitRepository;
      if (!gitRepository) {
        this.logger.debug(
          'Skip updating GitHub schema check. Schema check has no git repository or project has no git repository connected. (args=%o).',
          args,
        );
      } else {
        const [owner, repository] = gitRepository.split('/');
        const result = await this.githubIntegrationManager.updateCheckRunToSuccess({
          organizationId: args.organizationId,
          checkRun: {
            owner,
            repository,
            checkRunId: schemaCheck.githubCheckRunId,
          },
        });

        // In case updating the check run fails, we don't want to update our database state.
        // Instead, we want to return the error to the user and inform him that there is an issue with GitHub
        // and he needs to try again later.
        if (result?.type === 'error') {
          return {
            type: 'error',
            reason: result.reason,
          } as const;
        }
      }
    }

    schemaCheck = await this.storage.approveFailedSchemaCheck({
      targetId: target.id,
      contracts: this.contracts,
      schemaCheckId: args.schemaCheckId,
      userId: viewer?.id ?? null,
      comment: args.comment,
      author: args.author ?? null,
    });

    if (!schemaCheck) {
      // Re-fetch the schema check to determine why approval failed
      const recheck = await this.storage.findSchemaCheck({
        targetId: args.targetId,
        schemaCheckId: args.schemaCheckId,
      });

      if (recheck?.schemaPolicyErrors !== null) {
        return {
          type: 'error',
          reason: 'Schema check has schema policy errors that must be resolved before approval.',
        } as const;
      }

      return {
        type: 'error',
        reason: "Schema check doesn't exist.",
      } as const;
    }

    return {
      type: 'ok',
      schemaCheck,
    } as const;
  }

  async getApprovedByUser(args: { organizationId: string; userId: string | null }) {
    if (args.userId == null) {
      return null;
    }

    return this.storage.getUserById({
      id: args.userId,
    });
  }

  async getSchemaVersionByCommit(args: {
    commit: string;
    target: GraphQLSchema.TargetReferenceInput | null;
  }) {
    const selector = await this.idTranslator.resolveTargetReference({
      reference: args.target,
    });

    if (!selector) {
      this.session.raise('project:describe');
    }

    this.logger.debug('Fetch schema version by commit. (args=%o)', {
      projectId: selector.projectId,
      targetId: selector.targetId,
      commit: args.commit,
    });

    await this.session.assertPerformAction({
      action: 'project:describe',
      organizationId: selector.organizationId,
      params: {
        organizationId: selector.organizationId,
        projectId: selector.projectId,
      },
    });

    const record = await this.storage.getSchemaVersionByCommit({
      projectId: selector.projectId,
      targetId: selector.targetId,
      commit: args.commit,
    });

    if (!record) {
      return null;
    }

    return {
      ...record,
      projectId: selector.projectId,
      targetId: selector.targetId,
      organizationId: selector.organizationId,
    };
  }

  async getComposableVersionBeforeVersionId(args: {
    organization: string;
    project: string;
    target: string;
    beforeVersionId: string;
    beforeVersionCreatedAt: string;
  }) {
    this.logger.debug('Fetch version before version id. (args=%o)', args);

    const schemaVersion = await this.storage.getVersionBeforeVersionId({
      targetId: args.target,
      beforeVersionId: args.beforeVersionId,
      beforeVersionCreatedAt: args.beforeVersionCreatedAt,
      onlyComposable: true,
    });

    if (!schemaVersion) {
      return null;
    }

    return {
      ...schemaVersion,
      organizationId: args.organization,
      projectId: args.project,
      targetId: args.target,
    };
  }

  async getFirstComposableSchemaVersionBeforeVersionId(args: {
    organization: string;
    project: string;
    target: string;
    beforeVersionId: string;
    beforeVersionCreatedAt: string;
  }) {
    const schemaVersion = await this.storage.getVersionBeforeVersionId({
      targetId: args.target,
      beforeVersionId: args.beforeVersionId,
      beforeVersionCreatedAt: args.beforeVersionCreatedAt,
      onlyComposable: true,
    });

    if (!schemaVersion) {
      return null;
    }

    return {
      ...schemaVersion,
      organizationId: args.organization,
      projectId: args.project,
      targetId: args.target,
    };
  }

  checkProjectNativeFederationSupport(input: {
    targetId: string | null;
    project: Pick<Project, 'id' | 'nativeFederation'>;
    organization: Pick<Organization, 'id' | 'featureFlags'>;
  }) {
    if (input.project.nativeFederation === false) {
      return false;
    }

    if (
      input.targetId &&
      input.organization.featureFlags.forceLegacyCompositionInTargets.includes(input.targetId)
    ) {
      this.logger.warn(
        'Native Federation support is disabled for this target (organization=%s, project=%s, target=%s)',
        input.organization.id,
        input.project.id,
        input.targetId,
      );
      return false;
    }

    this.logger.debug(
      'Native Federation support available (organization=%s, project=%s)',
      input.organization.id,
      input.project.id,
    );
    return true;
  }

  async getNativeFederationCompatibilityStatus(project: Project): Promise<{
    status: NativeFederationCompatibilityStatusType;
    results: Array<null | {
      schemaVersion: SchemaVersion;
      target: Target;
      nativeCompositionResult: {
        supergraphSdl: string | null;
        errors: Array<{ message: string }> | null;
      };
      currentSupergraphSdl: string;
    }>;
  }> {
    this.logger.debug(
      'Get native Federation compatibility status (organization=%s, project=%s)',
      project.orgId,
      project.id,
    );

    if (project.type !== ProjectType.FEDERATION) {
      return {
        status: NativeFederationCompatibilityStatusType.NOT_APPLICABLE,
        results: [],
      };
    }

    const targets = await this.targetManager.getTargets({
      organizationId: project.orgId,
      projectId: project.id,
    });

    const results = await Promise.all(
      targets.map(async target => {
        const schemaVersion = await this.getMaybeLatestValidVersion(target);

        if (schemaVersion === null) {
          return null;
        }

        const currentSupergraphSdl = print(
          removeDescriptions(
            sortSDL(
              parseGraphQLSource(
                schemaVersion.supergraphSDL!,
                'parsing native supergraph in getNativeFederationCompatibilityStatus',
              ),
            ),
          ),
        );

        const schemas = await this.getSchemasOfVersion({
          organizationId: target.orgId,
          projectId: target.projectId,
          targetId: target.id,
          versionId: schemaVersion.id,
        });

        if (schemas.length === 0) {
          return null;
        }

        const compositionResult = await this.compositionOrchestrator.composeAndValidate(
          'federation',
          ensureCompositeSchemas(schemas).map(s =>
            this.schemaHelper.createSchemaObject({
              sdl: s.sdl,
              serviceName: s.service_name,
              serviceUrl: s.service_url,
            }),
          ),
          {
            native: true,
            external: {
              enabled: false,
            },
            contracts: null,
          },
        );

        const supergraphSdl = compositionResult.supergraph
          ? print(
              removeDescriptions(
                sortSDL(
                  parseGraphQLSource(
                    compositionResult.supergraph,
                    'parsing native supergraph in getNativeFederationCompatibilityStatus',
                  ),
                ),
              ),
            )
          : null;

        return {
          target,
          schemaVersion,
          currentSupergraphSdl,
          nativeCompositionResult: {
            supergraphSdl,
            errors: compositionResult.errors,
          },
        };
      }),
    );

    let status = NativeFederationCompatibilityStatusType.INCOMPATIBLE;

    if (results.every(result => result === null)) {
      this.logger.debug('No composable versions available (status: unknown)');
      status = NativeFederationCompatibilityStatusType.UNKNOWN;
    } else if (
      results.every(
        result =>
          result === null ||
          (result.nativeCompositionResult &&
            result.currentSupergraphSdl === result.nativeCompositionResult.supergraphSdl),
      )
    ) {
      this.logger.debug('All versions are compatible (status: compatible)');
      status = NativeFederationCompatibilityStatusType.COMPATIBLE;
    }

    return {
      status,
      results,
    };
  }

  async getGitHubMetadata(schemaVersion: SchemaVersion): Promise<null | {
    repository: `${string}/${string}`;
    commit: string;
  }> {
    if (schemaVersion.github) {
      return {
        repository: schemaVersion.github.repository as `${string}/${string}`,
        commit: schemaVersion.github.sha,
      };
    }

    const log = await this.getSchemaLog({
      commit: schemaVersion.actionId,
      organizationId: schemaVersion.organizationId,
      projectId: schemaVersion.projectId,
      targetId: schemaVersion.targetId,
    });

    if ('commit' in log && log.commit) {
      const project = await this.storage.getProject({
        organizationId: schemaVersion.organizationId,
        projectId: schemaVersion.projectId,
      });

      if (project.gitRepository) {
        return {
          repository: project.gitRepository,
          commit: log.commit,
        };
      }
    }

    return null;
  }

  async getUserForSchemaChangeById(input: { userId: string }) {
    this.logger.info('Load user by id. (userId=%%)', input.userId);
    const user = await this.storage.getUserById({ id: input.userId });
    if (user) {
      this.logger.info('User found. (userId=%s)', input.userId);
      return user;
    }
    this.logger.info('User not found. (userId=%s)', input.userId);
    return null;
  }
}
