import type { FastifyInstance, FastifyReply } from "fastify";
import { z } from "zod";
import { requireSession } from "../../lib/auth.js";
import { writeAuditLog } from "../../lib/audit.js";
import {
  listClientSponsorsByClientIds,
  type ClientSponsorSummary,
} from "../../lib/client-sponsors.js";
import { createId } from "../../lib/id.js";
import {
  ensureClientStructureValueSchema,
  listClientStructureFieldValues,
  type ClientStructureValueRecord as StoredClientStructureValueRecord,
} from "../../lib/client-structure-values.js";
import { prisma } from "../../lib/prisma.js";
import { getSessionProfile } from "../../lib/session.js";
import { createAiRun, finishAiRun, runJsonChatCompletion } from "../../lib/tenant-ai.js";

const CLIENT_STRUCTURE_DESCRIPTION_MAX_LENGTH = 2000;
const CLIENT_STRUCTURE_AI_INSTRUCTIONS_MAX_LENGTH = 12000;
const CLIENT_STRUCTURE_CONDITIONAL_PROMPT_MAX_LENGTH = 500;
const CLIENT_STRUCTURE_DOCUMENT_TYPE_NAME_MAX_LENGTH = 100;
const CLIENT_STRUCTURE_DOCUMENT_TYPE_DESCRIPTION_MAX_LENGTH = 255;
const CLIENT_STRUCTURE_AI_SUGGESTION_MIN_LABEL_LENGTH = 3;

const clientInputSchema = z.object({
  firstName: z.string().min(1).max(100),
  middleName: z.string().max(100).optional().or(z.literal("")),
  lastName: z.string().min(1).max(100),
  email: z.string().email().optional().or(z.literal("")),
  phone: z.string().max(50).optional().or(z.literal("")),
  preferredLanguage: z.string().max(30).optional().or(z.literal("")),
});

const clientCreateSchema = clientInputSchema;

const clientIdParamSchema = z.object({
  clientId: z.string().uuid(),
});

const clientImportSchema = z.object({
  clients: z.array(clientInputSchema).min(1).max(500),
});

const clientSponsorUpdateSchema = z
  .object({
    clear: z.boolean().optional().default(false),
    entityType: z.enum(["person", "company"]).optional().default("company"),
    firstName: z.string().max(100).optional().or(z.literal("")),
    lastName: z.string().max(100).optional().or(z.literal("")),
    companyName: z.string().max(255).optional().or(z.literal("")),
    email: z.string().email().optional().or(z.literal("")),
    phone: z.string().max(50).optional().or(z.literal("")),
  })
  .superRefine((value, ctx) => {
    if (value.clear) {
      return;
    }

    if (value.entityType === "company" && !normalizeOptionalValue(value.companyName)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "companyName is required when entityType is company",
        path: ["companyName"],
      });
    }

    if (
      value.entityType === "person" &&
      !normalizeOptionalValue(value.firstName) &&
      !normalizeOptionalValue(value.lastName)
    ) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "firstName or lastName is required when entityType is person",
        path: ["firstName"],
      });
    }
  });

const clientListQuerySchema = z.object({
  q: z.string().trim().max(100).optional(),
  limit: z.coerce.number().int().min(1).max(100).optional(),
});

const clientStructureNodeTypeSchema = z.enum(["group", "field", "document"]);
const clientStructureDataTypeSchema = z.enum([
  "text",
  "long_text",
  "number",
  "boolean",
  "date",
  "email",
  "phone",
]);

const clientStructureNodeParamSchema = z.object({
  nodeId: z.string().uuid(),
});

const clientStructureDocumentTypeCreateSchema = z.object({
  name: z.string().trim().min(1).max(CLIENT_STRUCTURE_DOCUMENT_TYPE_NAME_MAX_LENGTH),
  description: z
    .string()
    .trim()
    .max(CLIENT_STRUCTURE_DOCUMENT_TYPE_DESCRIPTION_MAX_LENGTH)
    .optional()
    .or(z.literal("")),
});

const clientStructureAiSuggestionSchema = z.object({
  parentId: z.string().uuid().nullable().optional(),
  nodeType: clientStructureNodeTypeSchema,
  label: z.string().trim().min(CLIENT_STRUCTURE_AI_SUGGESTION_MIN_LABEL_LENGTH).max(255),
});

const clientStructureAiSuggestionResultSchema = z.object({
  description: z.string().optional().default(""),
  aiInstructions: z.string().optional().default(""),
  conditional: z.boolean().optional().default(false),
  conditionalPrompt: z.string().optional().default(""),
  dataType: clientStructureDataTypeSchema.nullish().default(null),
  required: z.boolean().optional().default(false),
  repeatable: z.boolean().optional().default(false),
  documentTypeCode: z.string().nullish().default(null),
});

const clientStructureCreateSchema = z
  .object({
    parentId: z.string().uuid().nullable().optional(),
    nodeType: clientStructureNodeTypeSchema,
    label: z.string().trim().min(1).max(255),
    fieldKey: z.string().trim().max(120).optional().or(z.literal("")),
    description: z.string().trim().max(2000).optional().or(z.literal("")),
    aiInstructions: z
      .string()
      .trim()
      .max(CLIENT_STRUCTURE_AI_INSTRUCTIONS_MAX_LENGTH)
      .optional()
      .or(z.literal("")),
    dataType: clientStructureDataTypeSchema.optional(),
    documentTypeCode: z.string().trim().max(50).optional().or(z.literal("")),
    repeatable: z.boolean().optional().default(false),
    required: z.boolean().optional().default(false),
    conditional: z.boolean().optional().default(false),
    conditionalPrompt: z
      .string()
      .trim()
      .max(CLIENT_STRUCTURE_CONDITIONAL_PROMPT_MAX_LENGTH)
      .optional()
      .or(z.literal("")),
  })
  .superRefine((value, ctx) => {
    if (value.nodeType === "field" && !value.dataType) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "dataType is required when nodeType is field",
        path: ["dataType"],
      });
    }

    if (value.nodeType === "field" && value.repeatable) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "repeatable is only allowed when nodeType is group",
        path: ["repeatable"],
      });
    }

    if (value.nodeType === "field" && normalizeOptionalValue(value.documentTypeCode)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "documentTypeCode is only allowed when nodeType is document",
        path: ["documentTypeCode"],
      });
    }

    if (value.nodeType === "document" && !normalizeOptionalValue(value.documentTypeCode)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "documentTypeCode is required when nodeType is document",
        path: ["documentTypeCode"],
      });
    }

    if (value.nodeType === "document" && value.dataType) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "dataType is only allowed when nodeType is field",
        path: ["dataType"],
      });
    }

    if (value.nodeType === "document" && value.repeatable) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "repeatable is only allowed when nodeType is group",
        path: ["repeatable"],
      });
    }

    if (value.conditional && !normalizeOptionalValue(value.conditionalPrompt)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "conditionalPrompt is required when conditional is true",
        path: ["conditionalPrompt"],
      });
    }

    if (!value.conditional && normalizeOptionalValue(value.conditionalPrompt)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "conditionalPrompt is only allowed when conditional is true",
        path: ["conditionalPrompt"],
      });
    }
  });

const clientStructureUpdateSchema = z
  .object({
    label: z.string().trim().min(1).max(255),
    fieldKey: z.string().trim().max(120).optional().or(z.literal("")),
    description: z.string().trim().max(2000).optional().or(z.literal("")),
    aiInstructions: z
      .string()
      .trim()
      .max(CLIENT_STRUCTURE_AI_INSTRUCTIONS_MAX_LENGTH)
      .optional()
      .or(z.literal("")),
    dataType: clientStructureDataTypeSchema.optional(),
    documentTypeCode: z.string().trim().max(50).optional().or(z.literal("")),
    repeatable: z.boolean().optional().default(false),
    required: z.boolean().optional().default(false),
    conditional: z.boolean().optional().default(false),
    conditionalPrompt: z
      .string()
      .trim()
      .max(CLIENT_STRUCTURE_CONDITIONAL_PROMPT_MAX_LENGTH)
      .optional()
      .or(z.literal("")),
  })
  .superRefine((value, ctx) => {
    if (value.conditional && !normalizeOptionalValue(value.conditionalPrompt)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "conditionalPrompt is required when conditional is true",
        path: ["conditionalPrompt"],
      });
    }

    if (!value.conditional && normalizeOptionalValue(value.conditionalPrompt)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "conditionalPrompt is only allowed when conditional is true",
        path: ["conditionalPrompt"],
      });
    }
  });

const clientStructureReorderSchema = z.object({
  nodeId: z.string().uuid(),
  targetNodeId: z.string().uuid(),
  position: z.enum(["before", "after"]),
});

type ClientInput = z.infer<typeof clientInputSchema>;

type ClientRecord = {
  id: string;
  client_number: string | null;
  first_name: string;
  middle_name: string | null;
  last_name: string;
  email: string | null;
  phone: string | null;
  preferred_language: string | null;
  created_at: Date;
};

type ClientDetailRecord = ClientRecord & {
  law_firm_id: string;
  primary_office_id: string | null;
  preferred_name: string | null;
  date_of_birth: Date | null;
  gender: string | null;
  country_of_birth: string | null;
  country_of_citizenship: string | null;
  immigration_status: string | null;
  intake_channel_code: string | null;
  created_by_user_id: string | null;
  updated_at: Date;
  deleted_at: Date | null;
};

type ClientStructureNodeRecord = {
  id: string;
  law_firm_id: string;
  parent_id: string | null;
  node_type: string;
  field_key: string;
  label: string;
  description: string | null;
  ai_instructions: string | null;
  data_type: string | null;
  document_type_code: string | null;
  is_required: number;
  is_repeatable: number;
  is_conditional: number;
  conditional_prompt: string | null;
  sort_order: number;
  created_by_user_id: string | null;
  created_at: Date;
  updated_at: Date;
};

type ClientStructureDocumentTypeRecord = {
  code: string;
  name: string;
  description: string | null;
};

type ClientDetailDocumentMatchRecord = {
  id: string;
  client_structure_node_id: string | null;
  document_type_code: string;
  title: string;
  validation_status: string | null;
  validation_confidence: number | null;
  validation_reason: string | null;
  created_at: Date;
};

type ClientDetailStructureValueRecord = StoredClientStructureValueRecord;

type DefaultClientStructureNodeSeed = {
  fieldKey: string;
  label: string;
  nodeType: "group" | "field" | "document";
  parentFieldKey?: string;
  description?: string | null;
  dataType?: z.infer<typeof clientStructureDataTypeSchema>;
  documentTypeCode?: string | null;
  aiInstructions?: string | null;
  repeatable?: boolean;
  required?: boolean;
  conditional?: boolean;
  conditionalPrompt?: string | null;
  sortOrder: number;
};

type SerializedClient = ReturnType<typeof serializeClient>;

const defaultClientStructureDocumentTypeSeeds = [
  {
    code: "passport",
    name: "Passport",
    description: "Passport identity document",
    categoryCode: "identity",
    isIdentityDocument: 1,
    isExpirable: 1,
  },
  {
    code: "birth_certificate",
    name: "Birth Certificate",
    description: "Civil birth certificate",
    categoryCode: "civil_record",
    isIdentityDocument: 1,
    isExpirable: 0,
  },
  {
    code: "marriage_certificate",
    name: "Marriage Certificate",
    description: "Civil marriage certificate",
    categoryCode: "civil_record",
    isIdentityDocument: 0,
    isExpirable: 0,
  },
  {
    code: "resume",
    name: "Resume",
    description: "Professional resume or CV",
    categoryCode: "professional",
    isIdentityDocument: 0,
    isExpirable: 0,
  },
  {
    code: "employment_letter",
    name: "Employment Letter",
    description: "Offer letter or employment verification",
    categoryCode: "employment",
    isIdentityDocument: 0,
    isExpirable: 0,
  },
  {
    code: "pay_stub",
    name: "Pay Stub",
    description: "Proof of current compensation",
    categoryCode: "financial",
    isIdentityDocument: 0,
    isExpirable: 0,
  },
  {
    code: "tax_return",
    name: "Tax Return",
    description: "Income tax filing evidence",
    categoryCode: "financial",
    isIdentityDocument: 0,
    isExpirable: 0,
  },
  {
    code: "visa",
    name: "Visa",
    description: "Visa page or approval evidence",
    categoryCode: "immigration",
    isIdentityDocument: 1,
    isExpirable: 1,
  },
  {
    code: "i94",
    name: "I-94",
    description: "Admission record",
    categoryCode: "immigration",
    isIdentityDocument: 0,
    isExpirable: 0,
  },
  {
    code: "photo",
    name: "Photo",
    description: "Passport-style or evidence photo",
    categoryCode: "media",
    isIdentityDocument: 0,
    isExpirable: 0,
  },
  {
    code: "translation",
    name: "Translation",
    description: "Certified translation document",
    categoryCode: "supporting",
    isIdentityDocument: 0,
    isExpirable: 0,
  },
  {
    code: "other_supporting",
    name: "Other Supporting",
    description: "Any additional supporting evidence",
    categoryCode: "supporting",
    isIdentityDocument: 0,
    isExpirable: 0,
  },
] as const satisfies Array<{
  code: string;
  name: string;
  description: string;
  categoryCode: string;
  isIdentityDocument: 0 | 1;
  isExpirable: 0 | 1;
}>;

const defaultClientStructureNodeSeeds: DefaultClientStructureNodeSeed[] = [
  {
    fieldKey: "system.identity",
    label: "Identity",
    nodeType: "group",
    description: "Core personal identification fields for the client profile.",
    sortOrder: 0,
  },
  {
    fieldKey: "system.identity.client_number",
    label: "Client number",
    nodeType: "field",
    parentFieldKey: "system.identity",
    dataType: "text",
    sortOrder: 0,
  },
  {
    fieldKey: "system.identity.first_name",
    label: "First name",
    nodeType: "field",
    parentFieldKey: "system.identity",
    dataType: "text",
    required: true,
    sortOrder: 10,
  },
  {
    fieldKey: "system.identity.middle_name",
    label: "Middle name",
    nodeType: "field",
    parentFieldKey: "system.identity",
    dataType: "text",
    sortOrder: 20,
  },
  {
    fieldKey: "system.identity.last_name",
    label: "Surname",
    nodeType: "field",
    parentFieldKey: "system.identity",
    dataType: "text",
    required: true,
    sortOrder: 30,
  },
  {
    fieldKey: "system.identity.preferred_name",
    label: "Preferred name",
    nodeType: "field",
    parentFieldKey: "system.identity",
    dataType: "text",
    sortOrder: 40,
  },
  {
    fieldKey: "system.identity.date_of_birth",
    label: "Date of birth",
    nodeType: "field",
    parentFieldKey: "system.identity",
    dataType: "date",
    sortOrder: 50,
  },
  {
    fieldKey: "system.identity.gender",
    label: "Gender",
    nodeType: "field",
    parentFieldKey: "system.identity",
    dataType: "text",
    sortOrder: 60,
  },
  {
    fieldKey: "system.contact",
    label: "Contact",
    nodeType: "group",
    description: "Primary contact channels used to communicate with the client.",
    sortOrder: 10,
  },
  {
    fieldKey: "system.contact.email",
    label: "E-mail",
    nodeType: "field",
    parentFieldKey: "system.contact",
    dataType: "email",
    sortOrder: 0,
  },
  {
    fieldKey: "system.contact.phone",
    label: "Telephone",
    nodeType: "field",
    parentFieldKey: "system.contact",
    dataType: "phone",
    sortOrder: 10,
  },
  {
    fieldKey: "system.contact.preferred_language",
    label: "Preferred language",
    nodeType: "field",
    parentFieldKey: "system.contact",
    dataType: "text",
    sortOrder: 20,
  },
  {
    fieldKey: "system.immigration",
    label: "Immigration",
    nodeType: "group",
    description: "Immigration profile data used across cases and forms.",
    sortOrder: 20,
  },
  {
    fieldKey: "system.immigration.country_of_birth",
    label: "Country of birth",
    nodeType: "field",
    parentFieldKey: "system.immigration",
    dataType: "text",
    sortOrder: 0,
  },
  {
    fieldKey: "system.immigration.country_of_citizenship",
    label: "Country of citizenship",
    nodeType: "field",
    parentFieldKey: "system.immigration",
    dataType: "text",
    sortOrder: 10,
  },
  {
    fieldKey: "system.immigration.immigration_status",
    label: "Immigration status",
    nodeType: "field",
    parentFieldKey: "system.immigration",
    dataType: "text",
    sortOrder: 20,
  },
  {
    fieldKey: "system.immigration.intake_channel_code",
    label: "Intake channel",
    nodeType: "field",
    parentFieldKey: "system.immigration",
    dataType: "text",
    sortOrder: 30,
  },
  {
    fieldKey: "system.relationships",
    label: "Relationships",
    nodeType: "group",
    description: "Relationships linked to the client record.",
    sortOrder: 30,
  },
  {
    fieldKey: "system.relationships.sponsor",
    label: "Sponsor",
    nodeType: "group",
    parentFieldKey: "system.relationships",
    sortOrder: 0,
  },
  {
    fieldKey: "system.relationships.sponsor.id",
    label: "Record ID",
    nodeType: "field",
    parentFieldKey: "system.relationships.sponsor",
    dataType: "text",
    sortOrder: 0,
  },
  {
    fieldKey: "system.relationships.sponsor.entity_type",
    label: "Entity type",
    nodeType: "field",
    parentFieldKey: "system.relationships.sponsor",
    dataType: "text",
    sortOrder: 10,
  },
  {
    fieldKey: "system.relationships.sponsor.name",
    label: "Name",
    nodeType: "field",
    parentFieldKey: "system.relationships.sponsor",
    dataType: "text",
    sortOrder: 20,
  },
  {
    fieldKey: "system.relationships.sponsor.first_name",
    label: "First name",
    nodeType: "field",
    parentFieldKey: "system.relationships.sponsor",
    dataType: "text",
    sortOrder: 30,
  },
  {
    fieldKey: "system.relationships.sponsor.last_name",
    label: "Last name",
    nodeType: "field",
    parentFieldKey: "system.relationships.sponsor",
    dataType: "text",
    sortOrder: 40,
  },
  {
    fieldKey: "system.relationships.sponsor.company_name",
    label: "Company name",
    nodeType: "field",
    parentFieldKey: "system.relationships.sponsor",
    dataType: "text",
    sortOrder: 50,
  },
  {
    fieldKey: "system.relationships.sponsor.email",
    label: "E-mail",
    nodeType: "field",
    parentFieldKey: "system.relationships.sponsor",
    dataType: "email",
    sortOrder: 60,
  },
  {
    fieldKey: "system.relationships.sponsor.phone",
    label: "Telephone",
    nodeType: "field",
    parentFieldKey: "system.relationships.sponsor",
    dataType: "phone",
    sortOrder: 70,
  },
  {
    fieldKey: "system.workspace_metadata",
    label: "Workspace metadata",
    nodeType: "group",
    description: "Internal workspace identifiers linked to the client record.",
    sortOrder: 40,
  },
  {
    fieldKey: "system.workspace_metadata.id",
    label: "Client ID",
    nodeType: "field",
    parentFieldKey: "system.workspace_metadata",
    dataType: "text",
    sortOrder: 0,
  },
  {
    fieldKey: "system.workspace_metadata.law_firm_id",
    label: "Law firm ID",
    nodeType: "field",
    parentFieldKey: "system.workspace_metadata",
    dataType: "text",
    sortOrder: 10,
  },
  {
    fieldKey: "system.workspace_metadata.primary_office_id",
    label: "Primary office ID",
    nodeType: "field",
    parentFieldKey: "system.workspace_metadata",
    dataType: "text",
    sortOrder: 20,
  },
  {
    fieldKey: "system.workspace_metadata.created_by_user_id",
    label: "Created by user ID",
    nodeType: "field",
    parentFieldKey: "system.workspace_metadata",
    dataType: "text",
    sortOrder: 30,
  },
  {
    fieldKey: "system.audit",
    label: "Audit",
    nodeType: "group",
    description: "Audit timestamps for the client lifecycle.",
    sortOrder: 50,
  },
  {
    fieldKey: "system.audit.created_at",
    label: "Created at",
    nodeType: "field",
    parentFieldKey: "system.audit",
    dataType: "date",
    sortOrder: 0,
  },
  {
    fieldKey: "system.audit.updated_at",
    label: "Updated at",
    nodeType: "field",
    parentFieldKey: "system.audit",
    dataType: "date",
    sortOrder: 10,
  },
  {
    fieldKey: "system.audit.deleted_at",
    label: "Deleted at",
    nodeType: "field",
    parentFieldKey: "system.audit",
    dataType: "date",
    sortOrder: 20,
  },
];

function formatClientNumber(sequence: number) {
  return `CL-${String(sequence).padStart(6, "0")}`;
}

function formatClientName(client: {
  first_name: string;
  middle_name?: string | null;
  last_name: string;
}) {
  return [client.first_name, client.middle_name, client.last_name]
    .map((value) => value?.trim())
    .filter(Boolean)
    .join(" ");
}

function normalizeOptionalValue(value: string | undefined) {
  const normalized = value?.trim();
  return normalized ? normalized : null;
}

function sanitizeClientStructureSuggestionText(value: string | null | undefined, maxLength: number) {
  return String(value ?? "")
    .trim()
    .slice(0, maxLength);
}

function buildClientStructureNodeSuggestionSystemPrompt() {
  return [
    "You suggest the initial draft for a workspace client data structure node used by legal teams.",
    'Return JSON only with exactly these keys: {"description":"","aiInstructions":"","conditional":false,"conditionalPrompt":"","dataType":null,"required":false,"repeatable":false,"documentTypeCode":null}.',
    "The label is the main signal. Use the current node type and optional parent context only to avoid impossible suggestions.",
    "Detect the language used in the label and write the description, AI instructions, and conditional question entirely in that same language.",
    "The description must be detailed enough that a legal operations user clearly understands what is being requested, what should be included, and how it differs from similar fields or documents.",
    "Prefer a richer description over a vague one, but keep it readable inside a business application UI.",
    "AI instructions must be operational and specific, not generic or high-level.",
    "AI instructions must explain where downstream AI should look first, what evidence is acceptable, what to do when evidence conflicts or is missing, and any important validation or extraction edge cases.",
    "When useful, mention alternate source documents, synonyms, normalization expectations, date/name/number formatting concerns, and how to handle partial matches or ambiguity.",
    "For document nodes, be explicit about the exact pages, sections, identifiers, dates, names, or attributes the uploaded file should contain for validation to pass.",
    "Do not return shallow placeholders such as 'collect this information' or 'verify the document'. Write content that is immediately usable by a legal team without needing a rewrite.",
    "Only mark conditional true when the node usually applies to a subset of clients. When conditional is true, conditionalPrompt must be a short yes/no question.",
    'For field nodes, dataType must be one of: "text", "long_text", "number", "boolean", "date", "email", or "phone". For non-field nodes, use null.',
    "For group nodes, required must be false. Only mark repeatable true when the group naturally appears multiple times for the same client, such as dependents, employers, addresses, or prior entries.",
    "For document nodes, choose documentTypeCode only from the allowed document type codes that are provided and only when there is a strong match. Otherwise return null.",
    "Never invent client-specific facts, missing evidence, or case-specific details.",
    "Do not mention internal implementation details, prompts, or OpenAI.",
  ].join(" ");
}

async function suggestClientStructureNodeConfiguration(input: {
  lawFirmId: string;
  nodeType: "group" | "field" | "document";
  label: string;
  parent:
    | {
        label: string;
        description: string | null;
        fieldKey: string;
      }
    | null;
  documentTypes: ClientStructureDocumentTypeRecord[];
}) {
  const completion = await runJsonChatCompletion({
    lawFirmId: input.lawFirmId,
    maxCompletionTokens: 2200,
    systemPrompt: buildClientStructureNodeSuggestionSystemPrompt(),
    userPrompt: JSON.stringify(
      {
        node: {
          label: input.label,
          nodeType: input.nodeType,
        },
        parentGroup: input.parent,
        allowedDocumentTypes:
          input.nodeType === "document"
            ? input.documentTypes.map((documentType) => ({
                code: documentType.code,
                name: documentType.name,
                description: documentType.description,
              }))
            : [],
        outputRules: {
          description:
            input.nodeType === "document"
              ? "Write 2-4 sentences that clearly explain which exact document is being requested, what it should contain, and what would make a submission incomplete or incorrect."
              : input.nodeType === "group"
                ? "Write 2-4 sentences that explain the scope of this group, what kinds of child information belong inside it, and what should not be confused with it."
                : "Write 2-4 sentences that explain exactly what information should be captured, which details should be included, and how a user should distinguish it from adjacent fields.",
          aiInstructions:
            input.nodeType === "document"
              ? "Write detailed operational guidance for AI validation. Cover primary evidence sources, alternate acceptable documents, the exact identifiers or attributes that should be present, how to handle missing pages or blurry uploads, and what should cause the validation to stay uncertain instead of passing."
              : input.nodeType === "group"
                ? "Write detailed operational guidance for AI extraction across this group. Explain which sources usually contain these facts, how to reconcile conflicting evidence, what level of specificity is expected, and what to do when only part of the group can be supported."
                : "Write detailed operational guidance for AI extraction. Explain where this information is usually found first, alternate evidence sources, what counts as acceptable proof, how to normalize the result, and what to do when the evidence is conflicting, incomplete, or only indirectly supports the field.",
          conditionalPrompt:
            "Only provide this when conditional is true. It must be a short yes/no question.",
        },
      },
      null,
      2,
    ),
  });

  const parsed = clientStructureAiSuggestionResultSchema.parse(completion.json);
  const allowedDocumentTypeCodes = new Set(input.documentTypes.map((documentType) => documentType.code));
  const normalizedDocumentTypeCode = String(parsed.documentTypeCode ?? "")
    .trim();
  const normalizedConditionalPrompt = parsed.conditional
    ? sanitizeClientStructureSuggestionText(
        parsed.conditionalPrompt,
        CLIENT_STRUCTURE_CONDITIONAL_PROMPT_MAX_LENGTH,
      )
    : "";

  return {
    description: sanitizeClientStructureSuggestionText(
      parsed.description,
      CLIENT_STRUCTURE_DESCRIPTION_MAX_LENGTH,
    ),
    aiInstructions: sanitizeClientStructureSuggestionText(
      parsed.aiInstructions,
      CLIENT_STRUCTURE_AI_INSTRUCTIONS_MAX_LENGTH,
    ),
    conditional: Boolean(parsed.conditional && normalizedConditionalPrompt),
    conditionalPrompt: parsed.conditional ? normalizedConditionalPrompt : "",
    dataType: input.nodeType === "field" ? parsed.dataType ?? "text" : null,
    required: input.nodeType !== "group" ? Boolean(parsed.required) : false,
    repeatable: input.nodeType === "group" ? Boolean(parsed.repeatable) : false,
    documentTypeCode:
      input.nodeType === "document" &&
      normalizedDocumentTypeCode &&
      allowedDocumentTypeCodes.has(normalizedDocumentTypeCode)
        ? normalizedDocumentTypeCode
        : null,
    model: completion.model,
    usage: completion.usage,
  };
}

function normalizeClientStructureFieldKey(value: string) {
  return value
    .normalize("NFD")
    .replace(/[\u0300-\u036f]/g, "")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "_")
    .replace(/^_+|_+$/g, "")
    .replace(/_+/g, "_");
}

function isMissingClientDataStructureTableError(error: unknown) {
  if (!(error instanceof Error)) {
    return false;
  }

  const message = error.message.toLowerCase();
  return (
    message.includes("client_data_structure_nodes") &&
    (message.includes("doesn't exist") ||
      message.includes("does not exist") ||
      message.includes("unknown table"))
  );
}

function isMissingClientDataStructureRepeatableColumnError(error: unknown) {
  if (!(error instanceof Error)) {
    return false;
  }

  const message = error.message.toLowerCase();
  return (
    message.includes("is_repeatable") &&
    (message.includes("unknown column") ||
      message.includes("doesn't exist") ||
      message.includes("does not exist"))
  );
}

function isMissingClientDataStructureDocumentColumnError(error: unknown) {
  if (!(error instanceof Error)) {
    return false;
  }

  const message = error.message.toLowerCase();
  return (
    (message.includes("document_type_code") || message.includes("ai_instructions")) &&
    (message.includes("unknown column") ||
      message.includes("doesn't exist") ||
      message.includes("does not exist"))
  );
}

function isMissingClientDataStructureConditionalColumnError(error: unknown) {
  if (!(error instanceof Error)) {
    return false;
  }

  const message = error.message.toLowerCase();
  return (
    (message.includes("is_conditional") || message.includes("conditional_prompt")) &&
    (message.includes("unknown column") ||
      message.includes("doesn't exist") ||
      message.includes("does not exist"))
  );
}

function isMissingClientDocumentValidationColumnError(error: unknown) {
  if (!(error instanceof Error)) {
    return false;
  }

  const message = error.message.toLowerCase();
  return (
    (message.includes("client_structure_node_id") ||
      message.includes("validation_status") ||
      message.includes("validation_confidence") ||
      message.includes("validation_reason")) &&
    (message.includes("unknown column") ||
      message.includes("doesn't exist") ||
      message.includes("does not exist"))
  );
}

function replyClientDataStructureSchemaError(reply: FastifyReply, error: unknown) {
  if (isMissingClientDataStructureTableError(error)) {
    throw reply.internalServerError(
      "Client data structure table is missing. Run database/mysql/038_client_data_structure_nodes.sql, database/mysql/039_client_data_structure_repeatable.sql, database/mysql/040_client_structure_document_nodes.sql, and database/mysql/041_client_structure_conditional_nodes.sql.",
    );
  }

  if (isMissingClientDataStructureRepeatableColumnError(error)) {
    throw reply.internalServerError(
      "Client data structure schema is outdated. Run database/mysql/039_client_data_structure_repeatable.sql.",
    );
  }

  if (isMissingClientDataStructureDocumentColumnError(error) || isMissingClientDocumentValidationColumnError(error)) {
    throw reply.internalServerError(
      "Client data structure document schema is outdated. Run database/mysql/040_client_structure_document_nodes.sql.",
    );
  }

  if (isMissingClientDataStructureConditionalColumnError(error)) {
    throw reply.internalServerError(
      "Client data structure conditional schema is outdated. Run database/mysql/041_client_structure_conditional_nodes.sql.",
    );
  }
}

function serializeClient(client: ClientRecord, sponsor: ClientSponsorSummary | null = null) {
  return {
    id: client.id,
    clientNumber: client.client_number,
    firstName: client.first_name,
    middleName: client.middle_name,
    lastName: client.last_name,
    name: formatClientName(client),
    email: client.email,
    phone: client.phone,
    preferredLanguage: client.preferred_language,
    createdAt: client.created_at,
    sponsor,
  };
}

function serializeClientStructureDocumentType(documentType: ClientStructureDocumentTypeRecord) {
  return {
    code: documentType.code,
    name: documentType.name,
    description: documentType.description,
  };
}

function serializeClientStructureNode(node: ClientStructureNodeRecord) {
  const isSystemNode = !node.created_by_user_id;

  return {
    id: node.id,
    lawFirmId: node.law_firm_id,
    parentId: node.parent_id,
    nodeType:
      node.node_type === "group" ? "group" : node.node_type === "document" ? "document" : "field",
    fieldKey: node.field_key,
    label: node.label,
    description: node.description,
    aiInstructions: node.ai_instructions,
    dataType: node.data_type,
    documentTypeCode: node.document_type_code,
    required: Boolean(node.is_required),
    repeatable: Boolean(node.is_repeatable),
    conditional: Boolean(node.is_conditional),
    conditionalPrompt: node.conditional_prompt,
    sortOrder: Number(node.sort_order),
    createdByUserId: node.created_by_user_id,
    createdAt: node.created_at,
    updatedAt: node.updated_at,
    source: isSystemNode ? "system" : "custom",
    locked: isSystemNode,
  };
}

function serializeClientDetailDocumentMatch(match: ClientDetailDocumentMatchRecord) {
  return {
    id: match.id,
    nodeId: match.client_structure_node_id,
    documentTypeCode: match.document_type_code,
    title: match.title,
    validationStatus: match.validation_status,
    validationConfidence:
      match.validation_confidence == null ? null : Number(match.validation_confidence),
    validationReason: match.validation_reason,
    createdAt: match.created_at,
  };
}

function serializeClientDetailStructureValue(value: ClientDetailStructureValueRecord) {
  return {
    id: value.id,
    nodeId: value.client_structure_node_id,
    valueText: value.value_text,
    sourceType: value.source_type,
    sourceId: value.source_id,
    confidenceScore:
      value.confidence_score == null ? null : Number(value.confidence_score),
    updatedAt: value.updated_at,
  };
}

function serializeClientDetail(
  client: ClientDetailRecord,
  structureNodes: ClientStructureNodeRecord[],
  documentMatches: ClientDetailDocumentMatchRecord[],
  structureValues: ClientDetailStructureValueRecord[],
  sponsor: ClientSponsorSummary | null = null,
) {
  return {
    id: client.id,
    lawFirmId: client.law_firm_id,
    primaryOfficeId: client.primary_office_id,
    clientNumber: client.client_number,
    firstName: client.first_name,
    middleName: client.middle_name,
    lastName: client.last_name,
    preferredName: client.preferred_name,
    name: formatClientName(client),
    dateOfBirth: client.date_of_birth,
    gender: client.gender,
    email: client.email,
    phone: client.phone,
    preferredLanguage: client.preferred_language,
    countryOfBirth: client.country_of_birth,
    countryOfCitizenship: client.country_of_citizenship,
    immigrationStatus: client.immigration_status,
    intakeChannelCode: client.intake_channel_code,
    createdByUserId: client.created_by_user_id,
    createdAt: client.created_at,
    updatedAt: client.updated_at,
    deletedAt: client.deleted_at,
    sponsor,
    structureNodes: structureNodes.map((node) => serializeClientStructureNode(node)),
    documentMatches: documentMatches.map((match) => serializeClientDetailDocumentMatch(match)),
    structureValues: structureValues.map((value) => serializeClientDetailStructureValue(value)),
  };
}

async function serializeClientsWithSponsors(
  lawFirmId: string,
  clients: ClientRecord[],
): Promise<SerializedClient[]> {
  const sponsorMap = await listClientSponsorsByClientIds({
    lawFirmId,
    clientIds: clients.map((client) => client.id),
  });

  return clients.map((client) => serializeClient(client, sponsorMap.get(client.id) ?? null));
}

async function listClientStructureNodes(lawFirmId: string) {
  return prisma.$queryRaw<ClientStructureNodeRecord[]>`
    SELECT
      id,
      law_firm_id,
      parent_id,
      node_type,
      field_key,
      label,
      description,
      ai_instructions,
      data_type,
      document_type_code,
      is_required,
      is_repeatable,
      is_conditional,
      conditional_prompt,
      sort_order,
      created_by_user_id,
      created_at,
      updated_at
    FROM client_data_structure_nodes
    WHERE law_firm_id = ${lawFirmId}
    ORDER BY COALESCE(parent_id, ''), sort_order ASC, label ASC
  `;
}

async function listClientStructureDocumentTypes() {
  await ensureDefaultClientStructureDocumentTypes();

  return prisma.$queryRaw<ClientStructureDocumentTypeRecord[]>`
    SELECT code, name, description
    FROM document_types
    ORDER BY name ASC, code ASC
  `;
}

async function resolveClientStructureDocumentTypeCode(documentTypeCode: string) {
  await ensureDefaultClientStructureDocumentTypes();

  const normalizedCode = documentTypeCode.trim();

  if (!normalizedCode) {
    return null;
  }

  const [row] = await prisma.$queryRaw<Array<{ code: string }>>`
    SELECT code
    FROM document_types
    WHERE code = ${normalizedCode}
    LIMIT 1
  `;

  return row?.code ?? null;
}

async function ensureDefaultClientStructureDocumentTypes() {
  for (const documentType of defaultClientStructureDocumentTypeSeeds) {
    await prisma.$executeRaw`
      INSERT IGNORE INTO document_types (
        code,
        name,
        description,
        category_code,
        is_identity_document,
        is_expirable
      ) VALUES (
        ${documentType.code},
        ${documentType.name},
        ${documentType.description},
        ${documentType.categoryCode},
        ${documentType.isIdentityDocument},
        ${documentType.isExpirable}
      )
    `;
  }
}

async function buildUniqueClientStructureDocumentTypeCode(baseName: string) {
  const normalizedBaseCode =
    normalizeClientStructureFieldKey(baseName).slice(0, 50) || "custom_document";
  const rows = await prisma.$queryRaw<Array<{ code: string }>>`
    SELECT code
    FROM document_types
  `;
  const existingCodes = new Set(rows.map((row) => row.code.toLowerCase()));

  if (!existingCodes.has(normalizedBaseCode.toLowerCase())) {
    return normalizedBaseCode;
  }

  let sequence = 2;

  while (true) {
    const suffix = `_${sequence}`;
    const candidateCode = `${normalizedBaseCode.slice(0, Math.max(1, 50 - suffix.length))}${suffix}`;

    if (!existingCodes.has(candidateCode.toLowerCase())) {
      return candidateCode;
    }

    sequence += 1;
  }
}

async function listClientDetailDocumentMatches(lawFirmId: string, clientId: string) {
  return prisma.$queryRaw<ClientDetailDocumentMatchRecord[]>`
    SELECT
      id,
      client_structure_node_id,
      document_type_code,
      title,
      validation_status,
      validation_confidence,
      validation_reason,
      created_at
    FROM document_records
    WHERE law_firm_id = ${lawFirmId}
      AND client_id = ${clientId}
      AND client_structure_node_id IS NOT NULL
    ORDER BY created_at DESC, title ASC
  `;
}

async function ensureDefaultClientStructureNodes(lawFirmId: string) {
  const existingNodes = await listClientStructureNodes(lawFirmId);
  const existingByFieldKey = new Map(
    existingNodes.map((node) => [node.field_key, node] as const),
  );
  let insertedAny = false;

  for (const seedNode of defaultClientStructureNodeSeeds) {
    if (existingByFieldKey.has(seedNode.fieldKey)) {
      continue;
    }

    const parentId = seedNode.parentFieldKey
      ? existingByFieldKey.get(seedNode.parentFieldKey)?.id ?? null
      : null;

    if (seedNode.parentFieldKey && !parentId) {
      throw new Error(
        `Client structure parent "${seedNode.parentFieldKey}" was not found while seeding "${seedNode.fieldKey}".`,
      );
    }

    const now = new Date();
    const nodeRecord: ClientStructureNodeRecord = {
      id: createId(),
      law_firm_id: lawFirmId,
      parent_id: parentId,
      node_type: seedNode.nodeType,
      field_key: seedNode.fieldKey,
      label: seedNode.label,
      description: seedNode.description ?? null,
      ai_instructions: seedNode.aiInstructions ?? null,
      data_type: seedNode.nodeType === "field" ? seedNode.dataType ?? null : null,
      document_type_code:
        seedNode.nodeType === "document" ? seedNode.documentTypeCode ?? null : null,
      is_required: seedNode.nodeType !== "group" && seedNode.required ? 1 : 0,
      is_repeatable: seedNode.nodeType === "group" && seedNode.repeatable ? 1 : 0,
      is_conditional: seedNode.conditional ? 1 : 0,
      conditional_prompt: seedNode.conditional ? seedNode.conditionalPrompt ?? null : null,
      sort_order: seedNode.sortOrder,
      created_by_user_id: null,
      created_at: now,
      updated_at: now,
    };

    await prisma.$executeRaw`
      INSERT INTO client_data_structure_nodes (
        id,
        law_firm_id,
        parent_id,
        node_type,
        field_key,
        label,
        description,
        ai_instructions,
        data_type,
        document_type_code,
        is_required,
        is_repeatable,
        is_conditional,
        conditional_prompt,
        sort_order,
        created_by_user_id
      ) VALUES (
        ${nodeRecord.id},
        ${nodeRecord.law_firm_id},
        ${nodeRecord.parent_id},
        ${nodeRecord.node_type},
        ${nodeRecord.field_key},
        ${nodeRecord.label},
        ${nodeRecord.description},
        ${nodeRecord.ai_instructions},
        ${nodeRecord.data_type},
        ${nodeRecord.document_type_code},
        ${nodeRecord.is_required},
        ${nodeRecord.is_repeatable},
        ${nodeRecord.is_conditional},
        ${nodeRecord.conditional_prompt},
        ${nodeRecord.sort_order},
        ${nodeRecord.created_by_user_id}
      )
    `;

    existingByFieldKey.set(nodeRecord.field_key, nodeRecord);
    insertedAny = true;
  }

  return insertedAny ? listClientStructureNodes(lawFirmId) : existingNodes;
}

async function getClientStructureNode(lawFirmId: string, nodeId: string) {
  const [node] = await prisma.$queryRaw<ClientStructureNodeRecord[]>`
    SELECT
      id,
      law_firm_id,
      parent_id,
      node_type,
      field_key,
      label,
      description,
      ai_instructions,
      data_type,
      document_type_code,
      is_required,
      is_repeatable,
      is_conditional,
      conditional_prompt,
      sort_order,
      created_by_user_id,
      created_at,
      updated_at
    FROM client_data_structure_nodes
    WHERE law_firm_id = ${lawFirmId}
      AND id = ${nodeId}
    LIMIT 1
  `;

  return node ?? null;
}

async function buildUniqueClientStructureFieldKey(
  lawFirmId: string,
  baseKey: string,
  excludeNodeId: string | null = null,
) {
  const normalizedBaseKey = normalizeClientStructureFieldKey(baseKey) || "field";
  const nodes = await prisma.$queryRaw<Array<{ id: string; field_key: string }>>`
    SELECT id, field_key
    FROM client_data_structure_nodes
    WHERE law_firm_id = ${lawFirmId}
  `;
  const existingKeys = new Set(
    nodes
      .filter((node) => node.id !== excludeNodeId)
      .map((node) => node.field_key.toLowerCase()),
  );

  if (!existingKeys.has(normalizedBaseKey.toLowerCase())) {
    return normalizedBaseKey;
  }

  let sequence = 2;
  while (existingKeys.has(`${normalizedBaseKey}_${sequence}`.toLowerCase())) {
    sequence += 1;
  }

  return `${normalizedBaseKey}_${sequence}`;
}

async function nextClientStructureSortOrder(lawFirmId: string, parentId: string | null) {
  const [row] = await prisma.$queryRaw<Array<{ next_sort_order: number }>>`
    SELECT COALESCE(MAX(sort_order), 0) + 1 AS next_sort_order
    FROM client_data_structure_nodes
    WHERE law_firm_id = ${lawFirmId}
      AND parent_id <=> ${parentId}
  `;

  return Number(row?.next_sort_order ?? 1);
}

async function currentClientCount(lawFirmId: string) {
  const count = await prisma.client.count({
    where: { law_firm_id: lawFirmId },
  });

  return count;
}

async function createClients(
  lawFirmId: string,
  officeId: string | null,
  userId: string,
  clients: ClientInput[],
) {
  const baseCount = await currentClientCount(lawFirmId);

  return prisma.$transaction(
    clients.map((client, index) =>
      prisma.client.create({
        data: {
          id: createId(),
          law_firm_id: lawFirmId,
          primary_office_id: officeId,
          client_number: formatClientNumber(baseCount + index + 1),
          first_name: client.firstName.trim(),
          middle_name: normalizeOptionalValue(client.middleName),
          last_name: client.lastName.trim(),
          email: normalizeOptionalValue(client.email),
          phone: normalizeOptionalValue(client.phone),
          preferred_language: normalizeOptionalValue(client.preferredLanguage),
          created_by_user_id: userId,
        },
      }),
    ),
  );
}

export async function registerClientRoutes(app: FastifyInstance) {
  app.get("/", async (request, reply) => {
    const session = await requireSession(request, reply);
    const profile = await getSessionProfile(session);
    const query = clientListQuerySchema.parse(request.query);

    if (!profile) {
      throw reply.unauthorized("Session is no longer valid");
    }

    const terms = (query.q ?? "")
      .trim()
      .split(/\s+/)
      .filter(Boolean)
      .slice(0, 5);

    const clients = await prisma.client.findMany({
      where: {
        law_firm_id: profile.lawFirm.id,
        deleted_at: null,
        ...(terms.length > 0
          ? {
              AND: terms.map((term) => ({
                OR: [
                  {
                    first_name: {
                      contains: term,
                    },
                  },
                  {
                    last_name: {
                      contains: term,
                    },
                  },
                  {
                    middle_name: {
                      contains: term,
                    },
                  },
                  {
                    email: {
                      contains: term,
                    },
                  },
                  {
                    phone: {
                      contains: term,
                    },
                  },
                  {
                    client_number: {
                      contains: term,
                    },
                  },
                ],
              })),
            }
          : {}),
      },
      orderBy: {
        created_at: "desc",
      },
      ...(query.limit ? { take: query.limit } : {}),
    });

    return serializeClientsWithSponsors(profile.lawFirm.id, clients);
  });

  app.get("/structure", async (request, reply) => {
    const session = await requireSession(request, reply);
    const profile = await getSessionProfile(session);

    if (!profile) {
      throw reply.unauthorized("Session is no longer valid");
    }

    try {
      const nodes = await ensureDefaultClientStructureNodes(profile.lawFirm.id);
      return nodes.map((node) => serializeClientStructureNode(node));
    } catch (error) {
      replyClientDataStructureSchemaError(reply, error);
      throw error;
    }
  });

  app.get("/structure/document-types", async (request, reply) => {
    const session = await requireSession(request, reply);
    const profile = await getSessionProfile(session);

    if (!profile) {
      throw reply.unauthorized("Session is no longer valid");
    }

    const documentTypes = await listClientStructureDocumentTypes();
    return documentTypes.map((item) => serializeClientStructureDocumentType(item));
  });

  app.post("/structure/document-types", async (request, reply) => {
    const session = await requireSession(request, reply);
    const profile = await getSessionProfile(session);

    if (!profile) {
      throw reply.unauthorized("Session is no longer valid");
    }

    const payload = clientStructureDocumentTypeCreateSchema.parse(request.body);
    await ensureDefaultClientStructureDocumentTypes();

    const documentTypeCode = await buildUniqueClientStructureDocumentTypeCode(payload.name);

    await prisma.$executeRaw`
      INSERT INTO document_types (
        code,
        name,
        description,
        category_code,
        is_identity_document,
        is_expirable
      ) VALUES (
        ${documentTypeCode},
        ${payload.name.trim()},
        ${normalizeOptionalValue(payload.description)},
        'supporting',
        0,
        0
      )
    `;

    const [createdDocumentType] = await prisma.$queryRaw<ClientStructureDocumentTypeRecord[]>`
      SELECT code, name, description
      FROM document_types
      WHERE code = ${documentTypeCode}
      LIMIT 1
    `;

    if (!createdDocumentType) {
      throw reply.internalServerError("Failed to load created document type");
    }

    const serializedDocumentType = serializeClientStructureDocumentType(createdDocumentType);

    await writeAuditLog({
      lawFirmId: profile.lawFirm.id,
      officeId: profile.user.primaryOfficeId ?? null,
      actorUserId: profile.user.id,
      entityType: "document_type",
      entityId: createdDocumentType.code,
      action: "client.structure.document_type.create",
      afterJson: serializedDocumentType,
      request,
    });

    return reply.code(201).send(serializedDocumentType);
  });

  app.post("/structure/ai-suggestion", async (request, reply) => {
    const session = await requireSession(request, reply);
    const profile = await getSessionProfile(session);

    if (!profile) {
      throw reply.unauthorized("Session is no longer valid");
    }

    try {
      await ensureDefaultClientStructureNodes(profile.lawFirm.id);
      const payload = clientStructureAiSuggestionSchema.parse(request.body);
      const parentId = payload.parentId ?? null;

      let parentNode: ClientStructureNodeRecord | null = null;

      if (parentId) {
        parentNode = await getClientStructureNode(profile.lawFirm.id, parentId);

        if (!parentNode) {
          throw reply.notFound("Parent structure node not found");
        }

        if (parentNode.node_type !== "group") {
          throw reply.badRequest("Only group nodes can receive children");
        }
      }

      const documentTypes =
        payload.nodeType === "document" ? await listClientStructureDocumentTypes() : [];

      let aiRunId: string | null = null;

      try {
        const aiRun = await createAiRun({
          lawFirmId: profile.lawFirm.id,
          runType: "client_structure_node_suggestion",
          status: "running",
        });
        aiRunId = aiRun.id;

        const suggestion = await suggestClientStructureNodeConfiguration({
          lawFirmId: profile.lawFirm.id,
          nodeType: payload.nodeType,
          label: payload.label.trim(),
          parent:
            parentNode ?
              {
                label: parentNode.label,
                description: parentNode.description,
                fieldKey: parentNode.field_key,
              } :
              null,
          documentTypes,
        });

        await finishAiRun({
          aiRunId: aiRun.id,
          status: "completed",
          inputTokens: suggestion.usage.inputTokens,
          outputTokens: suggestion.usage.outputTokens,
        });

        await writeAuditLog({
          lawFirmId: profile.lawFirm.id,
          officeId: profile.user.primaryOfficeId ?? null,
          actorUserId: profile.user.id,
          entityType: "client_structure_node",
          entityId: parentId ?? `draft:${payload.nodeType}`,
          action: "client.structure.ai_suggestion.generate",
          afterJson: {
            aiRunId: aiRun.id,
            model: suggestion.model,
            label: payload.label.trim(),
            nodeType: payload.nodeType,
            parentId,
            suggestion: {
              description: suggestion.description,
              aiInstructions: suggestion.aiInstructions,
              conditional: suggestion.conditional,
              conditionalPrompt: suggestion.conditionalPrompt,
              dataType: suggestion.dataType,
              required: suggestion.required,
              repeatable: suggestion.repeatable,
              documentTypeCode: suggestion.documentTypeCode,
            },
          },
          request,
        });

        return {
          aiRunId: aiRun.id,
          model: suggestion.model,
          description: suggestion.description,
          aiInstructions: suggestion.aiInstructions,
          conditional: suggestion.conditional,
          conditionalPrompt: suggestion.conditionalPrompt || null,
          dataType: suggestion.dataType,
          required: suggestion.required,
          repeatable: suggestion.repeatable,
          documentTypeCode: suggestion.documentTypeCode,
        };
      } catch (error) {
        if (aiRunId) {
          await finishAiRun({
            aiRunId,
            status: "failed",
            errorMessage:
              error instanceof Error ?
                error.message :
                "Failed to generate client structure AI suggestion.",
          });
        }

        throw reply.badRequest(
          error instanceof Error ?
            error.message :
            "Failed to generate client structure AI suggestion.",
        );
      }
    } catch (error) {
      replyClientDataStructureSchemaError(reply, error);
      throw error;
    }
  });

  app.post("/structure", async (request, reply) => {
    const session = await requireSession(request, reply);
    const profile = await getSessionProfile(session);

    if (!profile) {
      throw reply.unauthorized("Session is no longer valid");
    }

    try {
      await ensureDefaultClientStructureNodes(profile.lawFirm.id);
      const payload = clientStructureCreateSchema.parse(request.body);
      const parentId = payload.parentId ?? null;

      if (parentId) {
        const parent = await getClientStructureNode(profile.lawFirm.id, parentId);

        if (!parent) {
          throw reply.notFound("Parent structure node not found");
        }

        if (parent.node_type !== "group") {
          throw reply.badRequest("Only group nodes can receive children");
        }
      }

      const resolvedDocumentTypeCode =
        payload.nodeType === "document" ?
          await resolveClientStructureDocumentTypeCode(payload.documentTypeCode ?? "") :
          null;

      if (payload.nodeType === "document" && !resolvedDocumentTypeCode) {
        throw reply.badRequest("documentTypeCode is invalid");
      }

      const fieldKey = await buildUniqueClientStructureFieldKey(
        profile.lawFirm.id,
        payload.fieldKey || payload.label,
      );
      const sortOrder = await nextClientStructureSortOrder(profile.lawFirm.id, parentId);
      const nodeId = createId();

      await prisma.$executeRaw`
        INSERT INTO client_data_structure_nodes (
          id,
          law_firm_id,
          parent_id,
          node_type,
          field_key,
          label,
          description,
          ai_instructions,
          data_type,
          document_type_code,
          is_required,
          is_repeatable,
          is_conditional,
          conditional_prompt,
          sort_order,
          created_by_user_id
        ) VALUES (
          ${nodeId},
          ${profile.lawFirm.id},
          ${parentId},
          ${payload.nodeType},
          ${fieldKey},
          ${payload.label.trim()},
          ${normalizeOptionalValue(payload.description)},
          ${normalizeOptionalValue(payload.aiInstructions)},
          ${payload.nodeType === "field" ? payload.dataType ?? null : null},
          ${payload.nodeType === "document" ? resolvedDocumentTypeCode : null},
          ${payload.nodeType !== "group" && payload.required ? 1 : 0},
          ${payload.nodeType === "group" && payload.repeatable ? 1 : 0},
          ${payload.conditional ? 1 : 0},
          ${payload.conditional ? normalizeOptionalValue(payload.conditionalPrompt) : null},
          ${sortOrder},
          ${profile.user.id}
        )
      `;

      const createdNode = await getClientStructureNode(profile.lawFirm.id, nodeId);

      if (!createdNode) {
        throw reply.internalServerError("Failed to load created client structure node");
      }

      const serializedNode = serializeClientStructureNode(createdNode);

      await writeAuditLog({
        lawFirmId: profile.lawFirm.id,
        officeId: profile.user.primaryOfficeId ?? null,
        actorUserId: profile.user.id,
        entityType: "client_structure_node",
        entityId: createdNode.id,
        action: "client.structure.create",
        afterJson: serializedNode,
        request,
      });

      return reply.code(201).send(serializedNode);
    } catch (error) {
      replyClientDataStructureSchemaError(reply, error);
      throw error;
    }
  });

  app.patch("/structure/:nodeId", async (request, reply) => {
    const session = await requireSession(request, reply);
    const profile = await getSessionProfile(session);

    if (!profile) {
      throw reply.unauthorized("Session is no longer valid");
    }

    try {
      await ensureDefaultClientStructureNodes(profile.lawFirm.id);
      const { nodeId } = clientStructureNodeParamSchema.parse(request.params);
      const payload = clientStructureUpdateSchema.parse(request.body);
      const currentNode = await getClientStructureNode(profile.lawFirm.id, nodeId);

      if (!currentNode) {
        throw reply.notFound("Client structure node not found");
      }

      const beforeNode = serializeClientStructureNode(currentNode);

      if (!currentNode.created_by_user_id) {
        await prisma.$executeRaw`
          UPDATE client_data_structure_nodes
          SET
            ai_instructions = ${normalizeOptionalValue(payload.aiInstructions)},
            updated_at = CURRENT_TIMESTAMP
          WHERE id = ${currentNode.id}
        `;

        const updatedSystemNode = await getClientStructureNode(profile.lawFirm.id, currentNode.id);

        if (!updatedSystemNode) {
          throw reply.internalServerError("Failed to load updated client structure node");
        }

        const serializedSystemNode = serializeClientStructureNode(updatedSystemNode);

        await writeAuditLog({
          lawFirmId: profile.lawFirm.id,
          officeId: profile.user.primaryOfficeId ?? null,
          actorUserId: profile.user.id,
          entityType: "client_structure_node",
          entityId: updatedSystemNode.id,
          action: "client.structure.update",
          beforeJson: beforeNode,
          afterJson: serializedSystemNode,
          request,
        });

        return serializedSystemNode;
      }

      if (currentNode.node_type === "field" && !payload.dataType) {
        throw reply.badRequest("dataType is required when nodeType is field");
      }

      if (currentNode.node_type === "field" && payload.repeatable) {
        throw reply.badRequest("repeatable is only allowed when nodeType is group");
      }

      if (currentNode.node_type === "document" && !normalizeOptionalValue(payload.documentTypeCode)) {
        throw reply.badRequest("documentTypeCode is required when nodeType is document");
      }

      if (currentNode.node_type === "document" && payload.repeatable) {
        throw reply.badRequest("repeatable is only allowed when nodeType is group");
      }

      const resolvedDocumentTypeCode =
        currentNode.node_type === "document" ?
          await resolveClientStructureDocumentTypeCode(payload.documentTypeCode ?? "") :
          null;

      if (currentNode.node_type === "document" && !resolvedDocumentTypeCode) {
        throw reply.badRequest("documentTypeCode is invalid");
      }

      const fieldKey = await buildUniqueClientStructureFieldKey(
        profile.lawFirm.id,
        payload.fieldKey || currentNode.field_key,
        currentNode.id,
      );

      await prisma.$executeRaw`
        UPDATE client_data_structure_nodes
        SET
          field_key = ${fieldKey},
          label = ${payload.label.trim()},
          description = ${normalizeOptionalValue(payload.description)},
          ai_instructions = ${normalizeOptionalValue(payload.aiInstructions)},
          data_type = ${currentNode.node_type === "field" ? payload.dataType ?? null : null},
          document_type_code = ${currentNode.node_type === "document" ? resolvedDocumentTypeCode : null},
          is_required = ${currentNode.node_type !== "group" && payload.required ? 1 : 0},
          is_repeatable = ${currentNode.node_type === "group" && payload.repeatable ? 1 : 0},
          is_conditional = ${payload.conditional ? 1 : 0},
          conditional_prompt = ${payload.conditional ? normalizeOptionalValue(payload.conditionalPrompt) : null},
          updated_at = CURRENT_TIMESTAMP
        WHERE id = ${currentNode.id}
      `;

      const updatedNode = await getClientStructureNode(profile.lawFirm.id, currentNode.id);

      if (!updatedNode) {
        throw reply.internalServerError("Failed to load updated client structure node");
      }

      const serializedNode = serializeClientStructureNode(updatedNode);

      await writeAuditLog({
        lawFirmId: profile.lawFirm.id,
        officeId: profile.user.primaryOfficeId ?? null,
        actorUserId: profile.user.id,
        entityType: "client_structure_node",
        entityId: updatedNode.id,
        action: "client.structure.update",
        beforeJson: beforeNode,
        afterJson: serializedNode,
        request,
      });

      return serializedNode;
    } catch (error) {
      replyClientDataStructureSchemaError(reply, error);
      throw error;
    }
  });

  app.post("/structure/reorder", async (request, reply) => {
    const session = await requireSession(request, reply);
    const profile = await getSessionProfile(session);

    if (!profile) {
      throw reply.unauthorized("Session is no longer valid");
    }

    try {
      await ensureDefaultClientStructureNodes(profile.lawFirm.id);
      const payload = clientStructureReorderSchema.parse(request.body);

      if (payload.nodeId === payload.targetNodeId) {
        const nodes = await listClientStructureNodes(profile.lawFirm.id);
        return nodes.map((node) => serializeClientStructureNode(node));
      }

      const [currentNode, targetNode] = await Promise.all([
        getClientStructureNode(profile.lawFirm.id, payload.nodeId),
        getClientStructureNode(profile.lawFirm.id, payload.targetNodeId),
      ]);

      if (!currentNode || !targetNode) {
        throw reply.notFound("Client structure node not found");
      }

      if (currentNode.node_type !== "group" || targetNode.node_type !== "group") {
        throw reply.badRequest("Only group nodes can be reordered.");
      }

      if (currentNode.parent_id !== targetNode.parent_id) {
        throw reply.badRequest("Groups can only be reordered within the same parent.");
      }

      const beforeNode = serializeClientStructureNode(currentNode);

      await prisma.$transaction(async (tx) => {
        const siblings = await tx.$queryRaw<ClientStructureNodeRecord[]>`
          SELECT
            id,
            law_firm_id,
            parent_id,
            node_type,
            field_key,
            label,
            description,
            ai_instructions,
            data_type,
            document_type_code,
            is_required,
            is_repeatable,
            is_conditional,
            conditional_prompt,
            sort_order,
            created_by_user_id,
            created_at,
            updated_at
          FROM client_data_structure_nodes
          WHERE law_firm_id = ${profile.lawFirm.id}
            AND parent_id <=> ${currentNode.parent_id}
          ORDER BY sort_order ASC, label ASC
        `;

        const fromIndex = siblings.findIndex((node) => node.id === currentNode.id);
        const targetIndex = siblings.findIndex((node) => node.id === targetNode.id);

        if (fromIndex < 0 || targetIndex < 0) {
          throw reply.notFound("Client structure node not found");
        }

        const [movedNode] = siblings.splice(fromIndex, 1);
        let insertIndex = targetIndex + (payload.position === "after" ? 1 : 0);

        if (fromIndex < insertIndex) {
          insertIndex -= 1;
        }

        siblings.splice(insertIndex, 0, movedNode);

        await Promise.all(
          siblings.map((node, index) =>
            tx.$executeRaw`
              UPDATE client_data_structure_nodes
              SET
                sort_order = ${(index + 1) * 10},
                updated_at = CURRENT_TIMESTAMP
              WHERE id = ${node.id}
            `,
          ),
        );
      });

      const updatedNode = await getClientStructureNode(profile.lawFirm.id, currentNode.id);

      if (!updatedNode) {
        throw reply.internalServerError("Failed to load reordered client structure node");
      }

      await writeAuditLog({
        lawFirmId: profile.lawFirm.id,
        officeId: profile.user.primaryOfficeId ?? null,
        actorUserId: profile.user.id,
        entityType: "client_structure_node",
        entityId: updatedNode.id,
        action: "client.structure.reorder",
        beforeJson: beforeNode,
        afterJson: serializeClientStructureNode(updatedNode),
        request,
      });

      const nodes = await listClientStructureNodes(profile.lawFirm.id);
      return nodes.map((node) => serializeClientStructureNode(node));
    } catch (error) {
      replyClientDataStructureSchemaError(reply, error);
      throw error;
    }
  });

  app.delete("/structure/:nodeId", async (request, reply) => {
    const session = await requireSession(request, reply);
    const profile = await getSessionProfile(session);

    if (!profile) {
      throw reply.unauthorized("Session is no longer valid");
    }

    try {
      await ensureDefaultClientStructureNodes(profile.lawFirm.id);
      const { nodeId } = clientStructureNodeParamSchema.parse(request.params);
      const currentNode = await getClientStructureNode(profile.lawFirm.id, nodeId);

      if (!currentNode) {
        throw reply.notFound("Client structure node not found");
      }

      if (!currentNode.created_by_user_id) {
        throw reply.badRequest("System client structure nodes cannot be deleted.");
      }

      const beforeNode = serializeClientStructureNode(currentNode);

      await prisma.$executeRaw`
        DELETE FROM client_data_structure_nodes
        WHERE id = ${currentNode.id}
      `;

      await writeAuditLog({
        lawFirmId: profile.lawFirm.id,
        officeId: profile.user.primaryOfficeId ?? null,
        actorUserId: profile.user.id,
        entityType: "client_structure_node",
        entityId: currentNode.id,
        action: "client.structure.delete",
        beforeJson: beforeNode,
        request,
      });

      return reply.code(204).send();
    } catch (error) {
      replyClientDataStructureSchemaError(reply, error);
      throw error;
    }
  });

  app.get("/:clientId", async (request, reply) => {
    const session = await requireSession(request, reply);
    const profile = await getSessionProfile(session);

    if (!profile) {
      throw reply.unauthorized("Session is no longer valid");
    }

    const { clientId } = clientIdParamSchema.parse(request.params);
    const client = await prisma.client.findFirst({
      where: {
        id: clientId,
        law_firm_id: profile.lawFirm.id,
      },
    });

    if (!client) {
      throw reply.notFound("Client not found");
    }

    try {
      await ensureClientStructureValueSchema();
      const structureNodes = await ensureDefaultClientStructureNodes(profile.lawFirm.id);
      const [sponsorMap, documentMatches, structureValues] = await Promise.all([
        listClientSponsorsByClientIds({
          lawFirmId: profile.lawFirm.id,
          clientIds: [client.id],
        }),
        listClientDetailDocumentMatches(profile.lawFirm.id, client.id),
        listClientStructureFieldValues({
          lawFirmId: profile.lawFirm.id,
          clientId: client.id,
        }),
      ]);

      return serializeClientDetail(
        client,
        structureNodes,
        documentMatches,
        structureValues,
        sponsorMap.get(client.id) ?? null,
      );
    } catch (error) {
      replyClientDataStructureSchemaError(reply, error);
      throw error;
    }
  });

  app.post("/", async (request, reply) => {
    const session = await requireSession(request, reply);
    const profile = await getSessionProfile(session);

    if (!profile) {
      throw reply.unauthorized("Session is no longer valid");
    }

    const payload = clientCreateSchema.parse(request.body);
    const [client] = await createClients(
      profile.lawFirm.id,
      profile.user.primaryOfficeId ?? null,
      profile.user.id,
      [payload],
    );

    await writeAuditLog({
      lawFirmId: profile.lawFirm.id,
      officeId: profile.user.primaryOfficeId ?? null,
      actorUserId: profile.user.id,
      entityType: "client",
      entityId: client.id,
      action: "client.create",
      afterJson: {
        clientNumber: client.client_number,
        firstName: client.first_name,
        middleName: client.middle_name,
        lastName: client.last_name,
        email: client.email,
      },
      request,
    });

    return reply.code(201).send(serializeClient(client));
  });

  app.post("/import", async (request, reply) => {
    const session = await requireSession(request, reply);
    const profile = await getSessionProfile(session);

    if (!profile) {
      throw reply.unauthorized("Session is no longer valid");
    }

    const payload = clientImportSchema.parse(request.body);
    const clients = await createClients(
      profile.lawFirm.id,
      profile.user.primaryOfficeId ?? null,
      profile.user.id,
      payload.clients,
    );

    await writeAuditLog({
      lawFirmId: profile.lawFirm.id,
      officeId: profile.user.primaryOfficeId ?? null,
      actorUserId: profile.user.id,
      entityType: "client_import",
      entityId: null,
      action: "client.import",
      afterJson: {
        importedCount: clients.length,
        clientIds: clients.map((client) => client.id),
      },
      request,
    });

    return reply.code(201).send({
      importedCount: clients.length,
      clients: clients.map((client) => serializeClient(client)),
    });
  });

  app.patch("/:clientId/sponsor", async (request, reply) => {
    const session = await requireSession(request, reply);
    const profile = await getSessionProfile(session);

    if (!profile) {
      throw reply.unauthorized("Session is no longer valid");
    }

    const { clientId } = clientIdParamSchema.parse(request.params);
    const payload = clientSponsorUpdateSchema.parse(request.body);

    const client = await prisma.client.findFirst({
      where: {
        id: clientId,
        law_firm_id: profile.lawFirm.id,
        deleted_at: null,
      },
    });

    if (!client) {
      throw reply.notFound("Client not found");
    }

    const existingSponsors = await prisma.relatedParty.findMany({
      where: {
        law_firm_id: profile.lawFirm.id,
        client_id: clientId,
        relation_type: "sponsor",
        deleted_at: null,
      },
      orderBy: [{ updated_at: "desc" }, { created_at: "desc" }],
    });

    const primarySponsor = existingSponsors[0] ?? null;
    const duplicateSponsorIds = existingSponsors.slice(1).map((item) => item.id);
    const now = new Date();

    if (payload.clear) {
      if (existingSponsors.length > 0) {
        await prisma.relatedParty.updateMany({
          where: {
            id: {
              in: existingSponsors.map((item) => item.id),
            },
          },
          data: {
            deleted_at: now,
          },
        });
      }

      await writeAuditLog({
        lawFirmId: profile.lawFirm.id,
        officeId: profile.user.primaryOfficeId ?? null,
        actorUserId: profile.user.id,
        entityType: "client_sponsor",
        entityId: clientId,
        action: "client.sponsor.clear",
        request,
      });

      return serializeClient(client, null);
    }

    const sponsorData = {
      entity_type: payload.entityType,
      first_name: normalizeOptionalValue(payload.firstName),
      last_name: normalizeOptionalValue(payload.lastName),
      company_name: normalizeOptionalValue(payload.companyName),
      email: normalizeOptionalValue(payload.email),
      phone: normalizeOptionalValue(payload.phone),
    };

    const sponsor =
      primarySponsor ?
        await prisma.relatedParty.update({
          where: {
            id: primarySponsor.id,
          },
          data: {
            ...sponsorData,
            deleted_at: null,
          },
        }) :
        await prisma.relatedParty.create({
          data: {
            id: createId(),
            law_firm_id: profile.lawFirm.id,
            client_id: clientId,
            case_id: null,
            relation_type: "sponsor",
            entity_type: sponsorData.entity_type,
            first_name: sponsorData.first_name,
            last_name: sponsorData.last_name,
            company_name: sponsorData.company_name,
            email: sponsorData.email,
            phone: sponsorData.phone,
          },
        });

    if (duplicateSponsorIds.length > 0) {
      await prisma.relatedParty.updateMany({
        where: {
          id: {
            in: duplicateSponsorIds,
          },
        },
        data: {
          deleted_at: now,
        },
      });
    }

    const serializedSponsor: ClientSponsorSummary = {
      id: sponsor.id,
      entityType: sponsor.entity_type === "company" ? "company" : "person",
      name:
        sponsor.entity_type === "company" ?
          sponsor.company_name?.trim() || sponsor.preferred_name?.trim() || "Unnamed sponsor" :
          [sponsor.first_name?.trim(), sponsor.last_name?.trim()].filter(Boolean).join(" ") ||
          sponsor.preferred_name?.trim() ||
          sponsor.company_name?.trim() ||
          "Unnamed sponsor",
      firstName: sponsor.first_name,
      lastName: sponsor.last_name,
      companyName: sponsor.company_name,
      email: sponsor.email,
      phone: sponsor.phone,
    };

    await writeAuditLog({
      lawFirmId: profile.lawFirm.id,
      officeId: profile.user.primaryOfficeId ?? null,
      actorUserId: profile.user.id,
      entityType: "client_sponsor",
      entityId: sponsor.id,
      action: primarySponsor ? "client.sponsor.update" : "client.sponsor.create",
      afterJson: {
        clientId,
        entityType: serializedSponsor.entityType,
        name: serializedSponsor.name,
        email: serializedSponsor.email,
        phone: serializedSponsor.phone,
      },
      request,
    });

    return serializeClient(client, serializedSponsor);
  });
}
