// CMS pages model. Owns the rules the API surface must not bypass:
//   - blocks are validated against the block registry and sanitized on every
//     save (the authoritative gate — a direct API call can't skip it);
//   - the DB row is mapped to/from the grouped API shape (metadata / settings);
//   - slug is validated + reserved-checked at create and is immutable after;
//   - `protected` can be turned ON via a normal update but only OFF via the
//     dedicated unprotect path (see unprotect()), enforced here regardless of
//     what the request body contains.
//
// Business/validation failures throw a PageError carrying an HTTP status + code
// so the controller can translate without knowing the rules.

const pagesDb = require('./pages.db')
const { isReservedSlug } = require('./reservedSlugs')
const { validateBlocks, sanitizeBlocks } = require('../../blocks')

const LAYOUTS = ['default', 'full_width', 'landing']
const NAV_GROUPS = ['main', 'footer', 'account', 'hidden']
const STATUSES = ['draft', 'published']
const SLUG_RE = /^[a-z0-9-]+$/
const MAX_SLUG = 160

class PageError extends Error {
  constructor(status, code, message, extra) {
    super(message)
    this.name = 'PageError'
    this.status = status
    this.code = code
    if (extra) Object.assign(this, extra)
  }
}

// ── Serialization (row → API shape) ───────────────────────────────────
function parseBlocks(raw) {
  if (raw == null || raw === '') return []
  try {
    const parsed = JSON.parse(raw)
    return Array.isArray(parsed) ? parsed : []
  } catch {
    return []
  }
}

function serialize(row) {
  if (!row) return null
  return {
    id: row.id,
    slug: row.slug,
    title: row.title,
    status: row.status,
    blocks: parseBlocks(row.blocks),
    metadata: {
      seoTitle: row.seo_title,
      metaDescription: row.meta_description,
      ogImage: row.og_image,
      canonicalUrl: row.canonical_url,
      robots: row.robots,
    },
    settings: {
      layout: row.layout,
      showInNav: Boolean(row.show_in_nav),
      navGroup: row.nav_group,
      navOrder: row.nav_order,
      protected: Boolean(row.protected),
    },
    authorId: row.author_id,
    createdAt: row.created_at,
    updatedAt: row.updated_at,
    publishedAt: row.published_at,
  }
}

function serializeSummary(row) {
  return {
    id: row.id,
    slug: row.slug,
    title: row.title,
    status: row.status,
    protected: Boolean(row.protected),
    showInNav: Boolean(row.show_in_nav),
    navGroup: row.nav_group,
    navOrder: row.nav_order,
    updatedAt: row.updated_at,
    publishedAt: row.published_at,
  }
}

// ── Field validation / mapping ────────────────────────────────────────
function assertSlug(slug) {
  if (typeof slug !== 'string' || !SLUG_RE.test(slug) || slug.length > MAX_SLUG) {
    throw new PageError(400, 'invalid_slug', 'Slug must be lowercase letters, numbers and dashes.')
  }
  if (isReservedSlug(slug)) {
    throw new PageError(400, 'reserved_slug', `"${slug}" is a reserved slug.`)
  }
}

function assertStatus(status) {
  if (status !== undefined && !STATUSES.includes(status)) {
    throw new PageError(400, 'invalid_status', `status must be one of ${STATUSES.join(', ')}.`)
  }
}

// Validate + sanitize blocks; returns a JSON string ready to store.
function buildBlocks(blocks) {
  const { valid, errors } = validateBlocks(blocks)
  if (!valid) {
    throw new PageError(400, 'invalid_blocks', 'One or more blocks are invalid.', { errors })
  }
  return JSON.stringify(sanitizeBlocks(blocks))
}

// Map the grouped `metadata` object to DB columns. Only keys present in the
// input are returned, so a PATCH touches only what it sends.
function mapMetadata(metadata) {
  const cols = {}
  if (!metadata || typeof metadata !== 'object') return cols
  const strOrNull = (v, max, field) => {
    if (v === null || v === undefined || v === '') return null
    if (typeof v !== 'string' || v.length > max) {
      throw new PageError(400, 'invalid_metadata', `${field} must be a string of at most ${max} characters.`)
    }
    return v
  }
  if ('seoTitle' in metadata) cols.seo_title = strOrNull(metadata.seoTitle, 200, 'seoTitle')
  if ('metaDescription' in metadata) cols.meta_description = strOrNull(metadata.metaDescription, 400, 'metaDescription')
  if ('ogImage' in metadata) cols.og_image = strOrNull(metadata.ogImage, 500, 'ogImage')
  if ('canonicalUrl' in metadata) cols.canonical_url = strOrNull(metadata.canonicalUrl, 500, 'canonicalUrl')
  if ('robots' in metadata) cols.robots = strOrNull(metadata.robots, 100, 'robots')
  return cols
}

// Per-setting validators — each throws a 400 or returns the accepted value. Split
// out of mapSettings so that function stays a flat dispatch (keeps its cognitive
// complexity low).
function validLayout(v) {
  if (!LAYOUTS.includes(v)) {
    throw new PageError(400, 'invalid_settings', `layout must be one of ${LAYOUTS.join(', ')}.`)
  }
  return v
}
function validShowInNav(v) {
  if (typeof v !== 'boolean') {
    throw new PageError(400, 'invalid_settings', 'showInNav must be a boolean.')
  }
  return v ? 1 : 0
}
function validNavGroup(v) {
  if (v !== null && !NAV_GROUPS.includes(v)) {
    throw new PageError(400, 'invalid_settings', `navGroup must be null or one of ${NAV_GROUPS.join(', ')}.`)
  }
  return v
}
function validNavOrder(v) {
  if (v !== null && !Number.isInteger(v)) {
    throw new PageError(400, 'invalid_settings', 'navOrder must be an integer or null.')
  }
  return v
}
function validTitle(v) {
  if (typeof v !== 'string' || v.trim() === '' || v.length > 200) {
    throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).')
  }
  return v.trim()
}

// Map the grouped `settings` object to DB columns (except `protected`, which is
// handled by the caller so the unprotect rule stays centralized).
function mapSettings(settings) {
  const cols = {}
  if (!settings || typeof settings !== 'object') return cols
  if ('layout' in settings) cols.layout = validLayout(settings.layout)
  if ('showInNav' in settings) cols.show_in_nav = validShowInNav(settings.showInNav)
  if ('navGroup' in settings) cols.nav_group = validNavGroup(settings.navGroup)
  if ('navOrder' in settings) cols.nav_order = validNavOrder(settings.navOrder)
  return cols
}

// ── Reads ─────────────────────────────────────────────────────────────
async function list() {
  const rows = await pagesDb.listSummaries()
  return rows.map(serializeSummary)
}

async function getById(id) {
  return serialize(await pagesDb.findById(id))
}

// Public read by slug. Non-admins only see published pages (returns null for a
// draft so the caller can 404 it indistinguishably from a missing page).
async function getBySlug(slug, { includeUnpublished = false } = {}) {
  const row = await pagesDb.findBySlug(slug)
  if (!row) return null
  if (!includeUnpublished && row.status !== 'published') return null
  return serialize(row)
}

// Raw row (for the controller's protected/status checks without re-serializing).
async function getRawById(id) {
  return pagesDb.findById(id)
}

// ── Writes ────────────────────────────────────────────────────────────
async function create(input, authorId) {
  const { slug, title, blocks = [], status = 'draft', metadata, settings } = input
  assertSlug(slug)
  assertStatus(status)

  const row = {
    slug,
    title: validTitle(title),
    blocks: buildBlocks(blocks),
    status,
    author_id: authorId,
    ...mapMetadata(metadata),
    ...mapSettings(settings),
    protected: settings && settings.protected === true ? 1 : 0,
    published_at: status === 'published' ? new Date() : null,
  }

  let id
  try {
    id = await pagesDb.insert(row)
  } catch (err) {
    if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) {
      throw new PageError(409, 'slug_taken', `A page with slug "${slug}" already exists.`)
    }
    throw err
  }
  return getById(id)
}

// Apply the status patch, stamping published_at the first time a page publishes.
function applyStatusPatch(fields, status, current) {
  assertStatus(status)
  fields.status = status
  if (status === 'published' && !current.published_at) fields.published_at = new Date()
}

// Protected transitions: turning protection ON is allowed here; turning it OFF is
// not (must go through the password-gated unprotect endpoint), regardless of the
// request body. Turning OFF while already unprotected is a no-op.
function applyProtectedPatch(fields, settings, current) {
  if (!settings || !('protected' in settings)) return
  const want = settings.protected
  if (want === true) {
    fields.protected = 1
  } else if (want === false && current.protected) {
    throw new PageError(403, 'unprotect_required', 'Disabling protection requires the unprotect endpoint.')
  }
}

async function update(id, patch) {
  const current = await pagesDb.findById(id)
  if (!current) throw new PageError(404, 'not_found', 'Page not found.')

  // slug is immutable after create — reject an attempt rather than silently
  // ignoring it, so the caller knows their change didn't take.
  if (patch.slug !== undefined && patch.slug !== current.slug) {
    throw new PageError(400, 'slug_immutable', 'A page slug cannot be changed after creation.')
  }

  const fields = {}
  if (patch.title !== undefined) fields.title = validTitle(patch.title)
  if (patch.blocks !== undefined) fields.blocks = buildBlocks(patch.blocks)
  if (patch.status !== undefined) applyStatusPatch(fields, patch.status, current)

  Object.assign(fields, mapMetadata(patch.metadata))
  Object.assign(fields, mapSettings(patch.settings))
  applyProtectedPatch(fields, patch.settings, current)

  await pagesDb.update(id, fields)
  return getById(id)
}

async function remove(id) {
  const current = await pagesDb.findById(id)
  if (!current) throw new PageError(404, 'not_found', 'Page not found.')
  if (current.protected) {
    throw new PageError(403, 'page_protected', 'This page is protected and cannot be deleted.')
  }
  await pagesDb.remove(id)
  return { id }
}

// Flip protected → false. The controller performs the password step-up before
// calling this; the model just applies it.
async function unprotect(id) {
  const current = await pagesDb.findById(id)
  if (!current) throw new PageError(404, 'not_found', 'Page not found.')
  await pagesDb.update(id, { protected: 0 })
  return getById(id)
}

module.exports = {
  PageError,
  LAYOUTS,
  NAV_GROUPS,
  STATUSES,
  serialize,
  list,
  getById,
  getBySlug,
  getRawById,
  create,
  update,
  remove,
  unprotect,
}
