import { HttpError, type RequestContext } from '../../core/http.js';
import type pg from 'pg';
import { intParam, optionalString, readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { requireTenantContentEditor } from './auth.js';
import { boolValue, intValue, jsonArrayValue, jsonObjectValue, nullableString, optionalStatus } from './utils.js';

const QUESTION_STATUSES = ['draft', 'published', 'archived'];

async function assertOptionalReference(
  client: { query: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }> },
  tenantId: string,
  table: string,
  id: string | null,
  code: string,
) {
  if (!id) return;
  const row = await client.query(`select id from public.${table} where tenant_id = $1 and id = $2 limit 1`, [tenantId, id]);
  if (!row.rows[0]) {
    throw new HttpError(400, `${table} reference is not in this tenant`, code);
  }
}

async function resolveOptionalContentNavigation(
  client: pg.PoolClient,
  tenantId: string,
  entryId: string | null,
  contentNodeId: string | null,
  expectedEntryType: 'vocabulary' | 'handbook',
) {
  let resolvedEntryId = entryId;

  if (resolvedEntryId) {
    const entry = await client.query<{ id: string; entry_type: string }>(
      'select id, entry_type from public.content_entries where tenant_id = $1 and id = $2 limit 1',
      [tenantId, resolvedEntryId],
    );
    if (!entry.rows[0]) throw new HttpError(400, 'content entry reference is not in this tenant', 'ENTRY_NOT_FOUND');
    if (entry.rows[0].entry_type !== expectedEntryType) {
      throw new HttpError(400, `content entry must be ${expectedEntryType}`, 'ENTRY_TYPE_MISMATCH');
    }
  }

  if (contentNodeId) {
    const node = await client.query<{ id: string; entry_id: string }>(
      'select id, entry_id from public.content_nodes where tenant_id = $1 and id = $2 limit 1',
      [tenantId, contentNodeId],
    );
    if (!node.rows[0]) throw new HttpError(400, 'content node reference is not in this tenant', 'CONTENT_NODE_NOT_FOUND');
    if (resolvedEntryId && node.rows[0].entry_id !== resolvedEntryId) {
      throw new HttpError(400, 'content node is not under the selected entry', 'CONTENT_NODE_ENTRY_MISMATCH');
    }
    resolvedEntryId = resolvedEntryId || node.rows[0].entry_id;
  }

  return { entryId: resolvedEntryId, contentNodeId };
}

async function syncPrimaryCollectionItem(
  client: { query: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }> },
  tenantId: string,
  collectionId: string | null,
  questionId: string,
) {
  if (!collectionId) return;
  await client.query(
    `
    insert into public.question_collection_items (tenant_id, collection_id, question_id, sort_order)
    values (
      $1, $2, $3,
      coalesce((select max(sort_order) + 1 from public.question_collection_items where tenant_id = $1 and collection_id = $2), 0)
    )
    on conflict (tenant_id, collection_id, question_id) do nothing
    `,
    [tenantId, collectionId, questionId],
  );
  await client.query(
    `
    update public.question_collections
    set question_count = (
          select count(*)
          from public.question_collection_items
          where tenant_id = $1 and collection_id = $2
        ),
        updated_at = now()
    where tenant_id = $1 and id = $2
    `,
    [tenantId, collectionId],
  );
}

export async function createQuestionRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const body = await readJsonBody(ctx);

  const item = await transaction(async client => {
    const questionBankId = nullableString(body.questionBankId);
    const subjectId = nullableString(body.subjectId);
    const categoryId = nullableString(body.categoryId);
    const nodeId = nullableString(body.nodeId);
    const entryId = nullableString(body.entryId);
    const contentNodeId = nullableString(body.contentNodeId);
    const primaryCollectionId = nullableString(body.primaryCollectionId) || nullableString(body.collectionId);

    await assertOptionalReference(client, auth.tenantId, 'question_banks', questionBankId, 'QUESTION_BANK_NOT_FOUND');
    await assertOptionalReference(client, auth.tenantId, 'subjects', subjectId, 'SUBJECT_NOT_FOUND');
    await assertOptionalReference(client, auth.tenantId, 'categories', categoryId, 'CATEGORY_NOT_FOUND');
    await assertOptionalReference(client, auth.tenantId, 'module_nodes', nodeId, 'NODE_NOT_FOUND');
    await assertOptionalReference(client, auth.tenantId, 'content_entries', entryId, 'ENTRY_NOT_FOUND');
    await assertOptionalReference(client, auth.tenantId, 'content_nodes', contentNodeId, 'CONTENT_NODE_NOT_FOUND');
    await assertOptionalReference(client, auth.tenantId, 'question_collections', primaryCollectionId, 'QUESTION_COLLECTION_NOT_FOUND');

    const questionResult = await client.query(
      `
      insert into public.questions (
        tenant_id, question_bank_id, subject_id, category_id, node_id,
        entry_id, content_node_id, primary_collection_id, legacy_id, type,
        type_label, difficulty, tags, media_url, status, exam_markers
      )
      values (
        $1, $2::uuid, $3::uuid, $4::uuid, $5::uuid,
        $6::uuid, $7::uuid, $8::uuid, $9, $10,
        $11, $12, $13::jsonb, $14, $15, $16::jsonb
      )
      returning id, tenant_id as "tenantId", question_bank_id as "questionBankId",
                subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
                entry_id as "entryId", content_node_id as "contentNodeId",
                primary_collection_id as "primaryCollectionId", type,
                type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
                status, exam_markers as "examMarkers", created_at as "createdAt",
                updated_at as "updatedAt"
      `,
      [
        auth.tenantId,
        questionBankId,
        subjectId,
        categoryId,
        nodeId,
        entryId,
        contentNodeId,
        primaryCollectionId,
        nullableString(body.legacyId),
        optionalString(body, 'type') || 'choice',
        optionalString(body, 'typeLabel') || null,
        intValue(body.difficulty, 1),
        jsonArrayValue(body.tags),
        nullableString(body.mediaUrl),
        optionalStatus(body.status, QUESTION_STATUSES, 'published'),
        jsonObjectValue(body.examMarkers),
      ],
    );
    const question = questionResult.rows[0];
    await syncPrimaryCollectionItem(client, auth.tenantId, primaryCollectionId, question.id);

    const versionResult = await client.query(
      `
      insert into public.question_versions (
        tenant_id, question_id, version_no, content, options,
        correct_option_index, correct_option_indices, answer_text,
        explanation, sub_questions, code_lang, code_template, source_hash, created_by
      )
      values ($1, $2, 1, $3, $4::jsonb, $5, $6::jsonb, $7, $8, $9::jsonb, $10, $11, $12, $13)
      returning id, version_no as "versionNo", content, options,
                correct_option_index as "correctOptionIndex",
                correct_option_indices as "correctOptionIndices",
                answer_text as "answerText", explanation, sub_questions as "subQuestions",
                code_lang as "codeLang", code_template as "codeTemplate", created_at as "createdAt"
      `,
      [
        auth.tenantId,
        question.id,
        nullableString(body.content),
        jsonArrayValue(body.options),
        body.correctOptionIndex === undefined || body.correctOptionIndex === null ? null : intValue(body.correctOptionIndex, 0),
        jsonArrayValue(body.correctOptionIndices),
        nullableString(body.answerText),
        nullableString(body.explanation),
        jsonArrayValue(body.subQuestions),
        nullableString(body.codeLang),
        nullableString(body.codeTemplate),
        nullableString(body.sourceHash),
        auth.userId,
      ],
    );

    await client.query(
      `
      update public.questions
      set current_version_id = $3, updated_at = now()
      where tenant_id = $1 and id = $2
      `,
      [auth.tenantId, question.id, versionResult.rows[0].id],
    );

    return { ...question, currentVersion: versionResult.rows[0] };
  });

  return { item };
}

export async function updateQuestionRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const body = await readJsonBody(ctx);
  const questionId = requiredString(body, 'questionId');
  const createVersion = body.createVersion === true;

  const item = await transaction(async client => {
    const questionBankId = nullableString(body.questionBankId);
    const subjectId = nullableString(body.subjectId);
    const categoryId = nullableString(body.categoryId);
    const nodeId = nullableString(body.nodeId);
    const entryId = nullableString(body.entryId);
    const contentNodeId = nullableString(body.contentNodeId);
    const primaryCollectionId = nullableString(body.primaryCollectionId) || nullableString(body.collectionId);

    await assertOptionalReference(client, auth.tenantId, 'question_banks', questionBankId, 'QUESTION_BANK_NOT_FOUND');
    await assertOptionalReference(client, auth.tenantId, 'subjects', subjectId, 'SUBJECT_NOT_FOUND');
    await assertOptionalReference(client, auth.tenantId, 'categories', categoryId, 'CATEGORY_NOT_FOUND');
    await assertOptionalReference(client, auth.tenantId, 'module_nodes', nodeId, 'NODE_NOT_FOUND');
    await assertOptionalReference(client, auth.tenantId, 'content_entries', entryId, 'ENTRY_NOT_FOUND');
    await assertOptionalReference(client, auth.tenantId, 'content_nodes', contentNodeId, 'CONTENT_NODE_NOT_FOUND');
    await assertOptionalReference(client, auth.tenantId, 'question_collections', primaryCollectionId, 'QUESTION_COLLECTION_NOT_FOUND');

    const questionResult = await client.query(
      `
      update public.questions
      set question_bank_id = coalesce($3::uuid, question_bank_id),
          subject_id = coalesce($4::uuid, subject_id),
          category_id = coalesce($5::uuid, category_id),
          node_id = coalesce($6::uuid, node_id),
          entry_id = coalesce($7::uuid, entry_id),
          content_node_id = coalesce($8::uuid, content_node_id),
          primary_collection_id = coalesce($9::uuid, primary_collection_id),
          type = coalesce(nullif($10, ''), type),
          type_label = coalesce(nullif($11, ''), type_label),
          difficulty = coalesce($12, difficulty),
          tags = case when $13::boolean then $14::jsonb else tags end,
          media_url = coalesce(nullif($15, ''), media_url),
          status = coalesce(nullif($16, ''), status),
          exam_markers = case when $17::boolean then $18::jsonb else exam_markers end,
          updated_at = now()
      where tenant_id = $1 and id = $2
      returning id, tenant_id as "tenantId", question_bank_id as "questionBankId",
                subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
                entry_id as "entryId", content_node_id as "contentNodeId",
                primary_collection_id as "primaryCollectionId", type,
                type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
                status, exam_markers as "examMarkers",
                current_version_id as "currentVersionId", updated_at as "updatedAt"
      `,
      [
        auth.tenantId,
        questionId,
        questionBankId,
        subjectId,
        categoryId,
        nodeId,
        entryId,
        contentNodeId,
        primaryCollectionId,
        optionalString(body, 'type'),
        optionalString(body, 'typeLabel'),
        body.difficulty === undefined ? null : intValue(body.difficulty, 1),
        Object.hasOwn(body, 'tags'),
        jsonArrayValue(body.tags),
        nullableString(body.mediaUrl),
        body.status ? optionalStatus(body.status, QUESTION_STATUSES, 'published') : '',
        Object.hasOwn(body, 'examMarkers'),
        jsonObjectValue(body.examMarkers),
      ],
    );

    const question = questionResult.rows[0];
    if (!question) throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND');
    await syncPrimaryCollectionItem(client, auth.tenantId, question.primaryCollectionId || null, question.id);

    if (!createVersion) return question;

    const latest = await client.query<{ version_no: number }>(
      'select coalesce(max(version_no), 0) as version_no from public.question_versions where question_id = $1',
      [questionId],
    );
    const nextVersionNo = Number(latest.rows[0]?.version_no || 0) + 1;

    const versionResult = await client.query(
      `
      insert into public.question_versions (
        tenant_id, question_id, version_no, content, options,
        correct_option_index, correct_option_indices, answer_text,
        explanation, sub_questions, code_lang, code_template, source_hash, created_by
      )
      values ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8, $9, $10::jsonb, $11, $12, $13, $14)
      returning id, version_no as "versionNo", content, options,
                correct_option_index as "correctOptionIndex",
                correct_option_indices as "correctOptionIndices",
                answer_text as "answerText", explanation, sub_questions as "subQuestions",
                code_lang as "codeLang", code_template as "codeTemplate", created_at as "createdAt"
      `,
      [
        auth.tenantId,
        questionId,
        nextVersionNo,
        nullableString(body.content),
        jsonArrayValue(body.options),
        body.correctOptionIndex === undefined || body.correctOptionIndex === null ? null : intValue(body.correctOptionIndex, 0),
        jsonArrayValue(body.correctOptionIndices),
        nullableString(body.answerText),
        nullableString(body.explanation),
        jsonArrayValue(body.subQuestions),
        nullableString(body.codeLang),
        nullableString(body.codeTemplate),
        nullableString(body.sourceHash),
        auth.userId,
      ],
    );

    await client.query(
      `
      update public.questions
      set current_version_id = $3, updated_at = now()
      where tenant_id = $1 and id = $2
      `,
      [auth.tenantId, questionId, versionResult.rows[0].id],
    );

    return { ...question, currentVersion: versionResult.rows[0] };
  });

  return { item };
}

export async function videosAdminRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const subjectId = stringParam(ctx, 'subjectId');
  const limit = intParam(ctx, 'limit', 100, 500);
  const items = await query(
    `
    select id, legacy_id as "legacyId", title, description, video_url as "videoUrl",
           thumbnail_url as "thumbnailUrl", duration_seconds as "duration",
           knowledge_tags as "knowledgeTags", is_general as "isGeneral",
           subject_id as "subjectId", difficulty, sort_order as "order",
           is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
    from public.video_explanations
    where tenant_id = $1 and ($2::uuid is null or subject_id = $2::uuid)
    order by sort_order asc, created_at desc
    limit $3
    `,
    [auth.tenantId, subjectId || null, limit],
  );
  return { items };
}

export async function upsertVideoRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const body = await readJsonBody(ctx);
  const id = nullableString(body.id);
  const title = requiredString(body, 'title');

  const item = await queryOne(
    `
    insert into public.video_explanations (
      id, tenant_id, legacy_id, title, description, video_url, thumbnail_url,
      duration_seconds, knowledge_tags, is_general, subject_id, difficulty,
      sort_order, is_active
    )
    values (
      coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7,
      $8, $9::jsonb, $10, $11::uuid, $12, $13, $14
    )
    on conflict (id)
    do update set title = excluded.title,
                  description = excluded.description,
                  video_url = excluded.video_url,
                  thumbnail_url = excluded.thumbnail_url,
                  duration_seconds = excluded.duration_seconds,
                  knowledge_tags = excluded.knowledge_tags,
                  is_general = excluded.is_general,
                  subject_id = excluded.subject_id,
                  difficulty = excluded.difficulty,
                  sort_order = excluded.sort_order,
                  is_active = excluded.is_active,
                  updated_at = now()
    returning id, title, description, video_url as "videoUrl",
              thumbnail_url as "thumbnailUrl", duration_seconds as "duration",
              knowledge_tags as "knowledgeTags", is_general as "isGeneral",
              subject_id as "subjectId", difficulty, sort_order as "order",
              is_active as "isActive", updated_at as "updatedAt"
    `,
    [
      id,
      auth.tenantId,
      nullableString(body.legacyId),
      title,
      nullableString(body.description),
      nullableString(body.videoUrl),
      nullableString(body.thumbnailUrl),
      body.duration === undefined ? null : intValue(body.duration, 0),
      jsonArrayValue(body.knowledgeTags),
      boolValue(body.isGeneral, false),
      nullableString(body.subjectId),
      body.difficulty === undefined ? null : intValue(body.difficulty, 1),
      intValue(body.order, 0),
      boolValue(body.isActive, true),
    ],
  );
  return { item };
}

export async function bindQuestionVideoRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const body = await readJsonBody(ctx);
  const questionId = requiredString(body, 'questionId');
  const videoId = requiredString(body, 'videoId');

  const item = await transaction(async client => {
    const question = await client.query('select id from public.questions where tenant_id = $1 and id = $2 limit 1', [auth.tenantId, questionId]);
    if (!question.rows[0]) throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND');
    const video = await client.query('select id from public.video_explanations where tenant_id = $1 and id = $2 limit 1', [auth.tenantId, videoId]);
    if (!video.rows[0]) throw new HttpError(404, 'Video not found', 'VIDEO_NOT_FOUND');

    const result = await client.query(
      `
      insert into public.question_videos (
        tenant_id, question_id, video_id, legacy_id, video_type, sort_order
      )
      values ($1, $2, $3, $4, $5, $6)
      returning id, question_id as "questionId", video_id as "videoId",
                video_type as "videoType", sort_order as "order", created_at as "createdAt"
      `,
      [
        auth.tenantId,
        questionId,
        videoId,
        nullableString(body.legacyId),
        optionalString(body, 'videoType') || 'specific',
        intValue(body.order, 0),
      ],
    );

    await client.query('update public.questions set has_video_explanation = true, updated_at = now() where tenant_id = $1 and id = $2', [
      auth.tenantId,
      questionId,
    ]);
    return result.rows[0];
  });

  return { item };
}

export async function scorelineSchoolsAdminRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const regionId = stringParam(ctx, 'regionId');
  const items = await query(
    `
    select id, region_id as "regionId", name, short_name as "shortName", type,
           is_hot as "isHot", sort_order as "order", created_at as "createdAt", updated_at as "updatedAt"
    from public.scoreline_schools
    where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
    order by sort_order asc, name asc
    `,
    [auth.tenantId, regionId || null],
  );
  return { items };
}

export async function upsertScorelineSchoolRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const body = await readJsonBody(ctx);
  const item = await queryOne(
    `
    insert into public.scoreline_schools (id, tenant_id, region_id, legacy_id, name, short_name, type, is_hot, sort_order)
    values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9)
    on conflict (id)
    do update set region_id = excluded.region_id,
                  name = excluded.name,
                  short_name = excluded.short_name,
                  type = excluded.type,
                  is_hot = excluded.is_hot,
                  sort_order = excluded.sort_order,
                  updated_at = now()
    returning id, region_id as "regionId", name, short_name as "shortName",
              type, is_hot as "isHot", sort_order as "order", updated_at as "updatedAt"
    `,
    [
      nullableString(body.id),
      auth.tenantId,
      nullableString(body.regionId),
      nullableString(body.legacyId),
      requiredString(body, 'name'),
      nullableString(body.shortName),
      nullableString(body.type),
      boolValue(body.isHot, false),
      intValue(body.order, 0),
    ],
  );
  return { item };
}

export async function scorelineMajorsAdminRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const schoolId = stringParam(ctx, 'schoolId');
  const items = await query(
    `
    select id, region_id as "regionId", school_id as "schoolId", name,
           sort_order as "order", has_restriction as "hasRestriction",
           restriction_desc as "restrictionDesc", created_at as "createdAt", updated_at as "updatedAt"
    from public.scoreline_majors
    where tenant_id = $1 and ($2::uuid is null or school_id = $2::uuid)
    order by sort_order asc, name asc
    `,
    [auth.tenantId, schoolId || null],
  );
  return { items };
}

export async function upsertScorelineMajorRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const body = await readJsonBody(ctx);
  const item = await queryOne(
    `
    insert into public.scoreline_majors (
      id, tenant_id, region_id, school_id, legacy_id, name, sort_order, has_restriction, restriction_desc
    )
    values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5, $6, $7, $8, $9)
    on conflict (id)
    do update set region_id = excluded.region_id,
                  school_id = excluded.school_id,
                  name = excluded.name,
                  sort_order = excluded.sort_order,
                  has_restriction = excluded.has_restriction,
                  restriction_desc = excluded.restriction_desc,
                  updated_at = now()
    returning id, region_id as "regionId", school_id as "schoolId", name,
              sort_order as "order", has_restriction as "hasRestriction",
              restriction_desc as "restrictionDesc", updated_at as "updatedAt"
    `,
    [
      nullableString(body.id),
      auth.tenantId,
      nullableString(body.regionId),
      requiredString(body, 'schoolId'),
      nullableString(body.legacyId),
      requiredString(body, 'name'),
      intValue(body.order, 0),
      boolValue(body.hasRestriction, false),
      nullableString(body.restrictionDesc),
    ],
  );
  return { item };
}

export async function scorelineFieldsAdminRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const regionId = stringParam(ctx, 'regionId');
  const items = await query(
    `
    select id, region_id as "regionId", field_key as "fieldKey",
           field_name as "fieldName", field_type as "fieldType", unit,
           is_filter as "isFilter", is_required as "isRequired",
           is_visible as "isVisible", is_trend as "isTrend",
           options, placeholder, description, sort_order as "sortOrder"
    from public.scoreline_fields
    where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
    order by sort_order asc
    `,
    [auth.tenantId, regionId || null],
  );
  return { items };
}

export async function upsertScorelineFieldRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const body = await readJsonBody(ctx);
  const item = await queryOne(
    `
    insert into public.scoreline_fields (
      id, tenant_id, region_id, legacy_id, field_key, field_name, field_type,
      unit, is_filter, is_required, is_visible, is_trend, options,
      placeholder, description, sort_order
    )
    values (
      coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7,
      $8, $9, $10, $11, $12, $13::jsonb, $14, $15, $16
    )
    on conflict (tenant_id, region_id, field_key)
    do update set field_name = excluded.field_name,
                  field_type = excluded.field_type,
                  unit = excluded.unit,
                  is_filter = excluded.is_filter,
                  is_required = excluded.is_required,
                  is_visible = excluded.is_visible,
                  is_trend = excluded.is_trend,
                  options = excluded.options,
                  placeholder = excluded.placeholder,
                  description = excluded.description,
                  sort_order = excluded.sort_order,
                  updated_at = now()
    returning id, region_id as "regionId", field_key as "fieldKey",
              field_name as "fieldName", field_type as "fieldType",
              unit, is_filter as "isFilter", is_required as "isRequired",
              is_visible as "isVisible", is_trend as "isTrend",
              options, placeholder, description, sort_order as "sortOrder",
              updated_at as "updatedAt"
    `,
    [
      nullableString(body.id),
      auth.tenantId,
      nullableString(body.regionId),
      nullableString(body.legacyId),
      requiredString(body, 'fieldKey'),
      requiredString(body, 'fieldName'),
      optionalString(body, 'fieldType') || 'number',
      nullableString(body.unit),
      boolValue(body.isFilter, false),
      boolValue(body.isRequired, false),
      boolValue(body.isVisible, true),
      boolValue(body.isTrend, false),
      jsonArrayValue(body.options),
      nullableString(body.placeholder),
      nullableString(body.description),
      intValue(body.sortOrder, 0),
    ],
  );
  return { item };
}

export async function scorelineRecordsAdminRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const regionId = stringParam(ctx, 'regionId');
  const limit = intParam(ctx, 'limit', 100, 500);
  const items = await query(
    `
    select id, region_id as "regionId", school_id as "schoolId",
           major_id as "majorId", year, school_name as "schoolName",
           major_name as "majorName", field_values as "fieldValues",
           created_at as "createdAt", updated_at as "updatedAt"
    from public.scoreline_records
    where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid)
    order by year desc, school_name asc, major_name asc
    limit $3
    `,
    [auth.tenantId, regionId || null, limit],
  );
  return { items };
}

export async function upsertScorelineRecordRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const body = await readJsonBody(ctx);
  const item = await queryOne(
    `
    insert into public.scoreline_records (
      id, tenant_id, region_id, school_id, major_id, legacy_id,
      year, school_name, major_name, field_values
    )
    values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb)
    on conflict (id)
    do update set region_id = excluded.region_id,
                  school_id = excluded.school_id,
                  major_id = excluded.major_id,
                  year = excluded.year,
                  school_name = excluded.school_name,
                  major_name = excluded.major_name,
                  field_values = excluded.field_values,
                  updated_at = now()
    returning id, region_id as "regionId", school_id as "schoolId",
              major_id as "majorId", year, school_name as "schoolName",
              major_name as "majorName", field_values as "fieldValues",
              updated_at as "updatedAt"
    `,
    [
      nullableString(body.id),
      auth.tenantId,
      nullableString(body.regionId),
      nullableString(body.schoolId),
      nullableString(body.majorId),
      nullableString(body.legacyId),
      intValue(body.year, new Date().getFullYear()),
      nullableString(body.schoolName),
      nullableString(body.majorName),
      jsonObjectValue(body.fieldValues),
    ],
  );
  return { item };
}

export async function vocabularyUnitsAdminRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const items = await query(
    `
    select id, region_id as "regionId", entry_id as "entryId",
           content_node_id as "contentNodeId", name, description, word_count as "wordCount",
           sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
    from public.vocabulary_units
    where tenant_id = $1
    order by sort_order asc, created_at asc
    `,
    [auth.tenantId],
  );
  return { items };
}

export async function upsertVocabularyUnitRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const body = await readJsonBody(ctx);
  const item = await transaction(async client => {
    const navigation = await resolveOptionalContentNavigation(
      client,
      auth.tenantId,
      nullableString(body.entryId),
      nullableString(body.contentNodeId),
      'vocabulary',
    );
    const result = await client.query(
      `
      insert into public.vocabulary_units (
        id, tenant_id, region_id, entry_id, content_node_id, legacy_id,
        name, description, word_count, sort_order, is_active, metadata
      )
      values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10, $11, $12::jsonb)
      on conflict (id)
      do update set region_id = excluded.region_id,
                    entry_id = excluded.entry_id,
                    content_node_id = excluded.content_node_id,
                    name = excluded.name,
                    description = excluded.description,
                    word_count = excluded.word_count,
                    sort_order = excluded.sort_order,
                    is_active = excluded.is_active,
                    metadata = excluded.metadata,
                    updated_at = now()
      returning id, region_id as "regionId", entry_id as "entryId",
                content_node_id as "contentNodeId", name, description, word_count as "wordCount",
                sort_order as "order", is_active as "isActive", metadata, updated_at as "updatedAt"
      `,
      [
        nullableString(body.id),
        auth.tenantId,
        nullableString(body.regionId),
        navigation.entryId,
        navigation.contentNodeId,
        nullableString(body.legacyId),
        requiredString(body, 'name'),
        nullableString(body.description),
        body.wordCount === undefined ? null : intValue(body.wordCount, 0),
        intValue(body.order, 0),
        boolValue(body.isActive, true),
        jsonObjectValue(body.metadata),
      ],
    );
    return result.rows[0];
  });
  return { item };
}

export async function vocabularyWordsAdminRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const unitId = stringParam(ctx, 'unitId');
  const limit = intParam(ctx, 'limit', 500, 2000);
  const items = await query(
    `
    select id, unit_id as "unitId", entry_id as "entryId",
           content_node_id as "contentNodeId", word, phonetic, meaning, example,
           example_translation as "exampleTranslation", difficulty, tags,
           sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
    from public.vocabulary_words
    where tenant_id = $1 and ($2::uuid is null or unit_id = $2::uuid)
    order by sort_order asc, word asc
    limit $3
    `,
    [auth.tenantId, unitId || null, limit],
  );
  return { items };
}

export async function upsertVocabularyWordRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const body = await readJsonBody(ctx);
  const item = await transaction(async client => {
    const unitId = nullableString(body.unitId);
    let inheritedEntryId: string | null = null;
    let inheritedContentNodeId: string | null = null;
    if (unitId) {
      const unit = await client.query<{ id: string; entry_id: string | null; content_node_id: string | null }>(
        'select id, entry_id, content_node_id from public.vocabulary_units where tenant_id = $1 and id = $2 limit 1',
        [auth.tenantId, unitId],
      );
      if (!unit.rows[0]) throw new HttpError(400, 'unitId is not in this tenant', 'VOCABULARY_UNIT_NOT_FOUND');
      inheritedEntryId = unit.rows[0].entry_id;
      inheritedContentNodeId = unit.rows[0].content_node_id;
    }
    const navigation = await resolveOptionalContentNavigation(
      client,
      auth.tenantId,
      nullableString(body.entryId) || inheritedEntryId,
      nullableString(body.contentNodeId) || inheritedContentNodeId,
      'vocabulary',
    );
    const result = await client.query(
      `
      insert into public.vocabulary_words (
        id, tenant_id, unit_id, entry_id, content_node_id, legacy_id, word,
        phonetic, meaning, example, example_translation, difficulty, tags,
        sort_order, is_active, metadata
      )
      values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $15, $16::jsonb)
      on conflict (id)
      do update set unit_id = excluded.unit_id,
                    entry_id = excluded.entry_id,
                    content_node_id = excluded.content_node_id,
                    word = excluded.word,
                    phonetic = excluded.phonetic,
                    meaning = excluded.meaning,
                    example = excluded.example,
                    example_translation = excluded.example_translation,
                    difficulty = excluded.difficulty,
                    tags = excluded.tags,
                    sort_order = excluded.sort_order,
                    is_active = excluded.is_active,
                    metadata = excluded.metadata,
                    updated_at = now()
      returning id, unit_id as "unitId", entry_id as "entryId",
                content_node_id as "contentNodeId", word, phonetic, meaning,
                example, example_translation as "exampleTranslation",
                difficulty, tags, sort_order as "order", is_active as "isActive",
                metadata, updated_at as "updatedAt"
      `,
      [
        nullableString(body.id),
        auth.tenantId,
        unitId,
        navigation.entryId,
        navigation.contentNodeId,
        nullableString(body.legacyId),
        requiredString(body, 'word'),
        nullableString(body.phonetic),
        nullableString(body.meaning),
        nullableString(body.example),
        nullableString(body.exampleTranslation),
        body.difficulty === undefined ? null : intValue(body.difficulty, 1),
        jsonArrayValue(body.tags),
        intValue(body.order, 0),
        boolValue(body.isActive, true),
        jsonObjectValue(body.metadata),
      ],
    );
    return result.rows[0];
  });
  return { item };
}

export async function handbookSubjectsAdminRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const items = await query(
    `
    select id, region_id as "regionId", entry_id as "entryId",
           content_node_id as "contentNodeId", name, type, icon, color,
           description, sort_order as "order", is_active as "isActive", metadata
    from public.handbook_subjects
    where tenant_id = $1
    order by sort_order asc, created_at asc
    `,
    [auth.tenantId],
  );
  return { items };
}

export async function upsertHandbookSubjectRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const body = await readJsonBody(ctx);
  const item = await transaction(async client => {
    const navigation = await resolveOptionalContentNavigation(
      client,
      auth.tenantId,
      nullableString(body.entryId),
      nullableString(body.contentNodeId),
      'handbook',
    );
    const result = await client.query(
      `
      insert into public.handbook_subjects (
        id, tenant_id, region_id, entry_id, content_node_id, legacy_id,
        name, type, icon, color, description, sort_order, is_active, metadata
      )
      values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb)
      on conflict (id)
      do update set region_id = excluded.region_id,
                    entry_id = excluded.entry_id,
                    content_node_id = excluded.content_node_id,
                    name = excluded.name,
                    type = excluded.type,
                    icon = excluded.icon,
                    color = excluded.color,
                    description = excluded.description,
                    sort_order = excluded.sort_order,
                    is_active = excluded.is_active,
                    metadata = excluded.metadata,
                    updated_at = now()
      returning id, region_id as "regionId", entry_id as "entryId",
                content_node_id as "contentNodeId", name, type, icon, color,
                description, sort_order as "order", is_active as "isActive",
                metadata, updated_at as "updatedAt"
      `,
      [
        nullableString(body.id),
        auth.tenantId,
        nullableString(body.regionId),
        navigation.entryId,
        navigation.contentNodeId,
        nullableString(body.legacyId),
        requiredString(body, 'name'),
        nullableString(body.type),
        nullableString(body.icon),
        nullableString(body.color),
        nullableString(body.description),
        intValue(body.order, 0),
        boolValue(body.isActive, true),
        jsonObjectValue(body.metadata),
      ],
    );
    return result.rows[0];
  });
  return { item };
}

export async function handbookChaptersAdminRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const subjectId = stringParam(ctx, 'subjectId');
  const items = await query(
    `
    select id, subject_id as "subjectId", entry_id as "entryId",
           content_node_id as "contentNodeId", name, description,
           sort_order as "order", is_active as "isActive"
    from public.handbook_chapters
    where tenant_id = $1 and ($2::uuid is null or subject_id = $2::uuid)
    order by sort_order asc, created_at asc
    `,
    [auth.tenantId, subjectId || null],
  );
  return { items };
}

export async function upsertHandbookChapterRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const body = await readJsonBody(ctx);
  const item = await transaction(async client => {
    const subjectId = requiredString(body, 'subjectId');
    const subject = await client.query<{ id: string; entry_id: string | null; content_node_id: string | null }>(
      'select id, entry_id, content_node_id from public.handbook_subjects where tenant_id = $1 and id = $2 limit 1',
      [auth.tenantId, subjectId],
    );
    if (!subject.rows[0]) throw new HttpError(400, 'subjectId is not in this tenant', 'HANDBOOK_SUBJECT_NOT_FOUND');
    const navigation = await resolveOptionalContentNavigation(
      client,
      auth.tenantId,
      nullableString(body.entryId) || subject.rows[0].entry_id,
      nullableString(body.contentNodeId) || subject.rows[0].content_node_id,
      'handbook',
    );
    const result = await client.query(
      `
      insert into public.handbook_chapters (
        id, tenant_id, subject_id, entry_id, content_node_id, legacy_id,
        name, description, sort_order, is_active, metadata
      )
      values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10, $11::jsonb)
      on conflict (id)
      do update set subject_id = excluded.subject_id,
                    entry_id = excluded.entry_id,
                    content_node_id = excluded.content_node_id,
                    name = excluded.name,
                    description = excluded.description,
                    sort_order = excluded.sort_order,
                    is_active = excluded.is_active,
                    metadata = excluded.metadata,
                    updated_at = now()
      returning id, subject_id as "subjectId", entry_id as "entryId",
                content_node_id as "contentNodeId", name, description,
                sort_order as "order", is_active as "isActive", metadata, updated_at as "updatedAt"
      `,
      [
        nullableString(body.id),
        auth.tenantId,
        subjectId,
        navigation.entryId,
        navigation.contentNodeId,
        nullableString(body.legacyId),
        requiredString(body, 'name'),
        nullableString(body.description),
        intValue(body.order, 0),
        boolValue(body.isActive, true),
        jsonObjectValue(body.metadata),
      ],
    );
    return result.rows[0];
  });
  return { item };
}

export async function handbookEntriesAdminRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const chapterId = stringParam(ctx, 'chapterId');
  const items = await query(
    `
    select id, chapter_id as "chapterId", entry_id as "entryId",
           content_node_id as "contentNodeId", title, summary, content,
           tags, sort_order as "order", is_active as "isActive"
    from public.handbook_entries
    where tenant_id = $1 and ($2::uuid is null or chapter_id = $2::uuid)
    order by sort_order asc, created_at asc
    `,
    [auth.tenantId, chapterId || null],
  );
  return { items };
}

export async function upsertHandbookEntryRoute(ctx: RequestContext) {
  const auth = await requireTenantContentEditor(ctx);
  const body = await readJsonBody(ctx);
  const item = await transaction(async client => {
    const chapterId = requiredString(body, 'chapterId');
    const chapter = await client.query<{ id: string; entry_id: string | null; content_node_id: string | null }>(
      'select id, entry_id, content_node_id from public.handbook_chapters where tenant_id = $1 and id = $2 limit 1',
      [auth.tenantId, chapterId],
    );
    if (!chapter.rows[0]) throw new HttpError(400, 'chapterId is not in this tenant', 'HANDBOOK_CHAPTER_NOT_FOUND');
    const navigation = await resolveOptionalContentNavigation(
      client,
      auth.tenantId,
      nullableString(body.entryId) || chapter.rows[0].entry_id,
      nullableString(body.contentNodeId) || chapter.rows[0].content_node_id,
      'handbook',
    );
    const result = await client.query(
      `
      insert into public.handbook_entries (
        id, tenant_id, chapter_id, entry_id, content_node_id, legacy_id, title,
        summary, content, tags, sort_order, is_active, metadata
      )
      values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb, $11, $12, $13::jsonb)
      on conflict (id)
      do update set chapter_id = excluded.chapter_id,
                    entry_id = excluded.entry_id,
                    content_node_id = excluded.content_node_id,
                    title = excluded.title,
                    summary = excluded.summary,
                    content = excluded.content,
                    tags = excluded.tags,
                    sort_order = excluded.sort_order,
                    is_active = excluded.is_active,
                    metadata = excluded.metadata,
                    updated_at = now()
      returning id, chapter_id as "chapterId", entry_id as "entryId",
                content_node_id as "contentNodeId", title, summary, content,
                tags, sort_order as "order", is_active as "isActive", metadata, updated_at as "updatedAt"
      `,
      [
        nullableString(body.id),
        auth.tenantId,
        chapterId,
        navigation.entryId,
        navigation.contentNodeId,
        nullableString(body.legacyId),
        requiredString(body, 'title'),
        nullableString(body.summary),
        nullableString(body.content),
        jsonArrayValue(body.tags),
        intValue(body.order, 0),
        boolValue(body.isActive, true),
        jsonObjectValue(body.metadata),
      ],
    );
    return result.rows[0];
  });
  return { item };
}
