import React from "react";
import { ClassNames } from "@emotion/react";
import {
  AspectRatio,
  Button,
  Box,
  HStack,
  IconButton,
  SkeletonText,
  Tooltip,
  VisuallyHidden,
  VStack,
  useBreakpointValue,
  useColorModeValue,
  useTheme,
  useToast,
  Flex,
  usePrefersReducedMotion,
  Grid,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Checkbox,
} from "@chakra-ui/react";
import {
  CheckIcon,
  ChevronDownIcon,
  ChevronRightIcon,
  EditIcon,
  StarIcon,
  WarningIcon,
} from "@chakra-ui/icons";
import { MdPause, MdPlayArrow } from "react-icons/md";
import gql from "graphql-tag";
import { useQuery, useMutation } from "@apollo/client";

import ItemPageLayout, { SubtleSkeleton } from "./ItemPageLayout";
import {
  Delay,
  logAndCapture,
  MajorErrorMessage,
  useLocalStorage,
} from "./util";
import HTML5Badge, { layerUsesHTML5 } from "./components/HTML5Badge";
import {
  itemAppearanceFragment,
  petAppearanceFragment,
} from "./components/useOutfitAppearance";
import { useOutfitPreview } from "./components/OutfitPreview";
import SpeciesColorPicker, {
  useAllValidPetPoses,
  getValidPoses,
  getClosestPose,
} from "./components/SpeciesColorPicker";
import useCurrentUser from "./components/useCurrentUser";
import SpeciesFacesPicker, {
  colorIsBasic,
} from "./ItemPage/SpeciesFacesPicker";

// Removed for the wardrobe-2020 case.
// TODO: Refactor this stuff, do we even need ItemPageContent really?
// function ItemPage() {
//   const { query } = useRouter();
//   return <ItemPageContent itemId={query.itemId} />;
// }

/**
 * ItemPageContent is the content of ItemPage, but we also use it as the
 * entry point for ItemPageDrawer! When embedded in ItemPageDrawer, the
 * `isEmbedded` prop is true, so we know not to e.g. set the page title.
 */
export function ItemPageContent({ itemId, isEmbedded = false }) {
  const { isLoggedIn } = useCurrentUser();

  const { error, data } = useQuery(
    gql`
      query ItemPage($itemId: ID!) {
        item(id: $itemId) {
          id
          name
          isNc
          isPb
          thumbnailUrl
          description
          createdAt
          ncTradeValueText

          # For Support users.
          rarityIndex
          isManuallyNc
        }
      }
    `,
    { variables: { itemId }, returnPartialData: true },
  );

  if (error) {
    return <MajorErrorMessage error={error} />;
  }

  const item = data?.item;

  return (
    <>
      <ItemPageLayout item={item} isEmbedded={isEmbedded}>
        <VStack spacing="8" marginTop="4">
          <ItemPageDescription
            description={item?.description}
            isEmbedded={isEmbedded}
          />
          <VStack spacing="4">
            <ItemPageTradeLinks itemId={itemId} isEmbedded={isEmbedded} />
            {isLoggedIn && <ItemPageOwnWantButtons itemId={itemId} />}
          </VStack>
          {!isEmbedded && <ItemPageOutfitPreview itemId={itemId} />}
        </VStack>
      </ItemPageLayout>
    </>
  );
}

function ItemPageDescription({ description, isEmbedded }) {
  // Show 2 lines of description text placeholder on small screens, or when
  // embedded in the wardrobe page's narrow drawer. In larger contexts, show
  // just 1 line.
  const viewportNumDescriptionLines = useBreakpointValue({ base: 2, md: 1 });
  const numDescriptionLines = isEmbedded ? 2 : viewportNumDescriptionLines;

  return (
    <Box width="100%" alignSelf="flex-start">
      {description ? (
        description
      ) : description === "" ? (
        <i>(This item has no description.)</i>
      ) : (
        <Box
          maxWidth="40em"
          minHeight={numDescriptionLines * 1.5 + "em"}
          display="flex"
          flexDirection="column"
          alignItems="stretch"
          justifyContent="center"
        >
          <Delay ms={500}>
            <SkeletonText noOfLines={numDescriptionLines} spacing="4" />
          </Delay>
        </Box>
      )}
    </Box>
  );
}

const ITEM_PAGE_OWN_WANT_BUTTONS_QUERY = gql`
  query ItemPageOwnWantButtons($itemId: ID!) {
    item(id: $itemId) {
      id
      name
      currentUserOwnsThis
      currentUserWantsThis
    }
    currentUser {
      closetLists {
        id
        name
        isDefaultList
        ownsOrWantsItems
        hasItem(itemId: $itemId)
      }
    }
  }
`;

function ItemPageOwnWantButtons({ itemId }) {
  const { loading, error, data } = useQuery(ITEM_PAGE_OWN_WANT_BUTTONS_QUERY, {
    variables: { itemId },
    context: { sendAuth: true },
  });

  if (error) {
    return <Box color="red.400">{error.message}</Box>;
  }

  const closetLists = data?.currentUser?.closetLists || [];
  const realLists = closetLists.filter((cl) => !cl.isDefaultList);
  const ownedLists = realLists.filter((cl) => cl.ownsOrWantsItems === "OWNS");
  const wantedLists = realLists.filter((cl) => cl.ownsOrWantsItems === "WANTS");

  return (
    <Grid
      templateRows="auto auto"
      templateColumns="160px 160px"
      gridAutoFlow="column"
      rowGap="0.5"
      columnGap="4"
      justifyItems="center"
    >
      <SubtleSkeleton isLoaded={!loading}>
        <ItemPageOwnButton
          itemId={itemId}
          isChecked={data?.item?.currentUserOwnsThis}
        />
      </SubtleSkeleton>
      <ItemPageOwnWantListsDropdown
        closetLists={ownedLists}
        item={data?.item}
        // Show the dropdown if the user owns this, and has at least one custom
        // list it could belong to.
        isVisible={data?.item?.currentUserOwnsThis && ownedLists.length >= 1}
        popoverPlacement="bottom-end"
      />

      <SubtleSkeleton isLoaded={!loading}>
        <ItemPageWantButton
          itemId={itemId}
          isChecked={data?.item?.currentUserWantsThis}
        />
      </SubtleSkeleton>
      <ItemPageOwnWantListsDropdown
        closetLists={wantedLists}
        item={data?.item}
        // Show the dropdown if the user wants this, and has at least one
        // custom list it could belong to.
        isVisible={data?.item?.currentUserWantsThis && wantedLists.length >= 1}
        popoverPlacement="bottom-start"
      />
    </Grid>
  );
}

function ItemPageOwnWantListsDropdown({
  closetLists,
  item,
  isVisible,
  popoverPlacement,
}) {
  return (
    <Popover placement={popoverPlacement}>
      <PopoverTrigger>
        <ItemPageOwnWantListsDropdownButton
          closetLists={closetLists}
          isVisible={isVisible}
        />
      </PopoverTrigger>
      <PopoverContent padding="2" width="64">
        <ItemPageOwnWantListsDropdownContent
          closetLists={closetLists}
          item={item}
        />
      </PopoverContent>
    </Popover>
  );
}

const ItemPageOwnWantListsDropdownButton = React.forwardRef(
  ({ closetLists, isVisible, ...props }, ref) => {
    const listsToShow = closetLists.filter((cl) => cl.hasItem);

    let buttonText;
    if (listsToShow.length === 1) {
      buttonText = `In list: "${listsToShow[0].name}"`;
    } else if (listsToShow.length > 1) {
      const listNames = listsToShow.map((cl) => `"${cl.name}"`).join(", ");
      buttonText = `${listsToShow.length} lists: ${listNames}`;
    } else {
      buttonText = "Add to list";
    }

    return (
      <Flex
        ref={ref}
        as="button"
        fontSize="xs"
        alignItems="center"
        borderRadius="sm"
        width="100%"
        _hover={{ textDecoration: "underline" }}
        _focus={{
          textDecoration: "underline",
          outline: "0",
          boxShadow: "outline",
        }}
        // Even when the button isn't visible, we still render it for layout
        // purposes, but hidden and disabled.
        opacity={isVisible ? 1 : 0}
        aria-hidden={!isVisible}
        disabled={!isVisible}
        {...props}
      >
        {/* Flex tricks to center the text, ignoring the caret */}
        <Box flex="1 0 0" />
        <Box textOverflow="ellipsis" overflow="hidden" whiteSpace="nowrap">
          {buttonText}
        </Box>
        <Flex flex="1 0 0">
          <ChevronDownIcon marginLeft="1" />
        </Flex>
      </Flex>
    );
  },
);

function ItemPageOwnWantListsDropdownContent({ closetLists, item }) {
  return (
    <Box as="ul" listStyleType="none">
      {closetLists.map((closetList) => (
        <Box key={closetList.id} as="li">
          <ItemPageOwnWantsListsDropdownRow
            closetList={closetList}
            item={item}
          />
        </Box>
      ))}
    </Box>
  );
}

function ItemPageOwnWantsListsDropdownRow({ closetList, item }) {
  const toast = useToast();

  const [sendAddToListMutation] = useMutation(
    gql`
      mutation ItemPage_AddToClosetList($listId: ID!, $itemId: ID!) {
        addItemToClosetList(
          listId: $listId
          itemId: $itemId
          removeFromDefaultList: true
        ) {
          id
          hasItem(itemId: $itemId)
        }
      }
    `,
    { context: { sendAuth: true } },
  );

  const [sendRemoveFromListMutation] = useMutation(
    gql`
      mutation ItemPage_RemoveFromClosetList($listId: ID!, $itemId: ID!) {
        removeItemFromClosetList(
          listId: $listId
          itemId: $itemId
          ensureInSomeList: true
        ) {
          id
          hasItem(itemId: $itemId)
        }
      }
    `,
    { context: { sendAuth: true } },
  );

  const onChange = React.useCallback(
    (e) => {
      if (e.target.checked) {
        sendAddToListMutation({
          variables: { listId: closetList.id, itemId: item.id },
          optimisticResponse: {
            addItemToClosetList: {
              __typename: "ClosetList",
              id: closetList.id,
              hasItem: true,
            },
          },
        }).catch((error) => {
          console.error(error);
          toast({
            status: "error",
            title: `Oops, error adding "${item.name}" to "${closetList.name}!"`,
            description:
              "Check your connection and try again? Sorry about this!",
          });
        });
      } else {
        sendRemoveFromListMutation({
          variables: { listId: closetList.id, itemId: item.id },
          optimisticResponse: {
            removeItemFromClosetList: {
              __typename: "ClosetList",
              id: closetList.id,
              hasItem: false,
            },
          },
        }).catch((error) => {
          console.error(error);
          toast({
            status: "error",
            title: `Oops, error removing "${item.name}" from "${closetList.name}!"`,
            description:
              "Check your connection and try again? Sorry about this!",
          });
        });
      }
    },
    [
      closetList,
      item,
      sendAddToListMutation,
      sendRemoveFromListMutation,
      toast,
    ],
  );

  return (
    <Checkbox
      size="sm"
      width="100%"
      value={closetList.id}
      isChecked={closetList.hasItem}
      onChange={onChange}
    >
      {closetList.name}
    </Checkbox>
  );
}

function ItemPageOwnButton({ itemId, isChecked }) {
  const theme = useTheme();
  const toast = useToast();

  const [sendAddMutation] = useMutation(
    gql`
      mutation ItemPageOwnButtonAdd($itemId: ID!) {
        addToItemsCurrentUserOwns(itemId: $itemId) {
          id
          currentUserOwnsThis
        }
      }
    `,
    {
      variables: { itemId },
      context: { sendAuth: true },
      optimisticResponse: {
        __typename: "Mutation",
        addToItemsCurrentUserOwns: {
          __typename: "Item",
          id: itemId,
          currentUserOwnsThis: true,
        },
      },
      // TODO: Refactor the mutation result to include closet lists
      refetchQueries: [
        {
          query: ITEM_PAGE_OWN_WANT_BUTTONS_QUERY,
          variables: { itemId },
          context: { sendAuth: true },
        },
      ],
    },
  );

  const [sendRemoveMutation] = useMutation(
    gql`
      mutation ItemPageOwnButtonRemove($itemId: ID!) {
        removeFromItemsCurrentUserOwns(itemId: $itemId) {
          id
          currentUserOwnsThis
        }
      }
    `,
    {
      variables: { itemId },
      context: { sendAuth: true },
      optimisticResponse: {
        __typename: "Mutation",
        removeFromItemsCurrentUserOwns: {
          __typename: "Item",
          id: itemId,
          currentUserOwnsThis: false,
        },
      },
      // TODO: Refactor the mutation result to include closet lists
      refetchQueries: [
        {
          query: ITEM_PAGE_OWN_WANT_BUTTONS_QUERY,
          variables: { itemId },
          context: { sendAuth: true },
        },
      ],
    },
  );

  return (
    <ClassNames>
      {({ css }) => (
        <Box as="label">
          <VisuallyHidden
            as="input"
            type="checkbox"
            checked={isChecked}
            onChange={(e) => {
              if (e.target.checked) {
                sendAddMutation().catch((e) => {
                  console.error(e);
                  toast({
                    title: "We had trouble adding this to the items you own.",
                    description:
                      "Check your internet connection, and try again.",
                    status: "error",
                    duration: 5000,
                  });
                });
              } else {
                sendRemoveMutation().catch((e) => {
                  console.error(e);
                  toast({
                    title:
                      "We had trouble removing this from the items you own.",
                    description:
                      "Check your internet connection, and try again.",
                    status: "error",
                    duration: 5000,
                  });
                });
              }
            }}
          />
          <Button
            as="div"
            colorScheme={isChecked ? "green" : "gray"}
            size="lg"
            cursor="pointer"
            transitionDuration="0.4s"
            className={css`
              input:focus + & {
                box-shadow: ${theme.shadows.outline};
              }
            `}
          >
            <IconCheckbox
              icon={<CheckIcon />}
              isChecked={isChecked}
              marginRight="0.5em"
            />
            I own this
          </Button>
        </Box>
      )}
    </ClassNames>
  );
}

function ItemPageWantButton({ itemId, isChecked }) {
  const theme = useTheme();
  const toast = useToast();

  const [sendAddMutation] = useMutation(
    gql`
      mutation ItemPageWantButtonAdd($itemId: ID!) {
        addToItemsCurrentUserWants(itemId: $itemId) {
          id
          currentUserWantsThis
        }
      }
    `,
    {
      variables: { itemId },
      context: { sendAuth: true },
      optimisticResponse: {
        __typename: "Mutation",
        addToItemsCurrentUserWants: {
          __typename: "Item",
          id: itemId,
          currentUserWantsThis: true,
        },
      },
      // TODO: Refactor the mutation result to include closet lists
      refetchQueries: [
        {
          query: ITEM_PAGE_OWN_WANT_BUTTONS_QUERY,
          variables: { itemId },
          context: { sendAuth: true },
        },
      ],
    },
  );

  const [sendRemoveMutation] = useMutation(
    gql`
      mutation ItemPageWantButtonRemove($itemId: ID!) {
        removeFromItemsCurrentUserWants(itemId: $itemId) {
          id
          currentUserWantsThis
        }
      }
    `,
    {
      variables: { itemId },
      context: { sendAuth: true },
      optimisticResponse: {
        __typename: "Mutation",
        removeFromItemsCurrentUserWants: {
          __typename: "Item",
          id: itemId,
          currentUserWantsThis: false,
        },
      },
      // TODO: Refactor the mutation result to include closet lists
      refetchQueries: [
        {
          query: ITEM_PAGE_OWN_WANT_BUTTONS_QUERY,
          variables: { itemId },
          context: { sendAuth: true },
        },
      ],
    },
  );

  return (
    <ClassNames>
      {({ css }) => (
        <Box as="label">
          <VisuallyHidden
            as="input"
            type="checkbox"
            checked={isChecked}
            onChange={(e) => {
              if (e.target.checked) {
                sendAddMutation().catch((e) => {
                  console.error(e);
                  toast({
                    title: "We had trouble adding this to the items you want.",
                    description:
                      "Check your internet connection, and try again.",
                    status: "error",
                    duration: 5000,
                  });
                });
              } else {
                sendRemoveMutation().catch((e) => {
                  console.error(e);
                  toast({
                    title:
                      "We had trouble removing this from the items you want.",
                    description:
                      "Check your internet connection, and try again.",
                    status: "error",
                    duration: 5000,
                  });
                });
              }
            }}
          />
          <Button
            as="div"
            colorScheme={isChecked ? "blue" : "gray"}
            size="lg"
            cursor="pointer"
            transitionDuration="0.4s"
            className={css`
              input:focus + & {
                box-shadow: ${theme.shadows.outline};
              }
            `}
          >
            <IconCheckbox
              icon={<StarIcon />}
              isChecked={isChecked}
              marginRight="0.5em"
            />
            I want this
          </Button>
        </Box>
      )}
    </ClassNames>
  );
}

function ItemPageTradeLinks({ itemId, isEmbedded }) {
  const { data, loading, error } = useQuery(
    gql`
      query ItemPageTradeLinks($itemId: ID!) {
        item(id: $itemId) {
          id
          numUsersOfferingThis
          numUsersSeekingThis
        }
      }
    `,
    { variables: { itemId } },
  );

  if (error) {
    return <Box color="red.400">{error.message}</Box>;
  }

  return (
    <HStack spacing="2">
      <Box as="header" fontSize="sm" fontWeight="bold">
        Trading:
      </Box>
      <SubtleSkeleton isLoaded={!loading}>
        <ItemPageTradeLink
          href={`/items/${itemId}/trades/offering`}
          count={data?.item?.numUsersOfferingThis || 0}
          label="offering"
          colorScheme="green"
          isEmbedded={isEmbedded}
        />
      </SubtleSkeleton>
      <SubtleSkeleton isLoaded={!loading}>
        <ItemPageTradeLink
          href={`/items/${itemId}/trades/seeking`}
          count={data?.item?.numUsersSeekingThis || 0}
          label="seeking"
          colorScheme="blue"
          isEmbedded={isEmbedded}
        />
      </SubtleSkeleton>
    </HStack>
  );
}

function ItemPageTradeLink({ href, count, label, colorScheme, isEmbedded }) {
  return (
    <Button
      as="a"
      href={href}
      target={isEmbedded ? "_blank" : undefined}
      size="xs"
      variant="outline"
      colorScheme={colorScheme}
      borderRadius="full"
      paddingRight="1"
    >
      <Box display="grid" gridTemplateAreas="single-area">
        <Box gridArea="single-area" display="flex" justifyContent="center">
          {count} {label} <ChevronRightIcon minHeight="1.2em" />
        </Box>
        <Box
          gridArea="single-area"
          display="flex"
          justifyContent="center"
          visibility="hidden"
        >
          888 offering <ChevronRightIcon minHeight="1.2em" />
        </Box>
      </Box>
    </Button>
  );
}

function IconCheckbox({ icon, isChecked, ...props }) {
  return (
    <Box display="grid" gridTemplateAreas="the-same-area" {...props}>
      <Box
        gridArea="the-same-area"
        width="1em"
        height="1em"
        border="2px solid currentColor"
        borderRadius="md"
        opacity={isChecked ? "0" : "0.75"}
        transform={isChecked ? "scale(0.75)" : "none"}
        transition="all 0.4s"
      />
      <Box
        gridArea="the-same-area"
        display="flex"
        opacity={isChecked ? "1" : "0"}
        transform={isChecked ? "none" : "scale(0.1)"}
        transition="all 0.4s"
      >
        {icon}
      </Box>
    </Box>
  );
}

export function ItemPageOutfitPreview({ itemId }) {
  const idealPose = React.useMemo(
    () => (Math.random() > 0.5 ? "HAPPY_FEM" : "HAPPY_MASC"),
    [],
  );
  const [petState, setPetState] = React.useState({
    // We'll fill these in once the canonical appearance data arrives.
    speciesId: null,
    colorId: null,
    pose: null,
    isValid: false,

    // We use appearance ID, in addition to the above, to give the Apollo cache
    // a really clear hint that the canonical pet appearance we preloaded is
    // the exact right one to show! But switching species/color will null this
    // out again, and that's okay. (We'll do an unnecessary reload if you
    // switch back to it though... we could maybe do something clever there!)
    appearanceId: null,
  });
  const [preferredSpeciesId, setPreferredSpeciesId] = useLocalStorage(
    "DTIItemPreviewPreferredSpeciesId",
    null,
  );
  const [preferredColorId, setPreferredColorId] = useLocalStorage(
    "DTIItemPreviewPreferredColorId",
    null,
  );

  const setPetStateFromUserAction = React.useCallback(
    (newPetState) =>
      setPetState((prevPetState) => {
        // When the user _intentionally_ chooses a species or color, save it in
        // local storage for next time. (This won't update when e.g. their
        // preferred species or color isn't available for this item, so we update
        // to the canonical species or color automatically.)
        //
        // Re the "ifs", I have no reason to expect null to come in here, but,
        // since this is touching client-persisted data, I want it to be even more
        // reliable than usual!
        if (
          newPetState.speciesId &&
          newPetState.speciesId !== prevPetState.speciesId
        ) {
          setPreferredSpeciesId(newPetState.speciesId);
        }
        if (
          newPetState.colorId &&
          newPetState.colorId !== prevPetState.colorId
        ) {
          if (colorIsBasic(newPetState.colorId)) {
            // When the user chooses a basic color, don't index on it specifically,
            // and instead reset to use default colors.
            setPreferredColorId(null);
          } else {
            setPreferredColorId(newPetState.colorId);
          }
        }

        return newPetState;
      }),
    [setPreferredColorId, setPreferredSpeciesId],
  );

  // We don't need to reload this query when preferred species/color change, so
  // cache their initial values here to use as query arguments.
  const [initialPreferredSpeciesId] = React.useState(preferredSpeciesId);
  const [initialPreferredColorId] = React.useState(preferredColorId);

  // Start by loading the "canonical" pet and item appearance for the outfit
  // preview. We'll use this to initialize both the preview and the picker.
  //
  // If the user has a preferred species saved from using the ItemPage in the
  // past, we'll send that instead. This will return the appearance on that
  // species if possible, or the default canonical species if not.
  //
  // TODO: If this is a non-standard pet color, like Mutant, we'll do an extra
  //       query after this loads, because our Apollo cache can't detect the
  //       shared item appearance. (For standard colors though, our logic to
  //       cover standard-color switches works for this preloading too.)
  const {
    loading: loadingGQL,
    error: errorGQL,
    data,
  } = useQuery(
    gql`
      query ItemPageOutfitPreview(
        $itemId: ID!
        $preferredSpeciesId: ID
        $preferredColorId: ID
      ) {
        item(id: $itemId) {
          id
          name
          restrictedZones {
            id
            label
          }
          compatibleBodiesAndTheirZones {
            body {
              id
              representsAllBodies
              species {
                id
                name
              }
            }
            zones {
              id
              label
            }
          }
          canonicalAppearance(
            preferredSpeciesId: $preferredSpeciesId
            preferredColorId: $preferredColorId
          ) {
            id
            ...ItemAppearanceForOutfitPreview
            body {
              id
              canonicalAppearance(preferredColorId: $preferredColorId) {
                id
                species {
                  id
                  name
                }
                color {
                  id
                }
                pose

                ...PetAppearanceForOutfitPreview
              }
            }
          }
        }
      }

      ${itemAppearanceFragment}
      ${petAppearanceFragment}
    `,
    {
      variables: {
        itemId,
        preferredSpeciesId: initialPreferredSpeciesId,
        preferredColorId: initialPreferredColorId,
      },
      onCompleted: (data) => {
        const canonicalBody = data?.item?.canonicalAppearance?.body;
        const canonicalPetAppearance = canonicalBody?.canonicalAppearance;

        setPetState({
          speciesId: canonicalPetAppearance?.species?.id,
          colorId: canonicalPetAppearance?.color?.id,
          pose: canonicalPetAppearance?.pose,
          isValid: true,
          appearanceId: canonicalPetAppearance?.id,
        });
      },
    },
  );

  const compatibleBodies =
    data?.item?.compatibleBodiesAndTheirZones?.map(({ body }) => body) || [];
  const compatibleBodiesAndTheirZones =
    data?.item?.compatibleBodiesAndTheirZones || [];

  // If there's only one compatible body, and the canonical species's name
  // appears in the item name, then this is probably a species-specific item,
  // and we should adjust the UI to avoid implying that other species could
  // model it.
  const isProbablySpeciesSpecific =
    compatibleBodies.length === 1 &&
    !compatibleBodies[0].representsAllBodies &&
    (data?.item?.name || "").includes(
      data?.item?.canonicalAppearance?.body?.canonicalAppearance?.species?.name,
    );
  const couldProbablyModelMoreData = !isProbablySpeciesSpecific;

  // TODO: Does this double-trigger the HTTP request with SpeciesColorPicker?
  const {
    loading: loadingValids,
    error: errorValids,
    valids,
  } = useAllValidPetPoses();

  const [hasAnimations, setHasAnimations] = React.useState(false);
  const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);

  // This is like <OutfitPreview />, but we can use the appearance data, too!
  const { appearance, preview } = useOutfitPreview({
    speciesId: petState.speciesId,
    colorId: petState.colorId,
    pose: petState.pose,
    appearanceId: petState.appearanceId,
    wornItemIds: [itemId],
    isLoading: loadingGQL || loadingValids,
    spinnerVariant: "corner",
    engine: "canvas",
    onChangeHasAnimations: setHasAnimations,
  });

  // If there's an appearance loaded for this item, but it's empty, then the
  // item is incompatible. (There should only be one item appearance: this one!)
  const itemAppearance = appearance?.itemAppearances?.[0];
  const itemLayers = itemAppearance?.layers || [];
  const isCompatible = itemLayers.length > 0;
  const usesHTML5 = itemLayers.every(layerUsesHTML5);

  const onChange = React.useCallback(
    ({ speciesId, colorId }) => {
      const validPoses = getValidPoses(valids, speciesId, colorId);
      const pose = getClosestPose(validPoses, idealPose);
      setPetStateFromUserAction({
        speciesId,
        colorId,
        pose,
        isValid: true,
        appearanceId: null,
      });
    },
    [valids, idealPose, setPetStateFromUserAction],
  );

  const borderColor = useColorModeValue("green.700", "green.400");
  const errorColor = useColorModeValue("red.600", "red.400");

  const error = errorGQL || errorValids;
  if (error) {
    return <Box color="red.400">{error.message}</Box>;
  }

  return (
    <Grid
      templateAreas={{
        base: `
          "preview"
          "speciesColorPicker"
          "speciesFacesPicker"
          "zones"
        `,
        md: `
          "preview             speciesFacesPicker"
          "speciesColorPicker  zones"
        `,
      }}
      // HACK: Really I wanted 400px to match the natural height of the
      //       preview in md, but in Chromium that creates a scrollbar and
      //       401px doesn't, not sure exactly why?
      templateRows={{
        base: "auto auto 200px auto",
        md: "401px auto",
      }}
      templateColumns={{
        base: "minmax(min-content, 400px)",
        md: "minmax(min-content, 400px) fit-content(480px)",
      }}
      rowGap="4"
      columnGap="6"
      justifyContent="center"
      width="100%"
    >
      <AspectRatio
        gridArea="preview"
        maxWidth="400px"
        maxHeight="400px"
        ratio="1"
        border="1px"
        borderColor={borderColor}
        transition="border-color 0.2s"
        borderRadius="lg"
        boxShadow="lg"
        overflow="hidden"
      >
        <Box>
          {petState.isValid && preview}
          <CustomizeMoreButton
            speciesId={petState.speciesId}
            colorId={petState.colorId}
            pose={petState.pose}
            itemId={itemId}
            isDisabled={!petState.isValid}
          />
          {hasAnimations && (
            <PlayPauseButton
              isPaused={isPaused}
              onClick={() => setIsPaused(!isPaused)}
            />
          )}
        </Box>
      </AspectRatio>
      <Flex gridArea="speciesColorPicker" alignSelf="start" align="center">
        <Box
          // This box grows at the same rate as the box on the right, so the
          // middle box will be centered, if there's space!
          flex="1 0 0"
        />
        <SpeciesColorPicker
          speciesId={petState.speciesId}
          colorId={petState.colorId}
          pose={petState.pose}
          idealPose={idealPose}
          onChange={(species, color, isValid, closestPose) => {
            setPetStateFromUserAction({
              speciesId: species.id,
              colorId: color.id,
              pose: closestPose,
              isValid,
              appearanceId: null,
            });
          }}
          speciesIsDisabled={isProbablySpeciesSpecific}
          size="sm"
          showPlaceholders
        />
        <Box flex="1 0 0" lineHeight="1" paddingLeft="1">
          {
            // Wait for us to start _requesting_ the appearance, and _then_
            // for it to load, and _then_ check compatibility.
            !loadingGQL &&
              !appearance.loading &&
              petState.isValid &&
              !isCompatible && (
                <Tooltip
                  label={
                    couldProbablyModelMoreData
                      ? "Item needs models"
                      : "Not compatible"
                  }
                  placement="top"
                >
                  <WarningIcon
                    color={errorColor}
                    transition="color 0.2"
                    marginLeft="2"
                    borderRadius="full"
                    tabIndex="0"
                    _focus={{ outline: "none", boxShadow: "outline" }}
                  />
                </Tooltip>
              )
          }
        </Box>
      </Flex>
      <Box
        gridArea="speciesFacesPicker"
        paddingTop="2"
        overflow="auto"
        padding="8px"
      >
        <SpeciesFacesPicker
          selectedSpeciesId={petState.speciesId}
          selectedColorId={petState.colorId}
          compatibleBodies={compatibleBodies}
          couldProbablyModelMoreData={couldProbablyModelMoreData}
          onChange={onChange}
          isLoading={loadingGQL || loadingValids}
        />
      </Box>
      <Flex gridArea="zones" justifySelf="center" align="center">
        {compatibleBodiesAndTheirZones.length > 0 && (
          <ItemZonesInfo
            compatibleBodiesAndTheirZones={compatibleBodiesAndTheirZones}
            restrictedZones={data?.item?.restrictedZones || []}
          />
        )}
        <Box width="6" />
        <Flex
          // Avoid layout shift while loading
          minWidth="54px"
        >
          <HTML5Badge
            usesHTML5={usesHTML5}
            // If we're not compatible, act the same as if we're loading:
            // don't change the badge, but don't show one yet if we don't
            // have one yet.
            isLoading={appearance.loading || !isCompatible}
          />
        </Flex>
      </Flex>
    </Grid>
  );
}

function CustomizeMoreButton({ speciesId, colorId, pose, itemId, isDisabled }) {
  const url =
    `/outfits/new?species=${speciesId}&color=${colorId}&pose=${pose}&` +
    `objects[]=${itemId}`;

  // The default background is good in light mode, but in dark mode it's a
  // very subtle transparent white... make it a semi-transparent black, for
  // better contrast against light-colored background items!
  const backgroundColor = useColorModeValue(undefined, "blackAlpha.700");
  const backgroundColorHover = useColorModeValue(undefined, "blackAlpha.900");

  return (
    <LinkOrButton
      href={isDisabled ? null : url}
      role="group"
      position="absolute"
      top="2"
      right="2"
      size="sm"
      background={backgroundColor}
      _hover={{ backgroundColor: backgroundColorHover }}
      _focus={{ backgroundColor: backgroundColorHover, boxShadow: "outline" }}
      boxShadow="sm"
      isDisabled={isDisabled}
    >
      <ExpandOnGroupHover paddingRight="2">Customize more</ExpandOnGroupHover>
      <EditIcon />
    </LinkOrButton>
  );
}

function LinkOrButton({ href, ...props }) {
  if (href != null) {
    return <Button as="a" href={href} {...props} />;
  } else {
    return <Button {...props} />;
  }
}

/**
 * ExpandOnGroupHover starts at width=0, and expands to full width when a
 * parent with role="group" gains hover or focus state.
 */
function ExpandOnGroupHover({ children, ...props }) {
  const [measuredWidth, setMeasuredWidth] = React.useState(null);
  const measurerRef = React.useRef(null);
  const prefersReducedMotion = usePrefersReducedMotion();

  React.useLayoutEffect(() => {
    if (!measurerRef) {
      // I don't think this is possible, but I'd like to know if it happens!
      logAndCapture(
        new Error(
          `Measurer node not ready during effect. Transition won't be smooth.`,
        ),
      );
      return;
    }

    if (measuredWidth != null) {
      // Skip re-measuring when we already have a measured width. This is
      // mainly defensive, to prevent the possibility of loops, even though
      // this algorithm should be stable!
      return;
    }

    const newMeasuredWidth = measurerRef.current.offsetWidth;
    setMeasuredWidth(newMeasuredWidth);
  }, [measuredWidth]);

  return (
    <Flex
      // In block layout, the overflowing children would _also_ be constrained
      // to width 0. But in flex layout, overflowing children _keep_ their
      // natural size, so we can measure it even when not visible.
      width="0"
      overflow="hidden"
      // Right-align the children, to keep the text feeling right-aligned when
      // we expand. (To support left-side expansion, make this a prop!)
      justify="flex-end"
      // If the width somehow isn't measured yet, expand to width `auto`, which
      // won't transition smoothly but at least will work!
      _groupHover={{ width: measuredWidth ? measuredWidth + "px" : "auto" }}
      _groupFocus={{ width: measuredWidth ? measuredWidth + "px" : "auto" }}
      transition={!prefersReducedMotion && "width 0.2s"}
    >
      <Box ref={measurerRef} {...props}>
        {children}
      </Box>
    </Flex>
  );
}

function PlayPauseButton({ isPaused, onClick }) {
  return (
    <IconButton
      icon={isPaused ? <MdPlayArrow /> : <MdPause />}
      aria-label={isPaused ? "Play" : "Pause"}
      onClick={onClick}
      borderRadius="full"
      boxShadow="md"
      color="gray.50"
      backgroundColor="blackAlpha.700"
      position="absolute"
      bottom="2"
      left="2"
      _hover={{ backgroundColor: "blackAlpha.900" }}
      _focus={{ backgroundColor: "blackAlpha.900" }}
    />
  );
}

export function ItemZonesInfo({
  compatibleBodiesAndTheirZones,
  restrictedZones,
}) {
  // Reorganize the body-and-zones data, into zone-and-bodies data. Also, we're
  // merging zones with the same label, because that's how user-facing zone UI
  // generally works!
  const zoneLabelsAndTheirBodiesMap = {};
  for (const { body, zones } of compatibleBodiesAndTheirZones) {
    for (const zone of zones) {
      if (!zoneLabelsAndTheirBodiesMap[zone.label]) {
        zoneLabelsAndTheirBodiesMap[zone.label] = {
          zoneLabel: zone.label,
          bodies: [],
        };
      }
      zoneLabelsAndTheirBodiesMap[zone.label].bodies.push(body);
    }
  }
  const zoneLabelsAndTheirBodies = Object.values(zoneLabelsAndTheirBodiesMap);

  const sortedZonesAndTheirBodies = [...zoneLabelsAndTheirBodies].sort((a, b) =>
    buildSortKeyForZoneLabelsAndTheirBodies(a).localeCompare(
      buildSortKeyForZoneLabelsAndTheirBodies(b),
    ),
  );

  const restrictedZoneLabels = [
    ...new Set(restrictedZones.map((z) => z.label)),
  ].sort();

  // We only show body info if there's more than one group of bodies to talk
  // about. If they all have the same zones, it's clear from context that any
  // preview available in the list has the zones listed here.
  const bodyGroups = new Set(
    zoneLabelsAndTheirBodies.map(({ bodies }) =>
      bodies.map((b) => b.id).join(","),
    ),
  );
  const showBodyInfo = bodyGroups.size > 1;

  return (
    <Flex
      fontSize="sm"
      textAlign="center"
      // If the text gets too long, wrap Restricts onto another line, and center
      // them relative to each other.
      wrap="wrap"
      justify="center"
      data-test-id="item-zones-info"
    >
      <Box flex="0 0 auto" maxWidth="100%">
        <Box as="header" fontWeight="bold" display="inline">
          Occupies:
        </Box>{" "}
        <Box as="ul" listStyleType="none" display="inline">
          {sortedZonesAndTheirBodies.map(({ zoneLabel, bodies }) => (
            <Box
              key={zoneLabel}
              as="li"
              display="inline"
              _notLast={{ _after: { content: '", "' } }}
            >
              <Box
                as="span"
                // Don't wrap any of the list item content. But, by putting
                // this in an extra container element, we _do_ allow wrapping
                // _between_ list items.
                whiteSpace="nowrap"
              >
                <ItemZonesInfoListItem
                  zoneLabel={zoneLabel}
                  bodies={bodies}
                  showBodyInfo={showBodyInfo}
                />
              </Box>
            </Box>
          ))}
        </Box>
      </Box>
      <Box width="4" flex="0 0 auto" />
      <Box flex="0 0 auto" maxWidth="100%">
        <Box as="header" fontWeight="bold" display="inline">
          Restricts:
        </Box>{" "}
        {restrictedZoneLabels.length > 0 ? (
          <Box as="ul" listStyleType="none" display="inline">
            {restrictedZoneLabels.map((zoneLabel) => (
              <Box
                key={zoneLabel}
                as="li"
                display="inline"
                _notLast={{ _after: { content: '", "' } }}
              >
                <Box
                  as="span"
                  // Don't wrap any of the list item content. But, by putting
                  // this in an extra container element, we _do_ allow wrapping
                  // _between_ list items.
                  whiteSpace="nowrap"
                >
                  {zoneLabel}
                </Box>
              </Box>
            ))}
          </Box>
        ) : (
          <Box as="span" fontStyle="italic" opacity="0.8">
            N/A
          </Box>
        )}
      </Box>
    </Flex>
  );
}

function ItemZonesInfoListItem({ zoneLabel, bodies, showBodyInfo }) {
  let content = zoneLabel;

  if (showBodyInfo) {
    if (bodies.some((b) => b.representsAllBodies)) {
      content = <>{content} (all species)</>;
    } else {
      // TODO: This is a bit reductive, if it's different for like special
      //       colors, e.g. Blue Acara vs Mutant Acara, this will just show
      //       "Acara" in either case! (We are at least gonna be defensive here
      //       and remove duplicates, though, in case both the Blue Acara and
      //       Mutant Acara body end up in the same list.)
      const speciesNames = new Set(bodies.map((b) => b.species.name));
      const speciesListString = [...speciesNames].sort().join(", ");

      content = (
        <>
          {content}{" "}
          <Tooltip
            label={speciesListString}
            textAlign="center"
            placement="bottom"
          >
            <Box
              as="span"
              tabIndex="0"
              _focus={{ outline: "none", boxShadow: "outline" }}
              fontStyle="italic"
              textDecoration="underline"
              style={{ textDecorationStyle: "dotted" }}
              opacity="0.8"
            >
              {/* Show the speciesNames count, even though it's less info,
               * because it's more important that the tooltip content matches
               * the count we show! */}
              ({speciesNames.size} species)
            </Box>
          </Tooltip>
        </>
      );
    }
  }

  return content;
}

function buildSortKeyForZoneLabelsAndTheirBodies({ zoneLabel, bodies }) {
  // Sort by "represents all bodies", then by body count descending, then
  // alphabetically.
  const representsAllBodies = bodies.some((body) => body.representsAllBodies);

  // To sort by body count _descending_, we subtract it from a large number.
  // Then, to make it work in string comparison, we pad it with leading zeroes.
  // Hacky but solid!
  const inverseBodyCount = (9999 - bodies.length).toString().padStart(4, "0");

  return `${representsAllBodies ? "A" : "Z"}-${inverseBodyCount}-${zoneLabel}`;
}