import {
  ECRClient,
  CreateRepositoryCommand,
  DescribeRepositoriesCommand,
  type Repository,
  type Tag,
  RepositoryNotFoundException,
  GetAuthorizationTokenCommand,
  PutLifecyclePolicyCommand,
  PutImageTagMutabilityCommand,
  SetRepositoryPolicyCommand,
} from "@aws-sdk/client-ecr";
import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts";
import { tryCatch } from "@trigger.dev/core";
import { logger } from "~/services/logger.server";
import { type RegistryConfig } from "./registryConfig.server";
import type { EnvironmentType } from "@trigger.dev/core/v3";

// Optional configuration for cross-account access
export type AssumeRoleConfig = {
  roleArn?: string;
  externalId?: string;
};

async function getAssumedRoleCredentials({
  region,
  assumeRole,
}: {
  region: string;
  assumeRole?: AssumeRoleConfig;
}): Promise<{
  accessKeyId: string;
  secretAccessKey: string;
  sessionToken: string;
}> {
  const sts = new STSClient({ region });

  // Generate a unique session name using timestamp and random string
  // This helps with debugging but doesn't affect concurrent sessions
  const timestamp = Date.now();
  const randomSuffix = Math.random().toString(36).substring(2, 8);
  const sessionName = `TriggerWebappECRAccess_${timestamp}_${randomSuffix}`;

  const [error, response] = await tryCatch(
    sts.send(
      new AssumeRoleCommand({
        RoleArn: assumeRole?.roleArn,
        RoleSessionName: sessionName,
        // Sessions automatically expire after 1 hour
        // AWS allows 5000 concurrent sessions by default
        DurationSeconds: 3600,
        ExternalId: assumeRole?.externalId,
      })
    )
  );

  if (error) {
    logger.error("Failed to assume role", {
      assumeRole,
      sessionName,
      error: error.message,
    });
    throw error;
  }

  if (!response.Credentials) {
    throw new Error("STS: No credentials returned from assumed role");
  }

  if (
    !response.Credentials.AccessKeyId ||
    !response.Credentials.SecretAccessKey ||
    !response.Credentials.SessionToken
  ) {
    throw new Error("STS: Invalid credentials returned from assumed role");
  }

  return {
    accessKeyId: response.Credentials.AccessKeyId,
    secretAccessKey: response.Credentials.SecretAccessKey,
    sessionToken: response.Credentials.SessionToken,
  };
}

export async function createEcrClient({
  region,
  assumeRole,
}: {
  region: string;
  assumeRole?: AssumeRoleConfig;
}) {
  if (!assumeRole) {
    return new ECRClient({ region });
  }

  // Get credentials for cross-account access
  const credentials = await getAssumedRoleCredentials({ region, assumeRole });
  return new ECRClient({
    region,
    credentials,
  });
}

export async function getDeploymentImageRef({
  registry,
  projectRef,
  nextVersion,
  environmentType,
  deploymentShortCode,
}: {
  registry: RegistryConfig;
  projectRef: string;
  nextVersion: string;
  environmentType: EnvironmentType;
  deploymentShortCode: string;
}): Promise<{
  imageRef: string;
  isEcr: boolean;
  repoCreated: boolean;
}> {
  const repositoryName = `${registry.namespace}/${projectRef}`;
  const envType = environmentType.toLowerCase();
  const imageRef = `${registry.host}/${repositoryName}:${nextVersion}.${envType}.${deploymentShortCode}`;

  if (!isEcrRegistry(registry.host)) {
    return {
      imageRef,
      isEcr: false,
      repoCreated: false,
    };
  }

  const [ecrRepoError, ecrData] = await tryCatch(
    ensureEcrRepositoryExists({
      repositoryName,
      registryHost: registry.host,
      registryTags: registry.ecrTags,
      assumeRole: {
        roleArn: registry.ecrAssumeRoleArn,
        externalId: registry.ecrAssumeRoleExternalId,
      },
      defaultRepositoryPolicy: registry.ecrDefaultRepositoryPolicy,
    })
  );

  if (ecrRepoError) {
    logger.error("Failed to ensure ECR repository exists", {
      repositoryName,
      host: registry.host,
      ecrRepoError: ecrRepoError.message,
    });
    throw ecrRepoError;
  }

  return {
    imageRef,
    isEcr: true,
    repoCreated: ecrData.repoCreated,
  };
}

export function isEcrRegistry(registryHost: string) {
  try {
    parseEcrRegistryDomain(registryHost);
    return true;
  } catch {
    return false;
  }
}

export function parseRegistryTags(tags: string): Tag[] {
  if (!tags) {
    return [];
  }

  return tags
    .split(",")
    .map((t) => {
      const tag = t.trim();
      if (tag.length === 0) {
        return null;
      }

      // If there's no '=' in the tag, treat the whole tag as the key with an empty value
      const equalIndex = tag.indexOf("=");
      const key = equalIndex === -1 ? tag : tag.slice(0, equalIndex);
      const value = equalIndex === -1 ? "" : tag.slice(equalIndex + 1);

      if (key.trim().length === 0) {
        logger.warn("Invalid ECR tag format (empty key), skipping tag", { tag: t });
        return null;
      }

      return {
        Key: key.trim(),
        Value: value.trim(),
      } as Tag;
    })
    .filter((tag): tag is Tag => tag !== null);
}

const untaggedImageExpirationPolicy = JSON.stringify({
  rules: [
    {
      rulePriority: 1,
      description: "Expire untagged images older than 3 days",
      selection: {
        tagStatus: "untagged",
        countType: "sinceImagePushed",
        countUnit: "days",
        countNumber: 3,
      },
      action: { type: "expire" },
    },
  ],
});

async function createEcrRepository({
  repositoryName,
  region,
  accountId,
  registryTags,
  assumeRole,
  defaultRepositoryPolicy,
}: {
  repositoryName: string;
  region: string;
  accountId?: string;
  registryTags?: string;
  assumeRole?: AssumeRoleConfig;
  defaultRepositoryPolicy?: string;
}): Promise<Repository> {
  const ecr = await createEcrClient({ region, assumeRole });

  const result = await ecr.send(
    new CreateRepositoryCommand({
      repositoryName,
      imageTagMutability: "IMMUTABLE_WITH_EXCLUSION",
      imageTagMutabilityExclusionFilters: [
        {
          // only the `cache` tag will be mutable, all other tags will be immutable
          filter: "cache",
          filterType: "WILDCARD",
        },
      ],
      encryptionConfiguration: {
        encryptionType: "AES256",
      },
      registryId: accountId,
      tags: registryTags ? parseRegistryTags(registryTags) : undefined,
    })
  );

  if (!result.repository) {
    logger.error("Failed to create ECR repository", { repositoryName, result });
    throw new Error(`Failed to create ECR repository: ${repositoryName}`);
  }

  // When the `cache` tag is mutated, the old cache images are untagged.
  // This policy matches those images and expires them to avoid bloating the repository.
  await ecr.send(
    new PutLifecyclePolicyCommand({
      repositoryName: result.repository.repositoryName,
      registryId: result.repository.registryId,
      lifecyclePolicyText: untaggedImageExpirationPolicy,
    })
  );

  // Apply an operator-provided IAM policy to the new repository. Useful for
  // self-hosters whose ECR account is separate from the account running the
  // EKS workers — without this the workers get 403 Forbidden when pulling the
  // task image (default ECR policy only grants access to the registry owner).
  // The existing-repo branch of `ensureEcrRepositoryExists` reconciles this
  // same policy on every call, so a partial-create that fails here is
  // self-healing on the next deploy.
  if (defaultRepositoryPolicy) {
    await applyEcrRepositoryPolicy({
      repositoryName: result.repository.repositoryName!,
      region,
      accountId: result.repository.registryId ?? accountId,
      assumeRole,
      defaultRepositoryPolicy,
    });
  }

  return result.repository;
}

async function applyEcrRepositoryPolicy({
  repositoryName,
  region,
  accountId,
  assumeRole,
  defaultRepositoryPolicy,
}: {
  repositoryName: string;
  region: string;
  accountId?: string;
  assumeRole?: AssumeRoleConfig;
  defaultRepositoryPolicy: string;
}): Promise<void> {
  const ecr = await createEcrClient({ region, assumeRole });

  await ecr.send(
    new SetRepositoryPolicyCommand({
      repositoryName,
      registryId: accountId,
      policyText: defaultRepositoryPolicy,
    })
  );
}

async function updateEcrRepositoryCacheSettings({
  repositoryName,
  region,
  accountId,
  assumeRole,
}: {
  repositoryName: string;
  region: string;
  accountId?: string;
  assumeRole?: AssumeRoleConfig;
}): Promise<void> {
  logger.debug("Updating ECR repository tag mutability to IMMUTABLE_WITH_EXCLUSION", {
    repositoryName,
    region,
  });

  const ecr = await createEcrClient({ region, assumeRole });

  await ecr.send(
    new PutImageTagMutabilityCommand({
      repositoryName,
      registryId: accountId,
      imageTagMutability: "IMMUTABLE_WITH_EXCLUSION",
      imageTagMutabilityExclusionFilters: [
        {
          // only the `cache` tag will be mutable, all other tags will be immutable
          filter: "cache",
          filterType: "WILDCARD",
        },
      ],
    })
  );

  // When the `cache` tag is mutated, the old cache images are untagged.
  // This policy matches those images and expires them to avoid bloating the repository.
  await ecr.send(
    new PutLifecyclePolicyCommand({
      repositoryName,
      registryId: accountId,
      lifecyclePolicyText: untaggedImageExpirationPolicy,
    })
  );

  logger.debug("Successfully updated ECR repository to IMMUTABLE_WITH_EXCLUSION", {
    repositoryName,
    region,
  });
}

async function getEcrRepository({
  repositoryName,
  region,
  accountId,
  assumeRole,
}: {
  repositoryName: string;
  region: string;
  accountId?: string;
  assumeRole?: AssumeRoleConfig;
}): Promise<Repository | undefined> {
  const ecr = await createEcrClient({ region, assumeRole });

  try {
    const result = await ecr.send(
      new DescribeRepositoriesCommand({
        repositoryNames: [repositoryName],
        registryId: accountId,
      })
    );

    if (!result.repositories || result.repositories.length === 0) {
      logger.debug("ECR repository not found", { repositoryName, region, result });
      return undefined;
    }

    return result.repositories[0];
  } catch (error) {
    if (
      error instanceof RepositoryNotFoundException ||
      (error instanceof Error && error.message?.includes("does not exist"))
    ) {
      logger.debug("ECR repository not found: RepositoryNotFoundException", {
        repositoryName,
        region,
      });
      return undefined;
    }
    throw error;
  }
}

export type EcrRegistryComponents = {
  accountId: string;
  region: string;
};

export function parseEcrRegistryDomain(registryHost: string): EcrRegistryComponents {
  const parts = registryHost.split(".");

  const isValid =
    parts.length === 6 &&
    parts[1] === "dkr" &&
    parts[2] === "ecr" &&
    parts[4] === "amazonaws" &&
    parts[5] === "com";

  if (!isValid) {
    throw new Error(`Invalid ECR registry host: ${registryHost}`);
  }

  return {
    accountId: parts[0],
    region: parts[3],
  };
}

async function ensureEcrRepositoryExists({
  repositoryName,
  registryHost,
  registryTags,
  assumeRole,
  defaultRepositoryPolicy,
}: {
  repositoryName: string;
  registryHost: string;
  registryTags?: string;
  assumeRole?: AssumeRoleConfig;
  defaultRepositoryPolicy?: string;
}): Promise<{ repo: Repository; repoCreated: boolean }> {
  const { region, accountId } = parseEcrRegistryDomain(registryHost);

  const [getRepoError, existingRepo] = await tryCatch(
    getEcrRepository({ repositoryName, region, accountId, assumeRole })
  );

  if (getRepoError) {
    logger.error("Failed to get ECR repository", { repositoryName, region, getRepoError });
    throw getRepoError;
  }

  if (existingRepo) {
    logger.debug("ECR repository already exists", { repositoryName, region, existingRepo });

    // check if the repository is missing the cache settings
    if (existingRepo.imageTagMutability === "IMMUTABLE") {
      const [updateError] = await tryCatch(
        updateEcrRepositoryCacheSettings({ repositoryName, region, accountId, assumeRole })
      );

      if (updateError) {
        logger.error("Failed to update ECR repository cache settings", {
          repositoryName,
          region,
          updateError,
        });
      }
    }

    // Reconcile the default repository policy on every call. Idempotent, and
    // covers two recovery cases: (1) a previous create succeeded but the
    // SetRepositoryPolicy call failed mid-flight, leaving the repo without a
    // policy; (2) the operator updated DEPLOY_REGISTRY_ECR_DEFAULT_REPOSITORY_POLICY
    // and existing repos need to pick up the new value.
    if (defaultRepositoryPolicy) {
      const [policyError] = await tryCatch(
        applyEcrRepositoryPolicy({
          repositoryName,
          region,
          accountId,
          assumeRole,
          defaultRepositoryPolicy,
        })
      );

      if (policyError) {
        logger.error("Failed to reconcile ECR repository policy on existing repo", {
          repositoryName,
          region,
          policyError,
        });
      }
    }

    return {
      repo: existingRepo,
      repoCreated: false,
    };
  }

  const [createRepoError, newRepo] = await tryCatch(
    createEcrRepository({
      repositoryName,
      region,
      accountId,
      registryTags,
      assumeRole,
      defaultRepositoryPolicy,
    })
  );

  if (createRepoError) {
    logger.error("Failed to create ECR repository", { repositoryName, region, createRepoError });
    throw createRepoError;
  }

  if (newRepo.repositoryName !== repositoryName) {
    logger.error("ECR repository name mismatch", { repositoryName, region, newRepo });
    throw new Error(
      `ECR repository name mismatch: ${repositoryName} !== ${newRepo.repositoryName}`
    );
  }

  return {
    repo: newRepo,
    repoCreated: true,
  };
}

export async function getEcrAuthToken({
  registryHost,
  assumeRole,
}: {
  registryHost: string;
  assumeRole?: AssumeRoleConfig;
}): Promise<{ username: string; password: string }> {
  const { region, accountId } = parseEcrRegistryDomain(registryHost);
  if (!region) {
    logger.error("Invalid ECR registry host", { registryHost });
    throw new Error("Invalid ECR registry host");
  }

  const ecr = await createEcrClient({ region, assumeRole });
  const response = await ecr.send(
    new GetAuthorizationTokenCommand({
      registryIds: accountId ? [accountId] : undefined,
    })
  );

  if (!response.authorizationData) {
    throw new Error("Failed to get ECR authorization token");
  }

  const authData = response.authorizationData[0];

  if (!authData.authorizationToken) {
    throw new Error("No authorization token returned from ECR");
  }

  const authToken = Buffer.from(authData.authorizationToken, "base64").toString();
  const [username, password] = authToken.split(":");

  return { username, password };
}
