import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
import { HttpError } from "@batch-cooking/error-tools";
import {
  type AddPlanningItemInput,
  ErrorCode,
  type PlanningItemView,
  type PlanningView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
import { assertRecipeVisible } from "../recipe/recipe.service.js";

/**
 * Finds the household's planning that covers `date` and shapes it into a
 * {@link PlanningView} (recipes resolved to `{id, name}`). `date` is
 * whatever the caller wants "now" to mean — the current `/planning` route
 * passes a `date-tools`-parsed `?date=` query param, letting a caller look
 * up any week's planning, not just the one covering today.
 *
 * Returns `null` for two distinct, both entirely normal states — a
 * `houseId` of `null` (the profile has no household yet — households are no
 * longer created automatically at signup, see `auth.service.ts`) and "no
 * planning row covers this date yet" (e.g. a week nobody has added a recipe
 * to via {@link addPlanningItem}) — neither is an error, so both collapse
 * to the same "nothing to show yet" result rather than throwing.
 */
export async function getPlanningForDate(
  houseId: number | null,
  date: DateTime,
): Promise<PlanningView | null> {
  try {
    if (houseId === null) {
      return null;
    }

    // `startDate`/`finishDate` are `@db.Date` columns (no time-of-day
    // component) — comparing against a UTC-midnight JS `Date` lines up with
    // how Postgres stores/returns them, regardless of the server's local
    // timezone.
    const dateOnly = toDateOnly(date).toJSDate();

    const planning = await prisma.planning.findFirst({
      where: {
        houseId,
        startDate: { lte: dateOnly },
        finishDate: { gte: dateOnly },
      },
      // A household should never have two plannings covering the same day,
      // but nothing in the schema enforces that yet — pick the most recently
      // started one rather than letting the query fail if it ever happens.
      orderBy: { startDate: "desc" },
      include: {
        items: {
          include: { recipe: { select: { id: true, name: true } } },
        },
      },
    });

    if (!planning) {
      return null;
    }

    return {
      id: planning.id,
      startDate: planning.startDate.toISOString(),
      finishDate: planning.finishDate.toISOString(),
      items: planning.items.map((item) => ({
        id: item.id,
        weekDay: item.weekDay,
        meal: item.meal,
        portions: item.portions,
        recipe: item.recipe,
      })),
    };
  } 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;
  }
}

/**
 * Finds the household's `Planning` covering the week `weekStart` (a Monday)
 * starts, creating it on the fly (spanning the full Monday-to-Sunday week)
 * if none exists yet. Matched by exact `startDate`, not a covering range
 * like {@link getPlanningForDate} — that query is for "what covers today",
 * this one is for "the row `addPlanningItem` should attach to", so it needs
 * to land on the same row a second add to the same week would reuse rather
 * than risk matching some other overlapping planning.
 *
 * Not wrapped in a transaction with the `findFirst` — no unique constraint
 * exists on `(houseId, startDate)` (see {@link getPlanningForDate}'s doc
 * comment on the same gap), so two concurrent first-adds to an empty week
 * could each create their own `Planning` row. Accepted at this project's
 * scale rather than adding a migration + retry-on-conflict loop for it.
 */
async function findOrCreatePlanningForWeek(houseId: number, weekStart: DateTime) {
  try {
    const startDate = weekStart.toJSDate();
    const existing = await prisma.planning.findFirst({
      where: { houseId, startDate },
    });
    if (existing) {
      return existing;
    }
    return await prisma.planning.create({
      data: {
        houseId,
        startDate,
        finishDate: weekStart.plus({ days: 6 }).toJSDate(),
      },
    });
  } catch (err) {
    throw err; // see getPlanningForDate()'s catch comment above
  }
}

/**
 * Adds a recipe to one (day, meal) slot of `houseId`'s planning for the
 * week containing `date`, creating that week's `Planning` row first if it
 * doesn't exist yet (see {@link findOrCreatePlanningForWeek}) — this is the
 * only place a `Planning` row gets created at all today, there's no
 * separate "create an empty planning" action. `date` is a caller-parsed
 * `DateTime` (see `planning.routes.ts`, which validates `input.date` the
 * same way `GET /planning` validates its own `?date=`, before calling
 * here) rather than the raw `input.date` string — only its week matters,
 * not the exact day.
 *
 * @throws {HttpError} `404 HOUSE_NOT_FOUND` if `houseId` is `null` (the caller has no household).
 * @throws {HttpError} `404 RECIPE_NOT_FOUND` if `input.recipeId` doesn't match a recipe visible to `viewerId` — see `recipe.service.ts`'s `assertRecipeVisible`.
 */
export async function addPlanningItem(
  houseId: number | null,
  viewerId: number,
  viewerHouseId: number | null,
  date: DateTime,
  input: AddPlanningItemInput,
): Promise<PlanningItemView> {
  try {
    if (houseId === null) {
      throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
    }
    await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId);

    const weekStart = getWeekStart(toDateOnly(date));
    const planning = await findOrCreatePlanningForWeek(houseId, weekStart);

    const item = await prisma.planningItem.create({
      data: {
        planningId: planning.id,
        weekDay: input.weekDay,
        meal: input.meal,
        recipeId: input.recipeId,
        portions: input.portions,
      },
      include: { recipe: { select: { id: true, name: true } } },
    });

    analytics.recordEvent("planning.item_added", {
      actorId: viewerId,
      context: { recipeId: input.recipeId, portions: input.portions },
    });

    return {
      id: item.id,
      weekDay: item.weekDay,
      meal: item.meal,
      portions: item.portions,
      recipe: item.recipe,
    };
  } catch (err) {
    throw err; // see getPlanningForDate()'s catch comment above
  }
}

/**
 * Removes one planning item outright. The parent `Planning` row is left in
 * place even if this was its last item — an empty planning is a normal,
 * already-handled state on the read side (`getPlanningForDate`'s grid just
 * shows every slot's "+"), not worth a cleanup pass here.
 *
 * @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `id` doesn't match any planning item, or does but belongs to a planning outside `houseId` — never `403`, same "don't confirm what exists" reasoning as `RECIPE_NOT_FOUND` elsewhere.
 */
export async function removePlanningItem(id: number, houseId: number | null): Promise<void> {
  try {
    const item = await prisma.planningItem.findUnique({
      where: { id },
      include: { planning: true },
    });
    if (!item || houseId === null || item.planning.houseId !== houseId) {
      throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`);
    }
    await prisma.planningItem.delete({ where: { id } });
  } catch (err) {
    throw err; // see getPlanningForDate()'s catch comment above
  }
}
