import {
  DISPO_REQUIRED_ROLE_SEEDS,
  DISPO_UTILIZATION_CATEGORIES,
  normalizeDispoRoleToken,
} from "@capakraken/shared";
import type { Prisma } from "@capakraken/db";
import {
  AllocationStatus,
  ImportBatchStatus,
  ProjectStatus,
  StagedRecordStatus,
  VacationStatus,
} from "@capakraken/db";
import {
  buildBatchSummaryEntry,
  buildFallbackAccentureEmail,
  toJsonObject,
} from "./shared.js";
import { recomputeResourceValueScores } from "../resource/recompute-resource-value-scores.js";
import { classifyDispoProject } from "./tbd-projects.js";
import type { CommitDbClient, MergedStagedResource, TxClient } from "./commit-dispo-batch-types.js";
import { validateDispoBatch } from "./validate-dispo-batch.js";
import type { ReferenceDataMaps } from "./build-dispo-maps.js";
import {
  buildReferenceDataMaps,
  inferRoleNameFromResource,
  mergeStagedResources,
  parseWeekdayAvailability,
} from "./build-dispo-maps.js";
import {
  aggregateAssignments,
  deriveOverlayAvailability,
} from "./determine-placement.js";

export interface CommitDispoImportBatchInput {
  allowTbdUnresolved?: boolean;
  importTbdProjects?: boolean;
  importBatchId: string;
}

export interface CommitDispoImportBatchResult {
  batchId: string;
  counts: {
    committedAssignments: number;
    committedProjects: number;
    committedResources: number;
    committedVacations: number;
    updatedEntitlements: number;
    updatedResourceAvailabilities: number;
    upsertedResourceRoles: number;
  };
  unresolved: {
    blocked: number;
    skippedTbd: number;
  };
}

function asObject(value: unknown): Record<string, unknown> {
  return value && typeof value === "object" && !Array.isArray(value)
    ? (value as Record<string, unknown>)
    : {};
}

function normalizeDate(date: Date): Date {
  return new Date(`${date.toISOString().slice(0, 10)}T00:00:00.000Z`);
}

function roundToOneDecimal(value: number): number {
  return Math.round(value * 10) / 10;
}

async function upsertRoleSeeds(tx: TxClient) {
  for (const role of DISPO_REQUIRED_ROLE_SEEDS) {
    await tx.role.upsert({
      where: { name: role.name },
      update: { description: role.description, isActive: true },
      create: { ...role, isActive: true },
    });
  }
}

async function upsertUtilizationCategories(tx: TxClient) {
  for (const category of DISPO_UTILIZATION_CATEGORIES) {
    await tx.utilizationCategory.upsert({
      where: { code: category.code },
      update: {
        description: category.description,
        isActive: true,
        isDefault: category.isDefault,
        name: category.name,
        sortOrder: category.sortOrder,
      },
      create: category,
    });
  }
}

async function ensureInferredRolesExist(
  tx: TxClient,
  mergedResources: Map<string, MergedStagedResource>,
  roleIdByName: Map<string, string>,
) {
  const inferredRoleNames = new Set<string>();
  for (const resource of mergedResources.values()) {
    const name = inferRoleNameFromResource(resource);
    if (name) inferredRoleNames.add(name);
  }
  for (const roleName of inferredRoleNames) {
    if (roleIdByName.has(roleName)) continue;
    const role = await tx.role.upsert({
      where: { name: roleName },
      update: { description: "Imported Dispo resource role", isActive: true },
      create: { name: roleName, description: "Imported Dispo resource role", isActive: true },
      select: { id: true, name: true },
    });
    roleIdByName.set(role.name, role.id);
  }
}

function buildResourceData(
  resource: MergedStagedResource,
  maps: ReferenceDataMaps,
  batchId: string,
) {
  const managementGroup = resource.managementLevelGroupName
    ? maps.managementLevelGroupByName.get(resource.managementLevelGroupName)
    : null;
  const defaultChargeabilityTarget = managementGroup
    ? roundToOneDecimal(managementGroup.targetPercentage * 100)
    : 80;
  const availability = parseWeekdayAvailability(resource.availability, resource.fte);
  const rawPayload = resource.rawPayload;
  const orgUnitId =
    (typeof rawPayload.sapOrgUnitLevelSeven === "string"
      ? maps.orgUnitIdByLevelAndName.get(`7:${rawPayload.sapOrgUnitLevelSeven.toLowerCase()}`)
      : null) ??
    (typeof rawPayload.sapOrgUnitLevelSix === "string"
      ? maps.orgUnitIdByLevelAndName.get(`6:${rawPayload.sapOrgUnitLevelSix.toLowerCase()}`)
      : null) ??
    (typeof rawPayload.sapOrgUnitLevelFive === "string"
      ? maps.orgUnitIdByLevelAndName.get(`5:${rawPayload.sapOrgUnitLevelFive.toLowerCase()}`)
      : null) ??
    null;

  const shared = {
    availability: availability as unknown as Prisma.InputJsonValue,
    chapter: resource.chapter,
    chargeabilityTarget: resource.chargeabilityTarget ?? defaultChargeabilityTarget,
    clientUnitId: resource.clientUnitName
      ? (maps.clientIdByCode.get(resource.clientUnitName) ?? maps.clientIdByName.get(resource.clientUnitName.toLowerCase()) ?? null)
      : null,
    countryId: resource.countryCode ? (maps.countryIdByCode.get(resource.countryCode) ?? null) : null,
    displayName: resource.displayName ?? resource.canonicalExternalId,
    email: resource.email ?? buildFallbackAccentureEmail(resource.canonicalExternalId),
    fte: resource.fte ?? 1,
    lcrCents: resource.lcrCents ?? 0,
    managementLevelGroupId: resource.managementLevelGroupName ? (managementGroup?.id ?? null) : null,
    managementLevelId: resource.managementLevelName
      ? (maps.managementLevelIdByName.get(resource.managementLevelName) ?? null)
      : null,
    metroCityId: resource.metroCityName
      ? (maps.metroCityIdByName.get(resource.metroCityName.toLowerCase()) ?? null)
      : null,
    orgUnitId,
    resourceType: resource.resourceType ?? "EMPLOYEE",
    ucrCents: resource.ucrCents ?? 0,
  } as const;

  const dynamicFields = {
    dispoImport: {
      importBatchId: batchId,
      sourceKinds: resource.sourceKinds,
      warnings: resource.warnings,
    },
  } as Prisma.InputJsonValue;

  return { shared, dynamicFields, managementGroup };
}

interface CommitResourcesResult {
  resourceIdByKey: Map<string, string>;
  resourceRoleNameByKey: Map<string, string>;
  updatedEntitlements: number;
  updatedResourceAvailabilities: number;
  upsertedResourceRoles: number;
}

async function commitResources(
  tx: TxClient,
  mergedResources: Map<string, MergedStagedResource>,
  maps: ReferenceDataMaps,
  batchId: string,
  stagedVacations: Awaited<ReturnType<TxClient["stagedVacation"]["findMany"]>>,
  stagedAvailabilityRules: Awaited<ReturnType<TxClient["stagedAvailabilityRule"]["findMany"]>>,
): Promise<CommitResourcesResult> {
  const resourceIdByKey = new Map<string, string>();
  const resourceRoleNameByKey = new Map<string, string>();
  let upsertedResourceRoles = 0;
  let updatedResourceAvailabilities = 0;
  let updatedEntitlements = 0;

  for (const resource of mergedResources.values()) {
    const inferredRoleName = inferRoleNameFromResource(resource);
    if (inferredRoleName) {
      resourceRoleNameByKey.set(resource.canonicalExternalId, inferredRoleName);
    }

    const { shared, dynamicFields } = buildResourceData(resource, maps, batchId);

    const committed = await tx.resource.upsert({
      where: { eid: resource.canonicalExternalId },
      update: { ...shared, enterpriseId: resource.canonicalExternalId, dynamicFields },
      create: {
        ...shared,
        dynamicFields,
        eid: resource.canonicalExternalId,
        enterpriseId: resource.canonicalExternalId,
      },
      select: { id: true, availability: true },
    });

    resourceIdByKey.set(resource.canonicalExternalId, committed.id);

    // Upsert resource roles
    const resourceRoleNames = new Set(
      Array.from(resource.roleTokens)
        .map((roleToken) => normalizeDispoRoleToken(roleToken))
        .filter((roleName): roleName is string => Boolean(roleName)),
    );
    if (inferredRoleName) resourceRoleNames.add(inferredRoleName);

    for (const roleName of resourceRoleNames) {
      const roleId = maps.roleIdByName.get(roleName);
      if (!roleId) continue;
      await tx.resourceRole.upsert({
        where: { resourceId_roleId: { resourceId: committed.id, roleId } },
        update: {},
        create: { resourceId: committed.id, roleId },
      });
      upsertedResourceRoles += 1;
    }

    // Upsert vacation entitlements
    if (resource.vacationDaysPerYear !== null && resource.vacationDaysPerYear !== undefined) {
      const entitlementYears = new Set<number>(
        stagedVacations
          .filter((v) => v.resourceExternalId === resource.canonicalExternalId)
          .map((v) => v.startDate.getUTCFullYear()),
      );
      if (entitlementYears.size === 0) entitlementYears.add(new Date().getUTCFullYear());
      for (const year of entitlementYears) {
        await tx.vacationEntitlement.upsert({
          where: { resourceId_year: { resourceId: committed.id, year } },
          update: { entitledDays: resource.vacationDaysPerYear },
          create: { resourceId: committed.id, year, entitledDays: resource.vacationDaysPerYear },
        });
        updatedEntitlements += 1;
      }
    }

    // Apply availability overlay rules
    const resourceRules = stagedAvailabilityRules.filter(
      (rule) => rule.resourceExternalId === resource.canonicalExternalId,
    );
    if (resourceRules.length > 0) {
      const overlaidAvailability = deriveOverlayAvailability(
        parseWeekdayAvailability(committed.availability, resource.fte),
        resourceRules,
      );
      await tx.resource.update({
        where: { id: committed.id },
        data: {
          availability: overlaidAvailability as unknown as Prisma.InputJsonValue,
          dynamicFields: {
            dispoImport: {
              availabilityRules: resourceRules.map((rule) => ({
                availableHours: rule.availableHours,
                effectiveEndDate: rule.effectiveEndDate?.toISOString().slice(0, 10) ?? null,
                effectiveStartDate: rule.effectiveStartDate?.toISOString().slice(0, 10) ?? null,
                percentage: rule.percentage,
                ruleType: rule.ruleType,
              })),
              importBatchId: batchId,
              sourceKinds: resource.sourceKinds,
              warnings: resource.warnings,
            },
          } as Prisma.InputJsonValue,
        },
      });
      updatedResourceAvailabilities += 1;
    }
  }

  return { resourceIdByKey, resourceRoleNameByKey, updatedEntitlements, updatedResourceAvailabilities, upsertedResourceRoles };
}

async function commitProjects(
  tx: TxClient,
  stagedProjects: Awaited<ReturnType<TxClient["stagedProject"]["findMany"]>>,
  maps: ReferenceDataMaps,
  batchId: string,
  importTbdProjects: boolean,
): Promise<Map<string, string>> {
  const projectIdByShortCode = new Map<string, string>();

  for (const stagedProject of stagedProjects) {
    if (stagedProject.isTbd && !importTbdProjects) continue;
    const shortCode = stagedProject.shortCode ?? stagedProject.projectKey;
    const classification = classifyDispoProject(stagedProject.utilizationCategoryCode ?? null);
    const projectStatus = stagedProject.isTbd ? ProjectStatus.DRAFT : ProjectStatus.ACTIVE;
    const dynamicFields = {
      dispoImport: {
        importBatchId: batchId,
        isInternal: stagedProject.isInternal,
        isTbd: stagedProject.isTbd,
        projectKey: stagedProject.projectKey,
        rawTokens: Array.isArray(asObject(stagedProject.rawPayload).rawTokens)
          ? asObject(stagedProject.rawPayload).rawTokens
          : [],
        warnings: stagedProject.warnings,
      },
    } as Prisma.InputJsonValue;

    const project = await tx.project.upsert({
      where: { shortCode },
      update: {
        allocationType: stagedProject.allocationType ?? classification.allocationType,
        budgetCents: 0,
        clientId: stagedProject.clientCode ? (maps.clientIdByCode.get(stagedProject.clientCode) ?? null) : null,
        dynamicFields,
        endDate: normalizeDate(stagedProject.endDate ?? stagedProject.startDate ?? new Date()),
        name: stagedProject.name ?? shortCode,
        orderType: stagedProject.orderType ?? classification.orderType,
        startDate: normalizeDate(stagedProject.startDate ?? stagedProject.endDate ?? new Date()),
        status: projectStatus,
        utilizationCategoryId: stagedProject.utilizationCategoryCode
          ? (maps.utilizationCategoryIdByCode.get(stagedProject.utilizationCategoryCode) ?? null)
          : null,
        winProbability: stagedProject.winProbability ?? 100,
      },
      create: {
        shortCode,
        name: stagedProject.name ?? shortCode,
        orderType: stagedProject.orderType ?? classification.orderType,
        allocationType: stagedProject.allocationType ?? classification.allocationType,
        budgetCents: 0,
        startDate: normalizeDate(stagedProject.startDate ?? stagedProject.endDate ?? new Date()),
        endDate: normalizeDate(stagedProject.endDate ?? stagedProject.startDate ?? new Date()),
        status: projectStatus,
        winProbability: stagedProject.winProbability ?? 100,
        utilizationCategoryId: stagedProject.utilizationCategoryCode
          ? (maps.utilizationCategoryIdByCode.get(stagedProject.utilizationCategoryCode) ?? null)
          : null,
        clientId: stagedProject.clientCode ? (maps.clientIdByCode.get(stagedProject.clientCode) ?? null) : null,
        dynamicFields,
      },
      select: { id: true },
    });
    projectIdByShortCode.set(shortCode, project.id);
  }

  return projectIdByShortCode;
}

export async function commitDispoImportBatch(
  db: CommitDbClient,
  input: CommitDispoImportBatchInput,
): Promise<CommitDispoImportBatchResult> {
  const validation = await validateDispoBatch(db, input);

  const result = await db.$transaction(async (tx) => {
    await tx.importBatch.update({
      where: { id: validation.batchId },
      data: { status: ImportBatchStatus.COMMITTING },
    });

    await upsertUtilizationCategories(tx);
    await upsertRoleSeeds(tx);

    const [
      stagedResources, stagedProjects, stagedAssignments, stagedVacations,
      stagedAvailabilityRules, adminUser, countries, metroCities,
      managementLevelGroups, managementLevels, clients, orgUnits, roles,
      utilizationCategories,
    ] = await Promise.all([
      tx.stagedResource.findMany({ where: { importBatchId: validation.batchId } }),
      tx.stagedProject.findMany({ where: { importBatchId: validation.batchId } }),
      tx.stagedAssignment.findMany({ where: { importBatchId: validation.batchId } }),
      tx.stagedVacation.findMany({ where: { importBatchId: validation.batchId } }),
      tx.stagedAvailabilityRule.findMany({ where: { importBatchId: validation.batchId } }),
      tx.user.findFirst({ where: { systemRole: "ADMIN" }, select: { id: true } }),
      tx.country.findMany({ select: { id: true, code: true } }),
      tx.metroCity.findMany({ select: { id: true, name: true } }),
      tx.managementLevelGroup.findMany({ select: { id: true, name: true, targetPercentage: true } }),
      tx.managementLevel.findMany({ select: { id: true, name: true } }),
      tx.client.findMany({ select: { id: true, code: true, name: true } }),
      tx.orgUnit.findMany({ select: { id: true, level: true, name: true } }),
      tx.role.findMany({ select: { id: true, name: true } }),
      tx.utilizationCategory.findMany({ select: { id: true, code: true } }),
    ]);

    if (!adminUser) {
      throw new Error("Cannot commit Dispo import without an ADMIN user in the database");
    }

    const maps = buildReferenceDataMaps({
      clients, countries, managementLevelGroups, managementLevels,
      metroCities, orgUnits, roles, utilizationCategories,
    });

    const mergedResources = mergeStagedResources(stagedResources);
    await ensureInferredRolesExist(tx, mergedResources, maps.roleIdByName);

    const resourceResult = await commitResources(
      tx, mergedResources, maps, validation.batchId,
      stagedVacations, stagedAvailabilityRules,
    );

    const projectIdByShortCode = await commitProjects(
      tx, stagedProjects, maps, validation.batchId, input.importTbdProjects ?? false,
    );

    // Commit assignments
    const aggregatedAssignments = aggregateAssignments(
      stagedAssignments, resourceResult.resourceIdByKey, projectIdByShortCode,
      maps.roleIdByName, resourceResult.resourceRoleNameByKey, input.importTbdProjects ?? false,
    );

    for (const assignment of aggregatedAssignments) {
      const dailyCostCents = Math.round(
        assignment.hoursPerDay * (mergedResources.get(assignment.resourceKey)?.lcrCents ?? 0),
      );
      const metadata = {
        dispoImport: {
          importBatchId: validation.batchId,
          sourceDates: assignment.sourceDates,
          utilizationCategoryCode: assignment.utilizationCategoryCode,
        },
      } as Prisma.InputJsonValue;

      await tx.assignment.upsert({
        where: {
          unique_assignment: {
            resourceId: assignment.resourceId,
            projectId: assignment.projectId,
            startDate: assignment.startDate,
            endDate: assignment.endDate,
          },
        },
        update: {
          dailyCostCents, metadata,
          percentage: assignment.percentage,
          role: assignment.roleName,
          roleId: assignment.roleId,
          status: AllocationStatus.PROPOSED,
        },
        create: {
          dailyCostCents, metadata,
          endDate: assignment.endDate,
          hoursPerDay: assignment.hoursPerDay,
          percentage: assignment.percentage,
          projectId: assignment.projectId,
          resourceId: assignment.resourceId,
          role: assignment.roleName,
          roleId: assignment.roleId,
          startDate: assignment.startDate,
          status: AllocationStatus.PROPOSED,
        },
      });

      await tx.resourceRole.upsert({
        where: { resourceId_roleId: { resourceId: assignment.resourceId, roleId: assignment.roleId } },
        update: {},
        create: { resourceId: assignment.resourceId, roleId: assignment.roleId },
      });
      resourceResult.upsertedResourceRoles += 1;
    }

    // Commit vacations
    for (const stagedVacation of stagedVacations) {
      const resourceId = resourceResult.resourceIdByKey.get(stagedVacation.resourceExternalId);
      if (!resourceId) {
        throw new Error(`Unable to resolve resource "${stagedVacation.resourceExternalId}" during vacation commit`);
      }

      const existing = await tx.vacation.findFirst({
        where: {
          endDate: stagedVacation.endDate, halfDayPart: stagedVacation.halfDayPart,
          isHalfDay: stagedVacation.isHalfDay, note: stagedVacation.note, resourceId,
          startDate: stagedVacation.startDate, status: VacationStatus.APPROVED,
          type: stagedVacation.vacationType,
        },
        select: { id: true },
      });

      if (existing) {
        await tx.vacation.update({
          where: { id: existing.id },
          data: { approvedAt: new Date(), approvedById: adminUser.id, note: stagedVacation.note, requestedById: adminUser.id },
        });
      } else {
        await tx.vacation.create({
          data: {
            approvedAt: new Date(), approvedById: adminUser.id,
            endDate: stagedVacation.endDate, halfDayPart: stagedVacation.halfDayPart,
            isHalfDay: stagedVacation.isHalfDay, note: stagedVacation.note,
            requestedById: adminUser.id, resourceId,
            startDate: stagedVacation.startDate, status: VacationStatus.APPROVED,
            type: stagedVacation.vacationType,
          },
        });
      }
    }

    // Mark staged records as committed
    await Promise.all([
      tx.stagedResource.updateMany({ where: { importBatchId: validation.batchId }, data: { status: StagedRecordStatus.COMMITTED } }),
      tx.stagedProject.updateMany({
        where: input.importTbdProjects ? { importBatchId: validation.batchId } : { importBatchId: validation.batchId, isTbd: false },
        data: { status: StagedRecordStatus.COMMITTED },
      }),
      tx.stagedAssignment.updateMany({
        where: input.importTbdProjects
          ? { importBatchId: validation.batchId, isUnassigned: false }
          : { importBatchId: validation.batchId, isTbd: false, isUnassigned: false },
        data: { status: StagedRecordStatus.COMMITTED },
      }),
      tx.stagedVacation.updateMany({ where: { importBatchId: validation.batchId }, data: { status: StagedRecordStatus.COMMITTED } }),
      tx.stagedAvailabilityRule.updateMany({ where: { importBatchId: validation.batchId }, data: { status: StagedRecordStatus.COMMITTED } }),
    ]);

    await tx.importBatch.update({
      where: { id: validation.batchId },
      data: {
        committedAt: new Date(),
        status: ImportBatchStatus.COMMITTED,
        summary: buildBatchSummaryEntry({
          ...toJsonObject(validation.batchSummary),
          commit: {
            committedAssignments: aggregatedAssignments.length,
            committedProjects: projectIdByShortCode.size,
            committedResources: mergedResources.size,
            committedVacations: stagedVacations.length,
            skippedTbdUnresolved: validation.skippedTbdUnresolved,
            updatedEntitlements: resourceResult.updatedEntitlements,
            updatedResourceAvailabilities: resourceResult.updatedResourceAvailabilities,
            upsertedResourceRoles: resourceResult.upsertedResourceRoles,
          },
        }),
      },
    });

    return {
      batchId: validation.batchId,
      counts: {
        committedAssignments: aggregatedAssignments.length,
        committedProjects: projectIdByShortCode.size,
        committedResources: mergedResources.size,
        committedVacations: stagedVacations.length,
        updatedEntitlements: resourceResult.updatedEntitlements,
        updatedResourceAvailabilities: resourceResult.updatedResourceAvailabilities,
        upsertedResourceRoles: resourceResult.upsertedResourceRoles,
      },
      unresolved: { blocked: 0, skippedTbd: validation.skippedTbdUnresolved },
    } satisfies CommitDispoImportBatchResult;
  }, { maxWait: 30_000, timeout: 600_000 });

  if ("resource" in db && db.resource) {
    await recomputeResourceValueScores(db);
  }

  return result;
}
