import * as path from 'path';
import * as fs from 'fs-extra';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as iam from 'aws-cdk-lib/aws-iam';
import { CfnFunction } from 'aws-cdk-lib/aws-lambda';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as ssm from 'aws-cdk-lib/aws-secretsmanager';
import * as cloudmap from 'aws-cdk-lib/aws-servicediscovery';
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { NETWORK_STACK_LOGICAL_ID } from '../../category-constants';
import Container from './docker-compose/ecs-objects/container';
import { GitHubSourceActionInfo, PipelineWithAwaiter } from './pipeline-with-awaiter';

const PIPELINE_AWAITER_ZIP = 'custom-resource-pipeline-awaiter-18.zip';

export enum DEPLOYMENT_MECHANISM {
  /**
   * on every amplify push.
   */
  FULLY_MANAGED = 'FULLY_MANAGED',
  /**
   * on every github push
   */
  INDENPENDENTLY_MANAGED = 'INDENPENDENTLY_MANAGED',
  /**
   * manually push by the customer to ECR
   */
  SELF_MANAGED = 'SELF_MANAGED',
}

export type ContainersStackProps = Readonly<{
  skipWait?: boolean;
  categoryName: string;
  apiName: string;
  dependsOn: ReadonlyArray<{
    category: string;
    resourceName: string;
    attributes: string[];
  }>;
  taskEnvironmentVariables?: Record<string, any>;
  deploymentMechanism: DEPLOYMENT_MECHANISM;
  restrictAccess: boolean;
  policies?: ReadonlyArray<iam.PolicyStatement | Record<string, any>>;
  containers: ReadonlyArray<Container>;
  secretsArns?: ReadonlyMap<string, string>;
  exposedContainer: { name: string; port: number };
  taskPorts: number[];
  isInitialDeploy: boolean;
  desiredCount: number;
  createCloudMapService?: boolean;
  gitHubSourceActionInfo?: GitHubSourceActionInfo;
  existingEcrRepositories: Set<string>;
  currentStackName: string;
}>;
export abstract class ContainersStack extends cdk.Stack {
  protected readonly vpcId: string;

  private readonly vpcCidrBlock: string;

  protected readonly subnets: ReadonlyArray<string>;

  private readonly clusterName: string;

  private readonly zipPath: string;

  private readonly cloudMapNamespaceId: string;

  protected readonly vpcLinkId: string;

  private readonly pipelineWithAwaiter: PipelineWithAwaiter;

  protected readonly cloudMapService: cloudmap.CfnService | undefined;

  protected readonly ecsService: ecs.CfnService;

  protected readonly isAuthCondition: cdk.CfnCondition;

  protected readonly appClientId: string | undefined;

  protected readonly userPoolId: string | undefined;

  protected readonly ecsServiceSecurityGroup: ec2.CfnSecurityGroup;

  protected readonly parameters: ReadonlyMap<string, cdk.CfnParameter>;

  protected readonly envName: string;

  protected readonly deploymentBucketName: string;

  protected readonly awaiterS3Key: string;

  constructor(scope: Construct, id: string, private readonly props: ContainersStackProps) {
    super(scope, id, { synthesizer: new cdk.LegacyStackSynthesizer() });

    const {
      parameters,
      vpcId,
      vpcCidrBlock,
      subnets,
      clusterName,
      zipPath,
      cloudMapNamespaceId,
      vpcLinkId,
      isAuthCondition,
      appClientId,
      userPoolId,
      envName,
      deploymentBucketName,
      awaiterS3Key,
    } = this.init();

    this.parameters = parameters;

    this.vpcId = vpcId;
    this.vpcCidrBlock = vpcCidrBlock;
    this.subnets = subnets;
    this.clusterName = clusterName;
    this.zipPath = zipPath;
    this.cloudMapNamespaceId = cloudMapNamespaceId;
    this.vpcLinkId = vpcLinkId;
    this.isAuthCondition = isAuthCondition;
    this.appClientId = appClientId;
    this.userPoolId = userPoolId;
    this.envName = envName;
    this.deploymentBucketName = deploymentBucketName;
    this.awaiterS3Key = awaiterS3Key;
    const { service, serviceSecurityGroup, containersInfo, cloudMapService } = this.ecs();

    this.cloudMapService = cloudMapService;
    this.ecsService = service;
    this.ecsServiceSecurityGroup = serviceSecurityGroup;

    const { gitHubSourceActionInfo, skipWait } = this.props;

    const { pipelineWithAwaiter } = this.pipeline({
      skipWait,
      service,
      containersInfo: containersInfo.filter((container) => container.repository),
      gitHubSourceActionInfo,
    });

    this.pipelineWithAwaiter = pipelineWithAwaiter;

    new cdk.CfnOutput(this, 'ContainerNames', {
      value: cdk.Fn.join(
        ',',
        containersInfo.map(({ container: { containerName } }) => containerName),
      ),
    });
  }

  private init() {
    const { restrictAccess, dependsOn, deploymentMechanism } = this.props;

    // Unused in this stack, but required by the root stack
    new cdk.CfnParameter(this, 'env', { type: 'String' });

    const paramDomain = new cdk.CfnParameter(this, 'domain', { type: 'String', default: '' });
    const paramRestrictAccess = new cdk.CfnParameter(this, 'restrictAccess', {
      type: 'String',
      allowedValues: ['true', 'false'],
      default: 'false',
    });

    const paramZipPath = new cdk.CfnParameter(this, 'ParamZipPath', {
      type: 'String',
      // Required only for FULLY_MANAGED
      default: deploymentMechanism === DEPLOYMENT_MECHANISM.FULLY_MANAGED ? undefined : '',
    });

    const parameters: Map<string, cdk.CfnParameter> = new Map();

    parameters.set('ParamZipPath', paramZipPath);
    parameters.set('domain', paramDomain);
    parameters.set('restrictAccess', paramRestrictAccess);

    const authParams: {
      UserPoolId?: cdk.CfnParameter;
      AppClientIDWeb?: cdk.CfnParameter;
    } = {};

    const paramTypes: Record<string, string> = {
      NetworkStackSubnetIds: 'CommaDelimitedList',
    };

    dependsOn.forEach(({ category, resourceName, attributes }) => {
      attributes.forEach((attrib) => {
        const paramName = [category, resourceName, attrib].join('');

        const type = paramTypes[paramName] ?? 'String';
        const param = new cdk.CfnParameter(this, paramName, { type });

        parameters.set(paramName, param);

        if (category === 'auth') {
          authParams[attrib as keyof typeof authParams] = param;
        }
      });
    });

    const paramVpcId = parameters.get(`${NETWORK_STACK_LOGICAL_ID}VpcId`);
    const paramVpcCidrBlock = parameters.get(`${NETWORK_STACK_LOGICAL_ID}VpcCidrBlock`);
    const paramSubnetIds = parameters.get(`${NETWORK_STACK_LOGICAL_ID}SubnetIds`);
    const paramClusterName = parameters.get(`${NETWORK_STACK_LOGICAL_ID}ClusterName`);
    const paramCloudMapNamespaceId = parameters.get(`${NETWORK_STACK_LOGICAL_ID}CloudMapNamespaceId`);
    const paramVpcLinkId = parameters.get(`${NETWORK_STACK_LOGICAL_ID}VpcLinkId`);

    const { UserPoolId: paramUserPoolId, AppClientIDWeb: paramAppClientIdWeb } = authParams;

    const isAuthCondition = new cdk.CfnCondition(this, 'isAuthCondition', {
      expression: cdk.Fn.conditionAnd(
        cdk.Fn.conditionEquals(restrictAccess, true),
        cdk.Fn.conditionNot(cdk.Fn.conditionEquals(paramUserPoolId ?? '', '')),
        cdk.Fn.conditionNot(cdk.Fn.conditionEquals(paramAppClientIdWeb ?? '', '')),
      ),
    });

    const stackNameParameter = new cdk.CfnParameter(this, 'rootStackName', {
      type: 'String',
    });

    const deploymentBucketName = new cdk.CfnParameter(this, 'deploymentBucketName', {
      type: 'String',
    });
    const awaiterS3Key = new cdk.CfnParameter(this, 'awaiterS3Key', {
      type: 'String',
      default: PIPELINE_AWAITER_ZIP,
    });
    return {
      parameters,
      vpcId: paramVpcId.valueAsString,
      vpcCidrBlock: paramVpcCidrBlock.valueAsString,
      subnets: paramSubnetIds.valueAsList,
      clusterName: paramClusterName.valueAsString,
      zipPath: paramZipPath.valueAsString,
      cloudMapNamespaceId: paramCloudMapNamespaceId.valueAsString,
      vpcLinkId: paramVpcLinkId.valueAsString,
      isAuthCondition,
      userPoolId: paramUserPoolId && paramUserPoolId.valueAsString,
      appClientId: paramAppClientIdWeb && paramAppClientIdWeb.valueAsString,
      envName: stackNameParameter.valueAsString,
      deploymentBucketName: deploymentBucketName.valueAsString,
      awaiterS3Key: awaiterS3Key.valueAsString,
    };
  }

  private ecs() {
    const {
      categoryName,
      apiName,
      policies,
      containers,
      secretsArns,
      taskEnvironmentVariables,
      exposedContainer,
      taskPorts,
      isInitialDeploy,
      desiredCount,
      currentStackName,
      createCloudMapService,
    } = this.props;

    let cloudMapService: cloudmap.CfnService = undefined;

    if (createCloudMapService) {
      cloudMapService = new cloudmap.CfnService(this, 'CloudmapService', {
        name: apiName,
        dnsConfig: {
          dnsRecords: [
            {
              ttl: 60,
              type: cloudmap.DnsRecordType.SRV,
            },
          ],
          namespaceId: this.cloudMapNamespaceId,
          routingPolicy: cloudmap.RoutingPolicy.MULTIVALUE,
        },
      });
    }

    const task = new ecs.TaskDefinition(this, 'TaskDefinition', {
      compatibility: ecs.Compatibility.FARGATE,
      memoryMiB: '1024',
      cpu: '512',
      family: `${this.envName}-${apiName}`,
    });
    (task.node.defaultChild as ecs.CfnTaskDefinition).overrideLogicalId('TaskDefinition');
    policies.forEach((policy) => {
      const statement = isPolicyStatement(policy) ? policy : jsonPolicyToCdkPolicyStatement(policy);
      task.addToTaskRolePolicy(statement);
    });

    const containersInfo: {
      container: ecs.ContainerDefinition;
      repository: ecr.IRepository;
    }[] = [];

    containers.forEach(
      ({
        name,
        image,
        build,
        portMappings,
        logConfiguration,
        environment,
        entrypoint: entryPoint,
        command,
        working_dir: workingDirectory,
        healthcheck: healthCheck,
        secrets: containerSecrets,
      }) => {
        const logGroup = new logs.LogGroup(this, `${name}ContainerLogGroup`, {
          logGroupName: `/ecs/${this.envName}-${apiName}-${name}`,
          retention: logs.RetentionDays.ONE_MONTH,
          removalPolicy: cdk.RemovalPolicy.DESTROY,
        });

        const { logDriver, options: { 'awslogs-stream-prefix': streamPrefix } = {} } = logConfiguration;

        const logging: ecs.LogDriver =
          logDriver === 'awslogs'
            ? ecs.LogDriver.awsLogs({
                streamPrefix,
                logGroup: logs.LogGroup.fromLogGroupName(this, `${name}logGroup`, logGroup.logGroupName),
              })
            : undefined;

        let repository: ecr.IRepository;
        if (build) {
          const logicalId = `${name}Repository`;

          const repositoryName = `${currentStackName}-${categoryName}-${apiName}-${name}`;

          if (this.props.existingEcrRepositories.has(repositoryName)) {
            repository = ecr.Repository.fromRepositoryName(this, logicalId, repositoryName);
          } else {
            repository = new ecr.Repository(this, logicalId, {
              repositoryName: `${this.envName}-${categoryName}-${apiName}-${name}`,
              removalPolicy: cdk.RemovalPolicy.RETAIN,
              lifecycleRules: [
                {
                  rulePriority: 10,
                  maxImageCount: 1,
                  tagPrefixList: ['latest'],
                  tagStatus: ecr.TagStatus.TAGGED,
                },
                {
                  rulePriority: 100,
                  maxImageAge: cdk.Duration.days(7),
                  tagStatus: ecr.TagStatus.ANY,
                },
              ],
            });
            (repository.node.defaultChild as ecr.CfnRepository).overrideLogicalId(logicalId);
          }

          // Needed because the image will be pulled from ecr repository later
          repository.grantPull(task.obtainExecutionRole());
        }

        const secrets: ecs.ContainerDefinitionOptions['secrets'] = {};
        const environmentWithoutSecrets = environment || {};

        containerSecrets.forEach((s, i) => {
          if (secretsArns.has(s)) {
            secrets[s] = ecs.Secret.fromSecretsManager(ssm.Secret.fromSecretPartialArn(this, `${name}secret${i + 1}`, secretsArns.get(s)));
          }

          delete environmentWithoutSecrets[s];
        });

        const container = task.addContainer(name, {
          image: repository ? ecs.ContainerImage.fromEcrRepository(repository) : ecs.ContainerImage.fromRegistry(image),
          logging,
          environment: {
            ...taskEnvironmentVariables,
            ...environmentWithoutSecrets,
          },
          entryPoint,
          command,
          workingDirectory,
          healthCheck: healthCheck && {
            command: healthCheck.command,
            interval: cdk.Duration.seconds(healthCheck.interval ?? 30),
            retries: healthCheck.retries,
            timeout: cdk.Duration.seconds(healthCheck.timeout ?? 5),
            startPeriod: cdk.Duration.seconds(healthCheck.start_period ?? 0),
          },
          secrets,
        });

        containersInfo.push({
          container,
          repository,
        });

        // TODO: should we use hostPort too? check network mode
        portMappings?.forEach(({ containerPort, protocol, hostPort }) => {
          container.addPortMappings({
            containerPort,
            protocol: ecs.Protocol.TCP,
          });
        });
      },
    );

    const serviceSecurityGroup = new ec2.CfnSecurityGroup(this, 'ServiceSG', {
      vpcId: this.vpcId,
      groupDescription: 'Service SecurityGroup',
      securityGroupEgress: [
        {
          description: 'Allow all outbound traffic by default',
          cidrIp: '0.0.0.0/0',
          ipProtocol: '-1',
        },
      ],
      securityGroupIngress: taskPorts.map((servicePort) => ({
        ipProtocol: 'tcp',
        fromPort: servicePort,
        toPort: servicePort,
        cidrIp: this.vpcCidrBlock,
      })),
    });

    let serviceRegistries: ecs.CfnService.ServiceRegistryProperty[] = undefined;

    if (cloudMapService) {
      serviceRegistries = [
        {
          containerName: exposedContainer.name,
          containerPort: exposedContainer.port,
          registryArn: cloudMapService.attrArn,
        },
      ];
    }

    const service = new ecs.CfnService(this, 'Service', {
      serviceName: `${apiName}-service-${exposedContainer.name}-${exposedContainer.port}`,
      cluster: this.clusterName,
      launchType: 'FARGATE',
      desiredCount: isInitialDeploy ? 0 : desiredCount, // This is later adjusted by the Predeploy action in the codepipeline
      networkConfiguration: {
        awsvpcConfiguration: {
          assignPublicIp: 'ENABLED',
          securityGroups: [serviceSecurityGroup.attrGroupId],
          subnets: <string[]>this.subnets,
        },
      },
      taskDefinition: task.taskDefinitionArn,
      serviceRegistries,
    });

    new cdk.CfnOutput(this, 'ServiceName', {
      value: service.serviceName,
    });

    new cdk.CfnOutput(this, 'ClusterName', {
      value: this.clusterName,
    });

    return {
      service,
      serviceSecurityGroup,
      containersInfo,
      cloudMapService,
    };
  }

  private pipeline({
    skipWait = false,
    service,
    containersInfo,
    gitHubSourceActionInfo,
  }: {
    skipWait?: boolean;
    service: ecs.CfnService;
    containersInfo: {
      container: ecs.ContainerDefinition;
      repository: ecr.IRepository;
    }[];
    gitHubSourceActionInfo?: GitHubSourceActionInfo;
  }) {
    const { deploymentMechanism, desiredCount } = this.props;

    const s3SourceActionKey = this.zipPath;

    const bucket = s3.Bucket.fromBucketName(this, 'Bucket', this.deploymentBucketName);

    const pipelineWithAwaiter = new PipelineWithAwaiter(this, 'ApiPipeline', {
      skipWait,
      envName: this.envName,
      containersInfo,
      service,
      bucket,
      s3SourceActionKey,
      deploymentMechanism,
      gitHubSourceActionInfo,
      desiredCount,
    });

    pipelineWithAwaiter.node.addDependency(service);

    return { pipelineWithAwaiter };
  }

  protected getPipelineName() {
    return this.pipelineWithAwaiter.getPipelineName();
  }

  getPipelineConsoleUrl(region: string) {
    const pipelineName = this.getPipelineName();
    return `https://${region}.console.aws.amazon.com/codesuite/codepipeline/pipelines/${pipelineName}/view`;
  }

  /**
   * This function renderers a full CFN template for this stack.
   * It is inspired by
   * https://github.com/aws/aws-cdk/blob/bd056d1d38a2d3f43efe4f857c4d38b30fb9b681/packages/%40aws-cdk/assertions/lib/template.ts#L298-L310.
   * This replaces private prepareApp (from CDK v1) and this._toCloudFormation() (the latter does not function properly without the former).
   */
  private renderCfnTemplate(): any {
    const root = this.node.root as cdk.Stage;
    const assembly = root.synth();
    if (this.nestedStackParent) {
      // if this is a nested stack (it has a parent), then just read the template as a string
      return JSON.parse(fs.readFileSync(path.join(assembly.directory, this.templateFile)).toString('utf-8'));
    }
    return assembly.getStackArtifact(this.artifactId).template;
  }

  toCloudFormation() {
    this.node
      .findAll()
      .filter((construct) => construct instanceof CfnFunction)
      .map((construct) => construct as CfnFunction)
      .forEach((lambdaFunction) => {
        if (lambdaFunction.logicalId.includes('AwaiterMyProvider')) {
          lambdaFunction.code = {
            s3Bucket: this.deploymentBucketName,
            s3Key: this.awaiterS3Key,
          };
        }
      });

    const cfn = this.renderCfnTemplate();

    Object.keys(cfn.Parameters).forEach((k) => {
      if (k.startsWith('AssetParameters')) {
        delete cfn.Parameters[k];
      }
    });

    return cfn;
  }
}

/**
 * Return a {iam.PolicyStatement} from JSON IAM policy.
 * This allow us tu pass the statements in a way that CDK can use when synthesizing
 *
 * @param policy JSON object of IAM policy
 * @returns {iam.PolicyStatement} CDK policy statement
 */
function jsonPolicyToCdkPolicyStatement(policy: Record<string, any>): iam.PolicyStatement {
  return new iam.PolicyStatement({
    effect: policy.Effect,
    actions: Array.isArray(policy.Action) ? policy.Action : [policy.Action],
    resources: Array.isArray(policy.Resource) ? policy.Resource.map((r) => cdk.Token.asString(r)) : [cdk.Token.asString(policy.Resource)],
  });
}

function isPolicyStatement(obj: any): obj is iam.PolicyStatement {
  if (obj && typeof (<iam.PolicyStatement>obj).toStatementJson === 'function') {
    return true;
  }

  return false;
}
