import { randomInt } from "node:crypto";
import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode, type HouseView } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";

/**
 * Charset for {@link generateInviteCode} — uppercase letters/digits only,
 * minus the visually-ambiguous `0`/`O`/`1`/`I` (this code is meant to be
 * read off one screen and typed into another).
 */
const INVITE_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const INVITE_CODE_LENGTH = 8;

/** Generates one candidate invite code. Collisions are handled by the caller (retry on the DB's unique-constraint failure), not here. */
function generateInviteCode(): string {
  let code = "";
  for (let i = 0; i < INVITE_CODE_LENGTH; i++) {
    code += INVITE_CODE_CHARS[randomInt(INVITE_CODE_CHARS.length)];
  }
  return code;
}

/** Shapes a Prisma `House` (with its `members` relation included) into the public {@link HouseView}. */
function toHouseView(house: {
  id: number;
  name: string;
  adminId: number;
  inviteCode: string;
  members: { id: number; firstName: string; lastName: string }[];
}): HouseView {
  return {
    id: house.id,
    name: house.name,
    adminId: house.adminId,
    inviteCode: house.inviteCode,
    members: house.members,
  };
}

/** Shared `include` for every query that needs to return a full {@link HouseView}. */
const houseWithMembers = {
  members: { select: { id: true, firstName: true, lastName: true } },
} as const;

/** Returns the profile's household (with its member list), or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */
export async function getCurrentHouse(houseId: number | null): Promise<HouseView | null> {
  try {
    if (houseId === null) {
      return null;
    }
    return toHouseView(await findHouseOrThrow(houseId));
  } catch (err) {
    // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
    // already logs it, see `error-logger.ts`) is what actually handles it,
    // this service layer just isn't allowed a bare `await` per the repo's
    // async/try-catch convention.
    throw err;
  }
}

/**
 * Renames the profile's household. Open to any member, not just the admin —
 * unlike deleting the household or removing a member, renaming isn't
 * destructive enough to gate behind adminship.
 *
 * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
 */
export async function renameHouse(houseId: number | null, name: string): Promise<HouseView> {
  try {
    if (houseId === null) {
      throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
    }
    await findHouseOrThrow(houseId);
    const house = await prisma.house.update({
      where: { id: houseId },
      data: { name },
      include: houseWithMembers,
    });
    return toHouseView(house);
  } catch (err) {
    throw err; // see getCurrentHouse()'s catch comment above
  }
}

/**
 * Creates a new household for a profile that doesn't have one yet, with the
 * creating profile as its admin.
 *
 * @throws {HttpError} `409 ALREADY_HAS_HOUSE` if the profile already belongs to a household.
 */
export async function createHouse(
  profileId: number,
  houseId: number | null,
  name: string,
): Promise<HouseView> {
  try {
    if (houseId !== null) {
      throw new HttpError(
        409,
        ErrorCode.ALREADY_HAS_HOUSE,
        "Profile already belongs to a household",
      );
    }

    // Astronomically unlikely to collide (33^8 possibilities), but retried
    // rather than assumed — a `@unique` constraint failure is the only fully
    // reliable way to detect it.
    const maxAttempts = 5;
    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
      try {
        const house = await prisma.$transaction(async (tx) => {
          const created = await tx.house.create({
            data: {
              name,
              adminId: profileId,
              inviteCode: generateInviteCode(),
            },
          });
          await tx.userProfile.update({
            where: { id: profileId },
            data: { houseId: created.id },
          });
          return created;
        });
        return await getCurrentHouseOrThrow(house.id);
      } catch (err) {
        if (isUniqueInviteCodeViolation(err) && attempt < maxAttempts) continue;
        throw err;
      }
    }
    throw new Error("Failed to generate a unique invite code after several attempts");
  } catch (err) {
    throw err; // see getCurrentHouse()'s catch comment above
  }
}

/**
 * Joins an existing household by invite code.
 *
 * @throws {HttpError} `409 ALREADY_HAS_HOUSE` if the profile already belongs to a household.
 * @throws {HttpError} `404 INVITE_CODE_NOT_FOUND` if no household matches the code.
 */
export async function joinHouse(
  profileId: number,
  houseId: number | null,
  inviteCode: string,
): Promise<HouseView> {
  try {
    if (houseId !== null) {
      throw new HttpError(
        409,
        ErrorCode.ALREADY_HAS_HOUSE,
        "Profile already belongs to a household",
      );
    }

    const house = await prisma.house.findUnique({ where: { inviteCode } });
    if (!house) {
      throw new HttpError(
        404,
        ErrorCode.INVITE_CODE_NOT_FOUND,
        "No household matches this invite code",
      );
    }

    await prisma.userProfile.update({
      where: { id: profileId },
      data: { houseId: house.id },
    });
    return await getCurrentHouseOrThrow(house.id);
  } catch (err) {
    throw err; // see getCurrentHouse()'s catch comment above
  }
}

/**
 * Removes a profile from its current household — used both by
 * `POST /house/leave` (a member removing themselves) and by account
 * deletion (`auth.service.ts`'s `deleteAccount`, before the profile row
 * itself is deleted).
 *
 * If the leaving profile was the household's admin: adminship transfers to
 * the longest-standing remaining member (lowest id) if there is one,
 * otherwise the household itself is deleted (cascading its plannings) since
 * a household can never be left without an admin.
 *
 * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household.
 */
export async function leaveCurrentHouse(profileId: number, houseId: number | null): Promise<void> {
  try {
    if (houseId === null) {
      throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
    }

    const house = await findHouseOrThrow(houseId);
    const remainingMembers = house.members.filter((member) => member.id !== profileId);

    await prisma.$transaction(async (tx) => {
      await tx.userProfile.update({
        where: { id: profileId },
        data: { houseId: null },
      });

      if (house.adminId !== profileId) {
        return;
      }
      if (remainingMembers.length === 0) {
        await tx.house.delete({ where: { id: house.id } });
        return;
      }
      const nextAdmin = remainingMembers.reduce((oldest, member) =>
        member.id < oldest.id ? member : oldest,
      );
      await tx.house.update({
        where: { id: house.id },
        data: { adminId: nextAdmin.id },
      });
    });
  } catch (err) {
    throw err; // see getCurrentHouse()'s catch comment above
  }
}

/**
 * Deletes a household outright — every member (not just the caller) loses
 * it, and its plannings are cascaded away. Only the household's admin may
 * do this; a non-admin member wanting out should call
 * {@link leaveCurrentHouse} (`POST /house/leave`) instead.
 *
 * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household.
 * @throws {HttpError} `403 NOT_HOUSE_ADMIN` if the profile isn't this household's admin.
 */
export async function deleteHouse(profileId: number, houseId: number | null): Promise<void> {
  try {
    if (houseId === null) {
      throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
    }
    const house = await findHouseOrThrow(houseId);
    if (house.adminId !== profileId) {
      throw new HttpError(
        403,
        ErrorCode.NOT_HOUSE_ADMIN,
        "Only the household's admin can delete it",
      );
    }

    // Members' houseId also cascades to null via the FK's onDelete: SetNull,
    // but clearing it explicitly first keeps the outcome obvious without
    // relying on that FK behavior being read alongside this function.
    await prisma.$transaction([
      prisma.userProfile.updateMany({
        where: { houseId: house.id },
        data: { houseId: null },
      }),
      prisma.house.delete({ where: { id: house.id } }),
    ]);
  } catch (err) {
    throw err; // see getCurrentHouse()'s catch comment above
  }
}

/**
 * Removes one specific member from the caller's household — the admin
 * acting on someone else. The admin can't remove themselves this way (no
 * adminship to hand off here); they use {@link leaveCurrentHouse} instead,
 * same as any other member leaving voluntarily.
 *
 * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household.
 * @throws {HttpError} `403 NOT_HOUSE_ADMIN` if the profile isn't this household's admin.
 * @throws {HttpError} `400 VALIDATION_ERROR` if `targetMemberId` is the caller, or isn't a member of this household.
 */
export async function removeMember(
  profileId: number,
  houseId: number | null,
  targetMemberId: number,
): Promise<HouseView> {
  try {
    if (houseId === null) {
      throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
    }
    const house = await findHouseOrThrow(houseId);
    if (house.adminId !== profileId) {
      throw new HttpError(
        403,
        ErrorCode.NOT_HOUSE_ADMIN,
        "Only the household's admin can remove a member",
      );
    }
    if (targetMemberId === profileId) {
      throw new HttpError(
        400,
        ErrorCode.VALIDATION_ERROR,
        "Use POST /house/leave to remove yourself",
      );
    }
    if (!house.members.some((member) => member.id === targetMemberId)) {
      throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Not a member of this household");
    }

    await prisma.userProfile.update({
      where: { id: targetMemberId },
      data: { houseId: null },
    });
    return await getCurrentHouseOrThrow(house.id);
  } catch (err) {
    throw err; // see getCurrentHouse()'s catch comment above
  }
}

/**
 * Current enabled-source ids for a household — an empty array is normal
 * and is this household's starting state (opt-in: see `HouseSource` in
 * schema.prisma), not just "no preference set yet".
 *
 * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
 */
export async function getHouseSourceIds(houseId: number | null): Promise<number[]> {
  try {
    if (houseId === null) {
      throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
    }
    const rows = await prisma.houseSource.findMany({
      where: { houseId },
      select: { sourceId: true },
    });
    return rows.map((row) => row.sourceId);
  } catch (err) {
    throw err; // see getCurrentHouse()'s catch comment above
  }
}

/**
 * Replaces a household's full set of enabled recipe sources (not a merge —
 * same "replace, not merge" contract as `profile.service.ts`'s
 * `updateAllergies`). Every recipe-catalog tab (`recipe.service.ts`'s
 * `listRecipes`) filters against this set — a source left out here simply
 * never shows its recipes to this household, in any tab.
 *
 * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
 * @throws {HttpError} `404 SOURCE_NOT_FOUND` if any `sourceId` doesn't match a reference `Source` row.
 */
export async function updateHouseSources(
  houseId: number | null,
  sourceIds: number[],
): Promise<number[]> {
  try {
    if (houseId === null) {
      throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
    }
    if (sourceIds.length > 0) {
      const found = await prisma.source.findMany({
        where: { id: { in: sourceIds } },
        select: { id: true },
      });
      const foundIds = new Set(found.map((source) => source.id));
      const missing = sourceIds.filter((id) => !foundIds.has(id));
      if (missing.length > 0) {
        throw new HttpError(
          404,
          ErrorCode.SOURCE_NOT_FOUND,
          `Unknown source id(s): ${missing.join(", ")}`,
        );
      }
    }

    await prisma.$transaction([
      prisma.houseSource.deleteMany({ where: { houseId } }),
      prisma.houseSource.createMany({
        data: sourceIds.map((sourceId) => ({ houseId, sourceId })),
      }),
    ]);

    return sourceIds;
  } catch (err) {
    throw err; // see getCurrentHouse()'s catch comment above
  }
}

/** Re-fetches a household by id (as a {@link HouseView}) once its id is already known to be valid — the common "reload after a mutation" step shared by several functions above. */
async function getCurrentHouseOrThrow(houseId: number): Promise<HouseView> {
  try {
    return toHouseView(await findHouseOrThrow(houseId));
  } catch (err) {
    throw err; // see getCurrentHouse()'s catch comment above
  }
}

/** True if `err` is Prisma's unique-constraint violation (`P2002`) on `invite_code` — the only expected cause of a collision retry in {@link createHouse}. */
function isUniqueInviteCodeViolation(err: unknown): boolean {
  return (
    typeof err === "object" &&
    err !== null &&
    "code" in err &&
    (err as { code: unknown }).code === "P2002"
  );
}

/**
 * A profile's `houseId` is only ever set to a real house (foreign key,
 * never assigned by hand) — a lookup miss here means the referenced row
 * was deleted out from under a still-linked profile, an internal
 * inconsistency rather than a normal "not found" a client could hit
 * through the API, hence a plain `Error` (500) rather than a
 * `HOUSE_NOT_FOUND` HttpError.
 */
async function findHouseOrThrow(houseId: number) {
  try {
    const house = await prisma.house.findUnique({
      where: { id: houseId },
      include: houseWithMembers,
    });
    if (!house) {
      throw new Error(`House ${houseId} referenced by a profile but not found`);
    }
    return house;
  } catch (err) {
    throw err; // see getCurrentHouse()'s catch comment above
  }
}
