import { Injectable, Scope } from 'graphql-modules';
import { traceFn } from '@hive/service-common';
import { SchemaChangeType } from '@hive/storage';
import { AppDeployments } from '../../../app-deployments/providers/app-deployments';
import {
  GetAffectedAppDeployments,
  RegistryChecks,
  type ConditionalBreakingChangeDiffConfig,
} from '../registry-checks';
import { CompositeSchemaInput, swapServices } from '../schema-helper';
import type { Organization, Project, Target } from './../../../../shared/entities';
import { ProjectType } from './../../../../shared/entities';
import { Logger } from './../../../shared/providers/logger';
import {
  buildSchemaCheckFailureState,
  ContractCheckInput,
  ContractInput,
  isContractChecksSuccessful,
  PublishFailureReasonCode,
  PublishIgnoreReasonCode /* Check */,
  SchemaCheckConclusion,
  SchemaCheckResult /* Delete */,
  SchemaDeleteConclusion,
  SchemaDeleteResult /* Publish */,
  SchemaPublishConclusion,
  SchemaPublishResult,
  temp,
} from './shared';

@Injectable({
  scope: Scope.Operation,
})
export class CompositeModel {
  constructor(
    private checks: RegistryChecks,
    private logger: Logger,
    private appDeployments: AppDeployments,
  ) {}

  private async getContractChecks(args: {
    contracts: Array<
      ContractInput & {
        approvedChanges?: Map<string, SchemaChangeType> | null;
      }
    > | null;
    compositionCheck: Awaited<ReturnType<RegistryChecks['composition']>>;
    conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig;
    failDiffOnDangerousChange: null | boolean;
    getAffectedAppDeployments: GetAffectedAppDeployments | null;
  }): Promise<Array<ContractCheckInput> | null> {
    const contractResults = (args.compositionCheck.result ?? args.compositionCheck.reason)
      ?.contracts;

    if (!args.contracts?.length || !contractResults?.length) {
      return null;
    }

    return await Promise.all(
      args.contracts.map(async (contract, contractIndex) => {
        const contractCompositionResult = contractResults[contractIndex];
        if (!contractCompositionResult) {
          throw new Error("Contract result doesn't exist. Inconsistency detected.");
        }

        return {
          contractId: contract.contract.id,
          contractName: contract.contract.contractName,
          compositionCheck: contractCompositionResult,
          diffCheck: await this.checks.diff({
            conditionalBreakingChangeConfig: args.conditionalBreakingChangeDiffConfig,
            includeUrlChanges: false,
            // contracts were introduced after this, so we do not need to filter out federation.
            filterOutFederationChanges: false,
            approvedChanges: contract.approvedChanges ?? null,
            existingSdl: contract.latestValidVersion?.compositeSchemaSdl ?? null,
            incomingSdl: contractCompositionResult?.result?.fullSchemaSdl ?? null,
            failDiffOnDangerousChange: args.failDiffOnDangerousChange,
            filterNestedChanges: true,
            getAffectedAppDeployments: args.getAffectedAppDeployments,
          }),
        };
      }),
    );
  }

  @traceFn('Composite modern: diffSchema')
  async diffSchema(args: {
    existing: Array<Pick<CompositeSchemaInput, 'sdl' | 'serviceName'>> | null;
    input: Pick<CompositeSchemaInput, 'sdl' | 'serviceName'>;
  }) {
    return this.checks.serviceDiff({
      existing:
        args.existing?.find(schema => schema.serviceName === args.input.serviceName) ?? null,
      incoming: args.input,
    });
  }

  @traceFn('Composite modern: check', {
    initAttributes: args => ({
      'hive.project.id': args.selector.projectId,
      'hive.target.id': args.selector.targetId,
      'hive.organization.id': args.selector.organizationId,
    }),
  })
  async check({
    input,
    selector,
    latest,
    latestComposable,
    project,
    organization,
    baseSchema,
    approvedChanges,
    conditionalBreakingChangeDiffConfig,
    contracts,
    failDiffOnDangerousChange,
    filterNestedChanges,
  }: {
    input: Pick<CompositeSchemaInput, 'sdl' | 'serviceName'> & {
      // for a schema check the service url is optional
      serviceUrl: string | null;
    };
    selector: {
      organizationId: string;
      projectId: string;
      targetId: string;
    };
    latest: {
      isComposable: boolean;
      sdl: string | null;
      schemas: CompositeSchemaInput[];
      contractNames: string[] | null;
    } | null;
    latestComposable: {
      isComposable: boolean;
      sdl: string | null;
      schemas: CompositeSchemaInput[];
    } | null;
    baseSchema: string | null;
    project: Project;
    organization: Organization;
    approvedChanges: Map<string, SchemaChangeType>;
    conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig;
    failDiffOnDangerousChange: null | boolean;
    contracts: Array<
      ContractInput & {
        approvedChanges: Map<string, SchemaChangeType> | null;
      }
    > | null;
    filterNestedChanges: boolean;
  }): Promise<SchemaCheckResult> {
    const incoming: CompositeSchemaInput = {
      id: temp,
      sdl: input.sdl,
      serviceName: input.serviceName,
      serviceUrl:
        input.serviceUrl ??
        latest?.schemas?.find(s => s.serviceName === input.serviceName)?.serviceUrl ??
        temp,
      metadata: null,
    };

    const schemaSwapResult = latest ? swapServices(latest.schemas, incoming) : null;
    const schemas = schemaSwapResult ? schemaSwapResult.schemas : [incoming];
    schemas.sort((a, b) => a.serviceName.localeCompare(b.serviceName));

    const checksumCheck = await this.checks.checksum({
      existing: schemaSwapResult?.existing
        ? {
            schema: schemaSwapResult.existing,
            contractNames: latest?.contractNames ?? null,
          }
        : null,
      incoming: {
        schema: incoming,
        contractNames: contracts?.map(({ contract }) => contract.contractName) ?? null,
      },
    });

    if (checksumCheck === 'unchanged') {
      this.logger.info('No changes detected, skipping schema check');
      return {
        conclusion: SchemaCheckConclusion.Skip,
      };
    }

    const compositionCheck = await this.checks.composition({
      targetId: selector.targetId,
      project,
      organization,
      schemas,
      baseSchema,
      contracts:
        contracts?.map(({ contract }) => ({
          id: contract.id,
          filter: {
            exclude: contract.excludeTags,
            include: contract.includeTags,
            removeUnreachableTypesFromPublicApiSchema:
              contract.removeUnreachableTypesFromPublicApiSchema,
          },
        })) ?? null,
    });

    const previousVersionSdl = await this.checks.retrievePreviousVersionSdl({
      version: latestComposable,
      organization,
      project,
      targetId: selector.targetId,
    });

    const getAffectedAppDeployments: GetAffectedAppDeployments = schemaCoordinates =>
      this.appDeployments.getAffectedAppDeploymentsBySchemaCoordinates({
        targetId: selector.targetId,
        schemaCoordinates,
        excludedAppDeploymentNames:
          conditionalBreakingChangeDiffConfig?.excludedAppDeploymentNames ?? null,
      });

    const contractChecks = await this.getContractChecks({
      contracts,
      compositionCheck,
      conditionalBreakingChangeDiffConfig,
      failDiffOnDangerousChange,
      getAffectedAppDeployments,
    });
    this.logger.info('Contract checks: %o', contractChecks);

    const [diffCheck, policyCheck] = await Promise.all([
      this.checks.diff({
        includeUrlChanges: false,
        filterOutFederationChanges: project.type === ProjectType.FEDERATION,
        approvedChanges,
        existingSdl: previousVersionSdl,
        incomingSdl:
          compositionCheck.result?.fullSchemaSdl ?? compositionCheck.reason?.fullSchemaSdl ?? null,
        conditionalBreakingChangeConfig: conditionalBreakingChangeDiffConfig,
        failDiffOnDangerousChange,
        filterNestedChanges,
        getAffectedAppDeployments,
      }),
      this.checks.policyCheck({
        selector,
        // if schema check does not compose then there's no point in linting. Pass in null in this case.
        incomingSdl: compositionCheck.result?.fullSchemaSdl ?? null,
        modifiedSdl: incoming.sdl,
      }),
    ]);
    this.logger.info('diff check status: %o', diffCheck);
    this.logger.info('policy check status: %o', policyCheck);

    if (
      compositionCheck.status === 'failed' ||
      diffCheck.status === 'failed' ||
      policyCheck.status === 'failed' ||
      // if any of the contract compositions failed, the schema check failed.
      (contractChecks?.length && contractChecks.some(check => !isContractChecksSuccessful(check)))
    ) {
      this.logger.debug('Schema check failed');
      return {
        conclusion: SchemaCheckConclusion.Failure,
        state: buildSchemaCheckFailureState({
          compositionCheck,
          diffCheck,
          policyCheck,
          contractChecks,
        }),
      };
    }

    this.logger.debug('Schema check successful');
    return {
      conclusion: SchemaCheckConclusion.Success,
      state: {
        schemaPolicyWarnings: policyCheck.result?.warnings ?? null,
        schemaChanges: diffCheck.result ?? null,
        composition: {
          compositeSchemaSDL: compositionCheck.result.fullSchemaSdl,
          supergraphSDL: compositionCheck.result.supergraph,
        },
        contracts:
          contractChecks?.map(contractCheck => {
            if (!isContractChecksSuccessful(contractCheck)) {
              throw new Error('This should not happen.');
            }

            return {
              contractId: contractCheck.contractId,
              contractName: contractCheck.contractName,
              isSuccessful: true,
              composition: {
                compositeSchemaSDL: contractCheck.compositionCheck.result.fullSchemaSdl,
                supergraphSDL: contractCheck.compositionCheck.result.supergraph ?? null,
              },
              schemaChanges: contractCheck.diffCheck.result ?? null,
            };
          }) ?? null,
      },
    };
  }

  async publish({
    input,
    target,
    project,
    organization,
    latest,
    latestComposable,
    baseSchema,
    contracts,
    conditionalBreakingChangeDiffConfig,
    failDiffOnDangerousChange,
  }: {
    input: {
      sdl: string;
      service: string;
      url: string | null;
      metadata: string | null;
    };
    project: Project;
    organization: Organization;
    target: Target;
    latest: {
      isComposable: boolean;
      sdl: string | null;
      schemas: CompositeSchemaInput[];
      contractNames: string[] | null;
    } | null;
    latestComposable: {
      isComposable: boolean;
      sdl: string | null;
      schemas: CompositeSchemaInput[];
    } | null;
    baseSchema: string | null;
    contracts: Array<ContractInput> | null;
    conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig;
    failDiffOnDangerousChange: null | boolean;
  }): Promise<SchemaPublishResult> {
    const latestSchemaVersion = latest;
    const latestServiceVersion = latest?.schemas?.find(
      schema => schema.serviceName === input.service,
    );

    const serviceUrlCheck = await this.checks.serviceUrl(
      input.url ?? null,
      latestServiceVersion?.serviceUrl ?? null,
    );

    if (serviceUrlCheck.status === 'failed') {
      return {
        conclusion: SchemaPublishConclusion.Reject,
        reasons: [
          {
            code: PublishFailureReasonCode.MissingServiceUrl,
          },
        ],
      };
    }

    const incoming: CompositeSchemaInput = {
      id: temp,
      sdl: input.sdl,
      serviceName: input.service,
      serviceUrl: serviceUrlCheck.result.serviceUrl,
      metadata: input.metadata ?? null,
    };

    const schemaSwapResult = latestSchemaVersion
      ? swapServices(latestSchemaVersion.schemas, incoming)
      : null;
    const previousService = schemaSwapResult?.existing;

    const schemas = schemaSwapResult?.schemas ?? [incoming];
    schemas.sort((a, b) => a.serviceName.localeCompare(b.serviceName));

    const checksumCheck = await this.checks.checksum({
      existing: schemaSwapResult?.existing
        ? {
            schema: schemaSwapResult.existing,
            contractNames: latest?.contractNames ?? null,
          }
        : null,
      incoming: {
        schema: incoming,
        contractNames: contracts?.map(contract => contract.contract.contractName) ?? null,
      },
    });

    if (checksumCheck === 'unchanged') {
      return {
        conclusion: SchemaPublishConclusion.Ignore,
        reason: PublishIgnoreReasonCode.NoChanges,
      };
    }

    const metadataCheck = await this.checks.metadata(incoming, previousService ?? null);

    if (metadataCheck?.status === 'failed') {
      return {
        conclusion: SchemaPublishConclusion.Reject,
        reasons: [
          {
            code: PublishFailureReasonCode.MetadataParsingFailure,
          },
        ],
      };
    }

    const compositionCheck = await this.checks.composition({
      targetId: target.id,
      project,
      organization,
      schemas,
      baseSchema,
      contracts:
        contracts?.map(({ contract }) => ({
          id: contract.id,
          filter: {
            exclude: contract.excludeTags,
            include: contract.includeTags,
            removeUnreachableTypesFromPublicApiSchema:
              contract.removeUnreachableTypesFromPublicApiSchema,
          },
        })) ?? null,
    });

    if (
      compositionCheck.status === 'failed' &&
      (compositionCheck.reason.includesNetworkError || compositionCheck.reason.includesException)
    ) {
      return {
        conclusion: SchemaPublishConclusion.Reject,
        reasons: [
          {
            code: PublishFailureReasonCode.CompositionFailure,
            compositionErrors: compositionCheck.reason.errorsBySource.composition,
          },
        ],
      };
    }

    const previousVersionSdl = await this.checks.retrievePreviousVersionSdl({
      version: latestComposable,
      organization,
      project,
      targetId: target.id,
    });

    const getAffectedAppDeploymentsForPublish: GetAffectedAppDeployments = schemaCoordinates =>
      this.appDeployments.getAffectedAppDeploymentsBySchemaCoordinates({
        targetId: target.id,
        schemaCoordinates,
        excludedAppDeploymentNames:
          conditionalBreakingChangeDiffConfig?.excludedAppDeploymentNames ?? null,
      });

    const diffCheck = await this.checks.diff({
      conditionalBreakingChangeConfig: conditionalBreakingChangeDiffConfig,
      includeUrlChanges: {
        schemasBefore: latestComposable?.schemas ?? [],
        schemasAfter: schemas,
      },
      filterOutFederationChanges: project.type === ProjectType.FEDERATION,
      approvedChanges: null,
      existingSdl: previousVersionSdl,
      incomingSdl: compositionCheck.result?.fullSchemaSdl ?? null,
      failDiffOnDangerousChange,
      filterNestedChanges: true, // filter because publish is never associated to schema proposals in this way.
      getAffectedAppDeployments: getAffectedAppDeploymentsForPublish,
    });

    const contractChecks = await this.getContractChecks({
      contracts,
      compositionCheck,
      conditionalBreakingChangeDiffConfig,
      failDiffOnDangerousChange,
      getAffectedAppDeployments: getAffectedAppDeploymentsForPublish,
    });

    const messages: string[] = [];

    if (serviceUrlCheck.status === 'completed' && serviceUrlCheck.result.status === 'modified') {
      messages.push(serviceUrlCheck.result.message);
    }

    if (metadataCheck?.status === 'completed' && metadataCheck.result.status === 'modified') {
      messages.push('Metadata has been updated');
    }

    return {
      conclusion: SchemaPublishConclusion.Publish,
      state: {
        composable: compositionCheck.status === 'completed',
        initial: latestSchemaVersion === null,
        changes: diffCheck.result?.all ?? diffCheck.reason?.all ?? null,
        coordinatesDiff:
          diffCheck.result?.coordinatesDiff ??
          diffCheck.reason?.coordinatesDiff ??
          diffCheck.data?.coordinatesDiff ??
          null,
        messages,
        breakingChanges: null,
        compositionErrors: compositionCheck.reason?.errors ?? null,
        schema: incoming,
        schemas,
        supergraph: compositionCheck.result?.supergraph ?? null,
        fullSchemaSdl: compositionCheck.result?.fullSchemaSdl ?? null,
        tags: compositionCheck.result?.tags ?? null,
        schemaMetadata: compositionCheck.result?.schemaMetadata ?? null,
        metadataAttributes: compositionCheck.result?.metadataAttributes ?? null,
        contracts:
          contractChecks?.map(contractCheck => ({
            contractId: contractCheck.contractId,
            contractName: contractCheck.contractName,
            isComposable: contractCheck.compositionCheck.status === 'completed',
            compositionErrors: contractCheck.compositionCheck.reason?.errors ?? null,
            supergraph: contractCheck.compositionCheck?.result?.supergraph ?? null,
            fullSchemaSdl:
              contractCheck.compositionCheck?.result?.fullSchemaSdl ??
              contractCheck.compositionCheck?.reason?.fullSchemaSdl ??
              null,
            changes:
              (contractCheck.diffCheck.result ?? contractCheck.diffCheck.reason)?.all ?? null,
          })) ?? null,
      },
    };
  }

  async delete({
    input,
    latest,
    latestComposable,
    organization,
    project,
    selector,
    baseSchema,
    conditionalBreakingChangeDiffConfig,
    contracts,
    failDiffOnDangerousChange,
  }: {
    input: {
      serviceName: string;
    };
    project: Project;
    organization: Organization;
    selector: {
      target: string;
      project: string;
      organization: string;
    };
    baseSchema: string | null;
    latest: {
      isComposable: boolean;
      sdl: string | null;
      schemas: CompositeSchemaInput[];
    };
    latestComposable: {
      isComposable: boolean;
      sdl: string | null;
      schemas: CompositeSchemaInput[];
    } | null;
    contracts: Array<ContractInput> | null;
    conditionalBreakingChangeDiffConfig: null | ConditionalBreakingChangeDiffConfig;
    failDiffOnDangerousChange: null | boolean;
  }): Promise<SchemaDeleteResult> {
    const latestVersion = latest;

    const schemas = latestVersion.schemas.filter(s => s.serviceName !== input.serviceName);
    schemas.sort((a, b) => a.serviceName.localeCompare(b.serviceName));

    const compositionCheck = await this.checks.composition({
      targetId: selector.target,
      project,
      organization,
      schemas,
      baseSchema,
      contracts:
        contracts?.map(({ contract }) => ({
          id: contract.id,
          filter: {
            exclude: contract.excludeTags,
            include: contract.includeTags,
            removeUnreachableTypesFromPublicApiSchema:
              contract.removeUnreachableTypesFromPublicApiSchema,
          },
        })) ?? null,
    });

    const previousVersionSdl = await this.checks.retrievePreviousVersionSdl({
      version: latestComposable,
      organization,
      project,
      targetId: selector.target,
    });

    const getAffectedAppDeploymentsForDelete: GetAffectedAppDeployments = schemaCoordinates =>
      this.appDeployments.getAffectedAppDeploymentsBySchemaCoordinates({
        targetId: selector.target,
        schemaCoordinates,
        excludedAppDeploymentNames:
          conditionalBreakingChangeDiffConfig?.excludedAppDeploymentNames ?? null,
      });

    const diffCheck = await this.checks.diff({
      conditionalBreakingChangeConfig: conditionalBreakingChangeDiffConfig,
      includeUrlChanges: {
        schemasBefore: latestVersion.schemas,
        schemasAfter: schemas,
      },
      filterOutFederationChanges: project.type === ProjectType.FEDERATION,
      approvedChanges: null,
      existingSdl: previousVersionSdl,
      incomingSdl: compositionCheck.result?.fullSchemaSdl ?? null,
      failDiffOnDangerousChange,
      filterNestedChanges: true, // filter because deletes are never associated with schema proposals in this way.
      getAffectedAppDeployments: getAffectedAppDeploymentsForDelete,
    });

    const contractChecks = await this.getContractChecks({
      contracts,
      compositionCheck,
      conditionalBreakingChangeDiffConfig,
      failDiffOnDangerousChange,
      getAffectedAppDeployments: getAffectedAppDeploymentsForDelete,
    });

    const { changes, breakingChanges } =
      diffCheck.status === 'failed'
        ? {
            changes: diffCheck.reason.all ?? [],
            breakingChanges: diffCheck.reason.breaking ?? [],
          }
        : {
            changes: diffCheck.result?.all ?? [],
            breakingChanges: [],
          };

    let composablePartial:
      | {
          composable: true;
          fullSchemaSdl: string;
        }
      | {
          composable: false;
          fullSchemaSdl: null;
        } = {
      composable: false,
      fullSchemaSdl: null,
    };

    const isComposable = compositionCheck.status === 'completed';
    const fullSchemaSdl = compositionCheck.result?.fullSchemaSdl ?? null;

    if (compositionCheck.status === 'completed' && isComposable && fullSchemaSdl == null) {
      throw new Error(
        'Full schema SDL is null when composition check is completed and is composable.',
      );
    } else if (isComposable && fullSchemaSdl) {
      composablePartial = {
        composable: isComposable,
        fullSchemaSdl,
      };
    }

    return {
      conclusion: SchemaDeleteConclusion.Accept,
      state: {
        ...composablePartial,
        changes,
        breakingChanges,
        coordinatesDiff:
          diffCheck.result?.coordinatesDiff ??
          diffCheck.reason?.coordinatesDiff ??
          diffCheck.data?.coordinatesDiff ??
          null,
        compositionErrors: compositionCheck.reason?.errors ?? [],
        supergraph: compositionCheck.result?.supergraph ?? null,
        tags: compositionCheck.result?.tags ?? null,
        schemaMetadata: compositionCheck.result?.schemaMetadata ?? null,
        metadataAttributes: compositionCheck.result?.metadataAttributes ?? null,
        schemas,
        contracts:
          contractChecks?.map(contractCheck => ({
            contractId: contractCheck.contractId,
            contractName: contractCheck.contractName,
            isComposable: contractCheck.compositionCheck.status === 'completed',
            compositionErrors: contractCheck.compositionCheck.reason?.errors ?? null,
            supergraph: contractCheck.compositionCheck?.result?.supergraph ?? null,
            fullSchemaSdl:
              contractCheck.compositionCheck?.result?.fullSchemaSdl ??
              contractCheck.compositionCheck?.reason?.fullSchemaSdl ??
              null,
            changes:
              (contractCheck.diffCheck.result ?? contractCheck.diffCheck.reason)?.all ?? null,
          })) ?? null,
      },
    };
  }
}
