import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import {
  ArrowUpCircleIcon,
  EnvelopeIcon,
  LockOpenIcon,
  UserPlusIcon,
} from "@heroicons/react/20/solid";
import { json } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { Fragment, useRef, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import simplur from "simplur";
import { z } from "zod";
import { MainCenteredContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { FormTitle } from "~/components/primitives/FormTitle";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Select, SelectItem } from "~/components/primitives/Select";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { inviteMembers } from "~/models/member.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
import { scheduleEmail } from "~/services/scheduleEmail.server";
import { rbac } from "~/services/rbac.server";
import { ssoController } from "~/services/sso.server";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder";
import { isAtOrBelow } from "~/utils/inviteRoleLadder";
import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route";

const Params = z.object({
  organizationSlug: z.string(),
});

export const loader = dashboardLoader(
  {
    params: Params,
    context: async (params) => {
      const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
      return organizationId ? { organizationId } : {};
    },
    authorization: {
      action: "manage",
      resource: { type: "members" },
      message: "With your current role, you can't invite team members.",
    },
  },
  async ({ user, context, ability }) => {
    const organizationId = context.organizationId;
    if (!organizationId) {
      throw new Response("Not Found", { status: 404 });
    }
    const userId = user.id;

    const presenter = new TeamPresenter();
    const result = await presenter.call({
      userId,
      organizationId,
    });

    if (!result) {
      throw new Response("Not Found", { status: 404 });
    }

    // Inviter's own role drives the "below their level" filter on the
    // dropdown. Plus assignable role IDs already encode the org's plan
    // tier — the intersection is what we offer.
    const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
      rbac.getUserRole({ userId, organizationId }),
      rbac.getAssignableRoleIds(organizationId),
      rbac.systemRoles(organizationId),
    ]);

    // Build the dropdown's offerable set server-side: roles that are
    // (a) assignable on the current plan AND (b) at or below the
    // inviter's own level. The client just renders these — it doesn't
    // need to know about the system-role catalogue or the ladder.
    const assignableSet = new Set(assignableRoleIds);
    const offerableRoleIds = systemRoles
      ? result.roles
          .filter(
            (r) =>
              assignableSet.has(r.id) && isAtOrBelow(systemRoles, inviterRole?.id ?? null, r.id)
          )
          .map((r) => r.id)
      : [];

    // Buying seats is a billing operation: surface whether this user can, so
    // the purchase modal disables its trigger (the team action enforces it).
    const canManageBilling = ability.can("manage", { type: "billing" });

    return typedjson({ ...result, offerableRoleIds, canManageBilling });
  }
);

// Sentinel for "no RBAC role attached to invite" — the runtime
// fallback will derive a role from the legacy OrgMember.role write at
// accept time. Used when the org has no RBAC plugin installed (the
// dropdown is hidden) or as a defensive default.
const NO_RBAC_ROLE = "__no_rbac_role__";

const schema = z.object({
  emails: z.preprocess((i) => {
    if (typeof i === "string") return [i];

    if (Array.isArray(i)) {
      const emails = i.filter((v) => typeof v === "string" && v !== "");
      if (emails.length === 0) {
        return [""];
      }
      return emails;
    }

    return [""];
  }, z.string().email().array().nonempty("At least one email is required")),
  rbacRoleId: z.string().optional(),
});

export const action = dashboardAction(
  {
    params: Params,
    context: async (params) => {
      const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
      return organizationId ? { organizationId } : {};
    },
    authorization: { action: "manage", resource: { type: "members" } },
  },
  async ({ request, params, user, context }) => {
    const userId = user.id;
    const { organizationSlug } = params;

    const formData = await request.formData();
    const submission = parseWithZod(formData, { schema });

    if (submission.status !== "success") {
      return json(submission.reply());
    }

    // Directory-managed membership: inviting is disabled (the directory is the
    // authority). Enforced here; the Team page also hides the invite button.
    if (context.organizationId) {
      const policy = await ssoController.getMembershipPolicy(context.organizationId);
      if (policy.isOk() && !policy.value.manualMembershipAllowed) {
        return json(
          { errors: { body: "Membership is managed by Directory Sync" } },
          { status: 403 }
        );
      }
    }

    // Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown
    // role → don't pass one through; the runtime fallback handles it.
    // Validation: the chosen role must be in the org's assignable set
    // (plan-tier) and at or below the inviter's own level.
    let resolvedRbacRoleId: string | null = null;
    const submittedRbacRoleId = submission.value.rbacRoleId;
    if (submittedRbacRoleId && submittedRbacRoleId !== NO_RBAC_ROLE) {
      const org = await $replica.organization.findFirst({
        where: { slug: organizationSlug },
        select: { id: true },
      });
      if (!org) {
        return json({ errors: { body: "Organization not found" } }, { status: 404 });
      }
      const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
        rbac.getUserRole({ userId, organizationId: org.id }),
        rbac.getAssignableRoleIds(org.id),
        rbac.systemRoles(org.id),
      ]);
      if (!systemRoles) {
        // No plugin installed but the form somehow submitted a role id —
        // ignore it (fall through to legacy behaviour rather than 400).
        resolvedRbacRoleId = null;
      } else {
        const assignable = new Set(assignableRoleIds);
        if (!assignable.has(submittedRbacRoleId)) {
          return json(
            { errors: { body: "You can't invite someone with this role on your current plan" } },
            { status: 400 }
          );
        }
        if (!isAtOrBelow(systemRoles, inviterRole?.id ?? null, submittedRbacRoleId)) {
          return json(
            { errors: { body: "You can only invite members at or below your own role" } },
            { status: 403 }
          );
        }
        resolvedRbacRoleId = submittedRbacRoleId;
      }
    }

    try {
      const invites = await inviteMembers({
        slug: organizationSlug,
        emails: submission.value.emails,
        userId,
        rbacRoleId: resolvedRbacRoleId,
      });

      for (const invite of invites) {
        try {
          await scheduleEmail({
            email: "invite",
            to: invite.email,
            orgName: invite.organization.title,
            inviterName: invite.inviter.name ?? undefined,
            inviterEmail: invite.inviter.email,
            inviteLink: `${env.LOGIN_ORIGIN}${acceptInvitePath(invite.token)}`,
          });
        } catch (error) {
          console.error("Failed to send invite email");
          console.error(error);
        }
      }

      return redirectWithSuccessMessage(
        organizationTeamPath(invites[0].organization),
        request,
        simplur`${submission.value.emails.length} member[|s] invited`
      );
    } catch (error: any) {
      return json({ errors: { body: error.message } }, { status: 400 });
    }
  }
);

export default function Page() {
  const {
    limits,
    canPurchaseSeats,
    seatPricing,
    extraSeats,
    maxSeatQuota,
    planSeatLimit,
    roles,
    offerableRoleIds,
    canManageBilling,
  } = useTypedLoaderData<typeof loader>();
  const [total, setTotal] = useState(limits.used);
  const organization = useOrganization();
  const lastSubmission = useActionData();

  // The loader filtered the catalogue to roles this inviter can
  // actually assign (plan tier × strict-below-my-level). With no plugin
  // installed, offerableRoleIds is [] and the picker hides entirely.
  const offerableSet = new Set(offerableRoleIds);
  const offerable = roles.filter((r) => offerableSet.has(r.id));
  const showRolePicker = offerable.length > 0;

  // Default to the lowest-tier offered role (the loader returns roles
  // in its allRoles order, which the plugin emits Owner→Member; the
  // last entry is the most restrictive).
  const defaultRoleId = showRolePicker ? offerable[offerable.length - 1].id : NO_RBAC_ROLE;
  const [selectedRoleId, setSelectedRoleId] = useState(defaultRoleId);

  const [form, fields] = useForm<z.infer<typeof schema>>({
    id: "invite-members",
    // TODO: type this
    lastResult: lastSubmission as any,
    onValidate({ formData }) {
      return parseWithZod(formData, { schema });
    },
    defaultValue: {
      emails: [""],
    },
  });
  const { emails } = fields;

  const fieldValues = useRef<string[]>([""]);
  const emailFields = emails.getFieldList();

  return (
    <MainCenteredContainer className="max-w-104 rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
      <div>
        <FormTitle
          LeadingIcon={<UserPlusIcon className="size-6 text-indigo-500" />}
          title="Invite team members"
          description={`Invite new team members to ${organization.title}.`}
        />
        {total > limits.limit &&
          (canPurchaseSeats && seatPricing ? (
            <InfoPanel
              variant="upgrade"
              icon={LockOpenIcon}
              iconClassName="text-indigo-500"
              title="Need more seats?"
              accessory={
                <PurchaseSeatsModal
                  seatPricing={seatPricing}
                  extraSeats={extraSeats}
                  usedSeats={limits.used}
                  maxQuota={maxSeatQuota}
                  planSeatLimit={planSeatLimit}
                  canManageBilling={canManageBilling}
                  triggerButton={<Button variant="primary/small">Purchase more seats…</Button>}
                />
              }
              panelClassName="mb-4"
            >
              <Paragraph variant="small">
                You've used all {limits.limit} of your available team members. Purchase extra seats
                to add more.
              </Paragraph>
            </InfoPanel>
          ) : (
            <InfoPanel
              variant="upgrade"
              icon={LockOpenIcon}
              iconClassName="text-indigo-500"
              title="Unlock more team members"
              accessory={
                <LinkButton
                  to={v3BillingPath(organization)}
                  variant="secondary/small"
                  LeadingIcon={ArrowUpCircleIcon}
                  leadingIconClassName="text-indigo-500"
                >
                  Upgrade
                </LinkButton>
              }
              panelClassName="mb-4"
            >
              <Paragraph variant="small">
                You've used all {limits.limit} of your available team members. Upgrade your plan to
                add more.
              </Paragraph>
            </InfoPanel>
          ))}
        <Form method="post" {...getFormProps(form)}>
          <Fieldset>
            <InputGroup>
              <Label htmlFor={emails.id}>Email addresses</Label>
              {emailFields.map((email, index) => (
                <Fragment key={email.key}>
                  <Input
                    {...getInputProps(email, { type: "email" })}
                    placeholder={index === 0 ? "Enter an email address" : "Add another email"}
                    icon={EnvelopeIcon}
                    autoFocus={index === 0}
                    onChange={(e) => {
                      fieldValues.current[index] = e.target.value;
                      const filledFields = fieldValues.current.filter((v) => v !== "");
                      setTotal(limits.used + filledFields.length);
                      if (
                        emailFields.length === fieldValues.current.length &&
                        fieldValues.current.every((v) => v !== "")
                      ) {
                        form.insert({ name: emails.name });
                      }
                    }}
                  />
                  <FormError id={email.errorId}>{email.errors}</FormError>
                </Fragment>
              ))}
            </InputGroup>
            {showRolePicker ? (
              <InputGroup>
                <Label htmlFor="rbacRoleId">Role</Label>
                <input type="hidden" name="rbacRoleId" value={selectedRoleId} />
                <Select<string, (typeof offerable)[number]>
                  defaultValue={defaultRoleId}
                  items={offerable}
                  variant="tertiary/medium"
                  dropdownIcon
                  text={(v) => offerable.find((r) => r.id === v)?.name ?? "Pick a role"}
                  setValue={(next) => {
                    if (typeof next === "string") setSelectedRoleId(next);
                  }}
                >
                  {(items) =>
                    items.map((role) => (
                      <SelectItem key={role.id} value={role.id}>
                        {role.name}
                      </SelectItem>
                    ))
                  }
                </Select>
                <Paragraph variant="extra-small" className="text-text-dimmed">
                  Invitees join with this role. They can be promoted later from the Team page.
                </Paragraph>
              </InputGroup>
            ) : null}
            <FormButtons
              confirmButton={
                <Button type="submit" variant={"primary/small"} disabled={total > limits.limit}>
                  Send invitations
                </Button>
              }
              cancelButton={
                <LinkButton to={organizationTeamPath(organization)} variant={"secondary/small"}>
                  Cancel
                </LinkButton>
              }
            />
          </Fieldset>
        </Form>
      </div>
    </MainCenteredContainer>
  );
}
