import * as Excel from 'exceljs'
import z from 'zod'

/**
 * Internal type for Zod schema definition with fields from both v3 and v4.
 */
type ZodDef = {
  type?: string
  innerType?: z.ZodTypeAny
  schema?: z.ZodTypeAny
  shape?: Record<string, z.ZodTypeAny>
  element?: z.ZodTypeAny
  options?: z.ZodTypeAny[]
  in?: z.ZodTypeAny
  out?: z.ZodTypeAny
  keyType?: z.ZodTypeAny
  valueType?: z.ZodTypeAny
  items?: z.ZodTypeAny[]
  left?: z.ZodTypeAny
  right?: z.ZodTypeAny
  defaultValue?: () => unknown
  catchValue?: unknown
  getter?: () => z.ZodTypeAny
}

type StripFn = (def: ZodDef, recurse: (s: z.ZodTypeAny) => z.ZodTypeAny) => z.ZodTypeAny | undefined

const handlePipe: StripFn = (def, recurse) => (def.in ? recurse(def.in) : undefined)

const handleEffect: StripFn = (def, recurse) => (def.schema ? recurse(def.schema) : undefined)

const handleObject: StripFn = (def, recurse) => {
  if (!def.shape) {
    return undefined
  }
  const newShape: Record<string, z.ZodTypeAny> = {}
  for (const [key, value] of Object.entries(def.shape)) {
    newShape[key] = recurse(value as z.ZodTypeAny)
  }
  return z.object(newShape)
}

const handleArray: StripFn = (def, recurse) => {
  const element = def.element || (def as { type?: z.ZodTypeAny }).type
  return element ? z.array(recurse(element)) : undefined
}

const handleNullable: StripFn = (def, recurse) => (def.innerType ? recurse(def.innerType).nullable() : undefined)

const handleOptional: StripFn = (def, recurse) => (def.innerType ? recurse(def.innerType).optional() : undefined)

const handleUnion: StripFn = (def, recurse) =>
  def.options
    ? z.union(def.options.map((opt) => recurse(opt)) as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]])
    : undefined

const handleIntersection: StripFn = (def, recurse) =>
  def.left && def.right ? z.intersection(recurse(def.left), recurse(def.right)) : undefined

const handleTuple: StripFn = (def, recurse) =>
  def.items ? z.tuple(def.items.map((item) => recurse(item)) as [z.ZodTypeAny, ...z.ZodTypeAny[]]) : undefined

const handleRecord: StripFn = (def, recurse) => {
  if (!def.keyType || !def.valueType) {
    return undefined
  }
  const strippedKey = recurse(def.keyType) as z.ZodString
  return z.record(strippedKey, recurse(def.valueType))
}

const handleDefault: StripFn = (def, recurse) => {
  if (!def.innerType) {
    return undefined
  }
  return def.defaultValue ? recurse(def.innerType).default(def.defaultValue()) : recurse(def.innerType)
}

const handleCatch: StripFn = (def, recurse) => {
  if (!def.innerType) {
    return undefined
  }
  const catchValue = typeof def.catchValue === 'function' ? def.catchValue() : def.catchValue
  return recurse(def.innerType).catch(catchValue)
}

const handleBranded: StripFn = (def, recurse) => (def.innerType ? recurse(def.innerType) : undefined)

const handleLazy: StripFn = (def, recurse) => (def.getter ? recurse(def.getter()) : undefined)

const handleDate: StripFn = () => z.any().describe('ISO 8601 date string')

const handleCustom: StripFn = () => z.unknown()

const typeHandlers: Record<string, StripFn> = {
  pipe: handlePipe,
  effect: handleEffect,
  object: handleObject,
  array: handleArray,
  nullable: handleNullable,
  optional: handleOptional,
  union: handleUnion,
  discriminatedUnion: handleUnion,
  intersection: handleIntersection,
  tuple: handleTuple,
  record: handleRecord,
  default: handleDefault,
  catch: handleCatch,
  branded: handleBranded,
  lazy: handleLazy,
  date: handleDate,
  custom: handleCustom,
}

/**
 * Recursively strips .transform() calls from a Zod schema to make it compatible
 * with Zod v4's JSON Schema generation.
 *
 * This is necessary because Zod v4's toJSONSchema() throws an error when it encounters
 * transforms, as they cannot be represented in JSON Schema. For REST API documentation,
 * we need schemas that describe the wire format (strings for IDs) rather than the
 * transformed TypeScript types.
 *
 * @example
 * const schema = z.object({
 *   id: z.string().transform((v) => toFacilityId(v)),
 *   name: z.string(),
 * })
 * const restApiSchema = stripTransforms(schema)
 * // restApiSchema is equivalent to z.object({ id: z.string(), name: z.string() })
 */
export function stripTransforms<T extends z.ZodTypeAny>(schema: T): z.ZodTypeAny {
  const def = schema.def as ZodDef

  const handler = def.type ? typeHandlers[def.type] : undefined
  if (handler) {
    const result = handler(def, stripTransforms)
    if (result) {
      return result
    }
  }

  return schema
}

/**
 * Adjusts column widths in a worksheet based on content.
 */
const adjustColumnWidth = (ws: Excel.Worksheet, maxWidth?: number) => {
  for (const column of ws.columns) {
    let dataMax = 0
    for (const cell of column.values ?? []) {
      const columnLength = typeof cell === 'string' ? cell.length : 0
      if (columnLength > dataMax) {
        dataMax = columnLength
      }
      if (maxWidth && dataMax > maxWidth) {
        dataMax = maxWidth
        break
      }
    }
    // minimum width of 10
    column.width = Math.max(10, dataMax)
  }
}

/**
 * Creates an Excel workbook buffer with a single table.
 */
export async function createWorkbookBuffer({
  wsName,
  tableName,
  columns,
  rows,
}: {
  wsName: string
  tableName: string
  columns: string[]
  rows: unknown[][]
}): Promise<Buffer> {
  const wb = new Excel.Workbook()
  wb.creator = 'PostXL'
  wb.lastModifiedBy = 'PostXL'
  wb.created = new Date()
  wb.modified = new Date()

  const ws = wb.addWorksheet(wsName.substring(0, 30)) // worksheet name has max length
  const table = ws.addTable({
    name: tableName.replaceAll(' ', '_'), // table name cannot have spaces
    ref: 'A1',
    headerRow: true,
    totalsRow: false,
    style: {
      theme: 'TableStyleMedium2',
      showRowStripes: true,
    },
    columns: columns.map((name) => ({ name, filterButton: true })),
    rows: rows.length > 0 ? rows : [columns.map(() => '')],
  })
  table.commit()
  adjustColumnWidth(ws, 100)

  const buffer = await wb.xlsx.writeBuffer()
  return Buffer.from(buffer)
}

/**
 * Creates a Zod schema for a query parameter that accepts a JSON string
 * and parses it into the specified schema type.
 *
 * OpenAPI will show this as an optional string parameter.
 * At runtime, the string is parsed as JSON and validated against the schema.
 *
 * @example
 * const decoder = z.object({
 *   filters: jsonQueryParam(zMyFilterSchema),
 * })
 * // Accepts: ?filters={"name":{"contains":"test"}}
 */
export const jsonQueryParam = <T extends z.ZodTypeAny>(schema: T) =>
  z
    .string()
    .optional()
    .transform((val, ctx) => {
      if (!val) {
        return undefined
      }
      try {
        return JSON.parse(val)
      } catch (e) {
        ctx.addIssue({
          code: 'custom',
          message: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}`,
        })
        return z.NEVER
      }
    })
    .pipe(schema.optional())
