import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ProjectEntity } from '../entities/project.entity';
import { WorkspaceEntity } from '../entities/workspace.entity';
import { AccessTag, ProjectAccess } from '../utils/project-access';
import {
  ProjectImportArchiveAsset,
  ProjectImportArchiveWorkspace,
  ProjectImportResponseDTO,
  ProjectImportResponseLogDTO,
} from './dto/project-import.dto';
import { ApiError } from '../common/error/api-error';
import { ApiErrorCodes } from '../common/error/api-error-codes';
import * as AdmZip from 'adm-zip';
import { ValidationError, validateOrReject } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { AssetEntity } from '../entities/asset.entity';
import { decodeBigNumberKey } from '../utils/big-number-key';
import { AssetImportEntity } from '../entities/asset-import.entity';
import { v4 as uuidv4 } from 'uuid';
import { WorkspaceImportEntity } from '../entities/workspace-import.entity';
import { assert } from 'console';
import { AssetService } from '../asset/asset.service';
import { WorkspaceService } from '../workspace/workspace.service';
import { AssetCreateDTO } from '../asset/dto/asset-change-dto';
import {
  AssetPropValueAsset,
  AssetPropValueEnum,
  AssetPropValueText,
  AssetPropValueTextOp,
  AssetProps,
  convertAssetPropValueTextOpsToStr,
  stringifyAssetBlockRef,
} from '../asset/logic/Props';
import { RefService } from '../asset/ref.service';

export const SUPPORTED_ARCHIVE_VERSION = '1.0.0';
export const PROJECT_ARCHIVE_MAX_ENTRIES = 10000;

type RedirectIdEntry = {
  id: string;
  new: boolean;
};

async function readJsonZipEntryByName<T>(
  zip: AdmZip,
  entry: string,
): Promise<T> {
  const str = await new Promise<string>((resolve, reject) => {
    zip.readAsTextAsync(entry, (data, err) => {
      if (err) reject(new Error(err));
      else resolve(data);
    });
  });
  return JSON.parse(str);
}

async function readJsonZipEntry<T>(zip_entry: AdmZip.IZipEntry): Promise<T> {
  const str = await new Promise<string>((resolve, reject) => {
    zip_entry.getDataAsync((data, err) => {
      if (err) reject(new Error(err));
      else resolve(data.toString('utf-8'));
    });
  });
  return JSON.parse(str);
}

type ImportContext = {
  projectAccess: ProjectAccess;
  res: ProjectImportResponseDTO;
  assetRedirectIds: Map<string, RedirectIdEntry>;
  workspaceRedirectIds: Map<string, RedirectIdEntry>;
  importedAssetIds: Set<string>;
  importedWorkspaceIds: Set<string>;
};

@Injectable()
export class ProjectImportService {
  constructor(
    @InjectRepository(ProjectEntity)
    private readonly projectRepo: Repository<ProjectEntity>,
    @InjectRepository(WorkspaceEntity)
    private readonly workspaceRepo: Repository<WorkspaceEntity>,
    @InjectRepository(AssetEntity)
    private readonly assetRepo: Repository<AssetEntity>,
    @InjectRepository(AssetImportEntity)
    private readonly assetImportRepo: Repository<AssetImportEntity>,
    @InjectRepository(WorkspaceImportEntity)
    private readonly workspaceImportRepo: Repository<WorkspaceImportEntity>,
    private readonly workspaceService: WorkspaceService,
    private readonly assetService: AssetService,
    private readonly refService: RefService,
  ) {}

  async importProject(
    projectAccess: ProjectAccess,
    file: Express.Multer.File,
  ): Promise<ProjectImportResponseDTO> {
    if (!projectAccess.accessTags.has(AccessTag.ROOT)) {
      if (!projectAccess.projectLicense?.features?.importProject) {
        throw new ApiError(
          "User hasn't PRO license",
          ApiErrorCodes.ACCESS_DENIED,
        );
      }
      if (!projectAccess.userRole || !projectAccess.userRole.isAdmin) {
        throw new ApiError(
          `Access denied. Only leader can import project`,
          ApiErrorCodes.ACCESS_DENIED,
        );
      }
    }

    const res = new ProjectImportResponseDTO();

    const zip = new AdmZip(file.buffer);

    const index = await readJsonZipEntryByName<any>(zip, 'index.json');
    if (index.version !== SUPPORTED_ARCHIVE_VERSION) {
      throw new ApiError(
        `Unsupported project archive format`,
        ApiErrorCodes.PARAM_BAD_VALUE,
      );
    }
    if (typeof index.ims !== 'string') {
      throw new ApiError(
        `Unsupported project archive format`,
        ApiErrorCodes.PARAM_BAD_VALUE,
      );
    }

    const entries = zip.getEntries();
    if (entries.length > PROJECT_ARCHIVE_MAX_ENTRIES) {
      throw new ApiError(`Too big archive`, ApiErrorCodes.LIMIT_EXCEEDED);
    }

    const assets: ProjectImportArchiveAsset[] = [];
    const asset_ids = new Set<string>();
    const workspaces: ProjectImportArchiveWorkspace[] = [];
    const workspace_ids = new Set<string>();

    for (const entry of entries) {
      if (entry.entryName.startsWith('assets/')) {
        try {
          const asset = plainToInstance(
            ProjectImportArchiveAsset,
            await readJsonZipEntry<ProjectImportArchiveAsset>(entry),
          );
          try {
            await validateOrReject(asset);
          } catch (valerrs: any) {
            throw new Error(
              (valerrs as ValidationError[])
                .map(
                  (valerr) =>
                    `${valerr.property} ${
                      valerr.constraints
                        ? JSON.stringify(valerr.constraints)
                        : ''
                    }`,
                )
                .join('; '),
            );
          }
          if (asset_ids.has(asset.id)) {
            throw new Error('Asset id aleady exists: ' + asset.id);
          }
          asset_ids.add(asset.id);
          assets.push(asset);
        } catch (err: any) {
          res.logs.push(
            new ProjectImportResponseLogDTO(
              'error',
              `Bad entry ${entry.entryName}: ${err.message}`,
            ),
          );
        }
      } else if (entry.entryName.startsWith('workspaces/')) {
        try {
          const workspace = plainToInstance(
            ProjectImportArchiveWorkspace,
            await readJsonZipEntry<ProjectImportArchiveWorkspace>(entry),
          );
          try {
            await validateOrReject(workspace);
          } catch (valerrs: any) {
            throw new Error(
              (valerrs as ValidationError[])
                .map(
                  (valerr) =>
                    `${valerr.property} ${
                      valerr.constraints
                        ? JSON.stringify(valerr.constraints)
                        : ''
                    }`,
                )
                .join('; '),
            );
          }
          if (workspace_ids.has(workspace.id)) {
            throw new Error('Workspace id aleady exists: ' + workspace.id);
          }
          workspace_ids.add(workspace.id);
          workspaces.push(workspace);
        } catch (err: any) {
          res.logs.push(
            new ProjectImportResponseLogDTO(
              'error',
              `Bad entry ${entry.entryName}: ${err.message}`,
            ),
          );
        }
      } else if (entry.entryName === 'index.json') {
        continue;
      } else {
        res.logs.push(
          new ProjectImportResponseLogDTO(
            'warn',
            `Unexpected entry ${entry.entryName}`,
          ),
        );
      }
    }

    const asset_redirect_ids = await this._getAssetRedictingIdsMap(
      projectAccess,
      [...asset_ids],
    );
    const workspace_redirect_ids = await this._getWorkspaceRedictingIdsMap(
      projectAccess,
      [...workspace_ids],
    );
    const context: ImportContext = {
      projectAccess,
      res,
      assetRedirectIds: asset_redirect_ids,
      workspaceRedirectIds: workspace_redirect_ids,
      importedAssetIds: new Set(),
      importedWorkspaceIds: new Set(),
    };

    await this._importWorkspaces(context, workspaces);
    await this._importAssets(context, assets);

    return res;
  }

  private async _importWorkspaces(
    context: ImportContext,
    workspaces: ProjectImportArchiveWorkspace[],
  ) {
    let cur_workspaces = workspaces;
    let skipped: ProjectImportArchiveWorkspace[];
    while (cur_workspaces.length > 0) {
      let any_imported = false;
      skipped = [];
      for (const workspace of cur_workspaces) {
        try {
          const imported = await this._importWorkspace(context, workspace);
          if (imported) any_imported = true;
          else skipped.push(workspace);
        } catch (err: any) {
          context.res.logs.push(
            new ProjectImportResponseLogDTO(
              'error',
              `Cannot import workpace ${workspace.id}: ${err.message}`,
            ),
          );
        }
      }
      cur_workspaces = skipped;
      if (!any_imported) {
        break;
      }
    }
    for (const workspace of cur_workspaces) {
      context.res.logs.push(
        new ProjectImportResponseLogDTO(
          'error',
          `Cannot import workpace ${workspace.id}: dependency is not imported`,
        ),
      );
    }
  }

  private async _importWorkspace(
    context: ImportContext,
    workspace: ProjectImportArchiveWorkspace,
  ): Promise<boolean> {
    if (workspace.parentId) {
      if (
        context.workspaceRedirectIds.has(workspace.parentId) &&
        !context.importedWorkspaceIds.has(workspace.parentId)
      ) {
        return false;
      }
    }

    const redirect = context.workspaceRedirectIds.get(workspace.id);
    assert(redirect, 'No redirect id entry');

    let redirected_parent_id: string | null = null;
    if (workspace.parentId) {
      redirected_parent_id =
        context.workspaceRedirectIds.get(workspace.parentId)?.id ??
        workspace.parentId;
    }

    if (redirect.new) {
      await this.workspaceService.doCreateWorkspaceWithId(
        context.projectAccess.projectId,
        redirect.id,
        {
          title: workspace.title,
          index: workspace.index ?? null,
          parentId: redirected_parent_id,
          scope: workspace.scope,
        },
      );
      await this.workspaceImportRepo.insert({
        projectId: context.projectAccess.projectId,
        importedId: workspace.id,
        newId: redirect.id,
      });
      context.res.createdWorkspaces++;
    } else {
      await this.workspaceService.doChangeWorkspace(
        context.projectAccess.projectId,
        redirect.id,
        {
          title: workspace.title,
          index: workspace.index ?? null,
          parentId: redirected_parent_id,
        },
      );
      context.res.updatedWorkspaces++;
    }

    context.importedWorkspaceIds.add(workspace.id);

    return true;
  }

  private async _importAssets(
    context: ImportContext,
    assets: ProjectImportArchiveAsset[],
  ) {
    // Import assets
    let left_assets = assets;
    let skipped: ProjectImportArchiveAsset[];
    let ignore_missing_deps = false;
    while (left_assets.length > 0) {
      let any_imported = false;
      skipped = [];
      for (const asset of left_assets) {
        try {
          const imported = await this._importAssetContent(
            context,
            asset,
            context.res.logs,
            ignore_missing_deps,
          );
          if (imported) any_imported = true;
          else skipped.push(asset);
        } catch (err: any) {
          context.res.logs.push(
            new ProjectImportResponseLogDTO(
              'error',
              `Cannot import asset ${asset.id}: ${err.message}`,
            ),
          );
        }
      }
      left_assets = skipped;
      if (!any_imported) {
        if (!ignore_missing_deps) {
          ignore_missing_deps = true;
        } else {
          break;
        }
      }
    }
    for (const asset of left_assets) {
      context.res.logs.push(
        new ProjectImportResponseLogDTO(
          'error',
          `Cannot import asset ${asset.id}: dependency is not imported`,
        ),
      );
    }

    // Create refs
    for (const asset of assets) {
      if (!context.importedAssetIds.has(asset.id)) {
        continue;
      }
      try {
        await this._importAssetReferences(
          context,
          asset,
          context.res.logs,
          ignore_missing_deps,
        );
      } catch (err: any) {
        context.res.logs.push(
          new ProjectImportResponseLogDTO(
            'error',
            `Cannot assign references for asset ${asset.id}: ${err.message}`,
          ),
        );
      }
    }
  }

  private async _importAssetContent(
    context: ImportContext,
    asset: ProjectImportArchiveAsset,
    logs: ProjectImportResponseLogDTO[],
    ignoreMissingDependencies = false,
  ): Promise<boolean> {
    const redirected_parent_ids: string[] = [];
    if (asset.parentIds) {
      for (const parent of asset.parentIds) {
        if (
          context.assetRedirectIds.has(parent) &&
          !context.importedAssetIds.has(parent)
        ) {
          if (!ignoreMissingDependencies) {
            return false;
          } else {
            logs.push(
              new ProjectImportResponseLogDTO(
                'warn',
                `Cannot find parent for asset ${asset.id}: dependency ${parent} is not imported`,
              ),
            );
          }
        } else {
          redirected_parent_ids.push(
            context.assetRedirectIds.get(parent)?.id ?? parent,
          );
        }
      }
    }

    const redirect = context.assetRedirectIds.get(asset.id);
    assert(redirect, 'No redirect id entry');

    const workspace_id = asset.workspaceId
      ? context.workspaceRedirectIds.get(asset.workspaceId)?.id ??
        asset.workspaceId
      : null;

    const content: AssetCreateDTO = {
      main: {
        icon: asset.ownIcon,
        index: asset.index,
        isAbstract: asset.isAbstract,
        name: asset.name,
        parentIds: redirected_parent_ids,
        scope: asset.scope,
        title: asset.ownTitle,
        workspaceId: workspace_id,
      },
      blocks: {},
      props: {},
    };

    for (const block of asset.blocks) {
      const ref = stringifyAssetBlockRef(
        block.name,
        block.ownTitle ? block.ownTitle : block.title,
        block.type,
      );
      content.blocks[ref] = {
        index: block.index,
        title: block.ownTitle,
      };
      content.props[ref] = this._replaceImportedLinksInProps(
        context,
        block.props,
      );
    }

    if (redirect.new) {
      await this.assetService.createAsset(
        content,
        context.projectAccess,
        redirect.id,
      );
      await this.assetImportRepo.insert({
        projectId: context.projectAccess.projectId,
        importedId: asset.id,
        newId: redirect.id,
      });
      context.res.createdAssets++;
    } else {
      await this.assetService.changeAssets(
        {
          id: redirect.id,
        },
        content,
        context.projectAccess,
      );
      context.res.updatedAssets++;
    }

    context.importedAssetIds.add(asset.id);

    return true;
  }

  private async _importAssetReferences(
    context: ImportContext,
    asset: ProjectImportArchiveAsset,
    logs: ProjectImportResponseLogDTO[],
    ignoreMissingDependencies = false,
  ): Promise<boolean> {
    if (!asset.references || asset.references.length < 1) {
      return true;
    }

    const redirect = context.assetRedirectIds.get(asset.id);
    assert(redirect, 'No redirect id entry');

    for (const reference of asset.references) {
      const ref_target_id = reference.targetAssetId
        ? context.assetRedirectIds.get(reference.targetAssetId)?.id ??
          reference.targetAssetId
        : null;
      if (ref_target_id) {
        await this.refService.doCreateRefs(
          this.assetRepo.manager,
          context.projectAccess.projectId,
          [redirect.id],
          reference.sourceBlockRef ?? null,
          ref_target_id,
          reference.targetBlockRef ?? null,
        );
      } else {
        if (!ignoreMissingDependencies) {
          return false;
        } else {
          logs.push(
            new ProjectImportResponseLogDTO(
              'warn',
              `Cannot find reference for asset ${asset.id}: dependency ${reference.targetAssetId} is not imported`,
            ),
          );
        }
      }
    }

    return true;
  }

  private async _getAssetRedictingIdsMap(
    projectAccess: ProjectAccess,
    ids: string[],
  ): Promise<Map<string, RedirectIdEntry>> {
    const left_ids = new Set(ids);
    const redirected_map = new Map<string, RedirectIdEntry>();
    if (left_ids.size > 0) {
      const existent_same_assets = await this.assetRepo
        .createQueryBuilder('a')
        .where('a.deleted_at IS NULL')
        .andWhere('a.project_id = :project_id', {
          project_id: decodeBigNumberKey(projectAccess.projectId),
        })
        .andWhere('a.id = ANY(:ids)', {
          ids,
        })
        .select(['id'])
        .getRawMany<{ id: string }>();
      for (const record of existent_same_assets) {
        redirected_map.set(record.id, {
          id: record.id,
          new: false,
        });
        left_ids.delete(record.id);
      }
    }
    if (left_ids.size > 0) {
      const existent_imports = await this.assetImportRepo
        .createQueryBuilder('ai')
        .innerJoin(
          AssetEntity,
          'a',
          'a.project_id = ai.project_id AND a.id = ai.new_id',
        )
        .where('a.deleted_at IS NULL')
        .andWhere('ai.project_id = :project_id', {
          project_id: decodeBigNumberKey(projectAccess.projectId),
        })
        .andWhere('ai.imported_id = ANY(:ids)', {
          ids,
        })
        .select(['ai.imported_id', 'ai.new_id'])
        .getRawMany<{ imported_id: string; new_id: string }>();
      for (const record of existent_imports) {
        redirected_map.set(record.imported_id, {
          id: record.new_id,
          new: false,
        });
        left_ids.delete(record.imported_id);
      }
    }
    if (left_ids.size > 0) {
      for (const left_id of left_ids) {
        redirected_map.set(left_id, {
          id: uuidv4(),
          new: true,
        });
      }
    }
    return redirected_map;
  }

  private async _getWorkspaceRedictingIdsMap(
    projectAccess: ProjectAccess,
    ids: string[],
  ): Promise<Map<string, RedirectIdEntry>> {
    const left_ids = new Set(ids);
    const redirected_map = new Map<string, RedirectIdEntry>();
    if (left_ids.size > 0) {
      const existent_same_assets = await this.workspaceRepo
        .createQueryBuilder('w')
        .where('w.deleted_at IS NULL')
        .andWhere('w.project_id = :project_id', {
          project_id: decodeBigNumberKey(projectAccess.projectId),
        })
        .andWhere('w.id = ANY(:ids)', {
          ids,
        })
        .select(['id'])
        .getRawMany<{ id: string }>();
      for (const record of existent_same_assets) {
        redirected_map.set(record.id, {
          id: record.id,
          new: false,
        });
        left_ids.delete(record.id);
      }
    }
    if (left_ids.size > 0) {
      const existent_imports = await this.workspaceImportRepo
        .createQueryBuilder('wi')
        .innerJoin(
          WorkspaceEntity,
          'w',
          'w.project_id = wi.project_id AND w.id = wi.new_id',
        )
        .where('w.deleted_at IS NULL')
        .andWhere('wi.project_id = :project_id', {
          project_id: decodeBigNumberKey(projectAccess.projectId),
        })
        .andWhere('wi.imported_id = ANY(:ids)', {
          ids,
        })
        .select(['wi.imported_id', 'wi.new_id'])
        .getRawMany<{ imported_id: string; new_id: string }>();
      for (const record of existent_imports) {
        redirected_map.set(record.imported_id, {
          id: record.new_id,
          new: false,
        });
        left_ids.delete(record.imported_id);
      }
    }
    if (left_ids.size > 0) {
      for (const left_id of left_ids) {
        redirected_map.set(left_id, {
          id: uuidv4(),
          new: true,
        });
      }
    }
    return redirected_map;
  }

  private _replaceImportedLinksInPropsForPropValueAsset(
    context: ImportContext,
    value: AssetPropValueAsset,
  ): AssetPropValueAsset {
    const redirected_asset_id =
      context.assetRedirectIds.get(value.assetId)?.id ?? value.assetId;
    return {
      ...value,
      assetId: redirected_asset_id,
    };
  }

  private _replaceImportedLinksInPropsForPropValueText(
    context: ImportContext,
    value: AssetPropValueText,
  ): AssetPropValueText {
    const res_ops: AssetPropValueTextOp[] = [];
    for (const op of value.ops) {
      if (op.attributes && op.attributes.asset) {
        const ch_asset = op.attributes.asset;
        const redirected_asset_id =
          context.assetRedirectIds.get(ch_asset.assetId)?.id ??
          ch_asset.assetId;
        res_ops.push({
          ...op,
          attributes: {
            ...op.attributes,
            asset: {
              ...op.attributes.asset,
              assetId: redirected_asset_id,
            },
          },
        });
      } else {
        res_ops.push(op);
      }
    }
    const conv = convertAssetPropValueTextOpsToStr(res_ops);
    return {
      ops: res_ops,
      str: conv.str,
    };
  }

  private _replaceImportedLinksInPropsForPropValueEnum(
    context: ImportContext,
    value: AssetPropValueEnum,
  ): AssetPropValueEnum {
    const redirected_asset_id =
      context.assetRedirectIds.get(value.enum)?.id ?? value.enum;
    return {
      ...value,
      enum: redirected_asset_id,
    };
  }

  private _replaceImportedLinksInProps(
    context: ImportContext,
    props: AssetProps,
  ): AssetProps {
    return Object.fromEntries(
      Object.entries(props).map(([key, value]) => {
        if (value) {
          if ((value as AssetPropValueAsset).assetId) {
            value = this._replaceImportedLinksInPropsForPropValueAsset(
              context,
              value as AssetPropValueAsset,
            );
          } else if ((value as AssetPropValueText).ops) {
            value = this._replaceImportedLinksInPropsForPropValueText(
              context,
              value as AssetPropValueText,
            );
          } else if ((value as AssetPropValueEnum).enum) {
            value = this._replaceImportedLinksInPropsForPropValueEnum(
              context,
              value as AssetPropValueEnum,
            );
          }
        }
        return [key, value];
      }),
    );
  }
}
