import { parse } from 'acorn'
import type { OrbitAppJobRef, OrbitAppPublishBody, OrbitAppRoute } from '@hrbr/core/orbit'
import { prepareRuntimeSource } from '@hrbr/runtime-planner/source-prep'
import { OrbitAuthoringValidationError } from './define-job'

type AcornNode = {
  readonly type: string
  readonly [key: string]: unknown
}

export type DeployAppCompileTiming = {
  readonly validate_ms: number
  readonly manifest_normalize_ms: number
  readonly total_ms: number
}

export type DeployAppCompileResult = {
  readonly publish: Omit<OrbitAppPublishBody, 'workspace_id'>
  readonly timing: DeployAppCompileTiming
}

const DEPLOY_APP_PATTERN = /\bdeployApp\s*\(/
const APP_NAME_PATTERN = /^[a-z][a-z0-9-]{0,127}$/
const CAMEL_APP_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9]{0,127}$/
const ROUTE_KEY_PATTERN = /^(GET|POST|PUT|PATCH|DELETE|OPTIONS)\s+(\/.*)$/
const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'])
const INPUT_ADAPTERS = new Set(['none', 'query', 'json', 'form', 'raw'])
const OUTPUT_ADAPTERS = new Set(['html', 'json', 'text', 'redirect', 'passthrough'])
const ROUTE_AUTH_VALUES = new Set(['public', 'workspace_member', 'signed_link', 'service'])

const stripStringsAndComments = (source: string): string => {
  let out = ''
  let i = 0
  while (i < source.length) {
    const ch = source[i]
    const next = source[i + 1]
    if (ch === '/' && next === '/') {
      while (i < source.length && source[i] !== '\n') {
        out += ' '
        i += 1
      }
      continue
    }
    if (ch === '/' && next === '*') {
      out += '  '
      i += 2
      while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) {
        out += source[i] === '\n' ? '\n' : ' '
        i += 1
      }
      if (i < source.length) {
        out += '  '
        i += 2
      }
      continue
    }
    if (ch === '"' || ch === "'" || ch === '`') {
      const quote = ch
      out += ' '
      i += 1
      while (i < source.length) {
        const c = source[i]
        out += c === '\n' ? '\n' : ' '
        i += c === '\\' ? 2 : 1
        if (c === quote) break
      }
      continue
    }
    out += ch
    i += 1
  }
  return out
}

const isNode = (value: unknown): value is AcornNode =>
  Boolean(value) &&
  typeof value === 'object' &&
  typeof (value as { type?: unknown }).type === 'string'

const issue = (message: string): never => {
  throw new OrbitAuthoringValidationError([message])
}

const propName = (node: AcornNode): string | undefined => {
  if (node.type === 'Identifier') return String(node.name)
  if (node.type === 'Literal' && typeof node.value === 'string') return node.value
  return undefined
}

const objectProperties = (node: AcornNode, label: string): ReadonlyMap<string, AcornNode> => {
  if (node.type !== 'ObjectExpression') issue(`${label} must be an object literal.`)
  const map = new Map<string, AcornNode>()
  for (const prop of (node.properties as ReadonlyArray<AcornNode> | undefined) ?? []) {
    if (prop.type === 'SpreadElement') issue(`${label} does not support spread properties in v1.`)
    const maybeKey = propName(prop.key as AcornNode)
    const key =
      maybeKey || issue(`${label} object keys must be static identifiers or string literals.`)
    const value = (prop.value ?? prop) as AcornNode
    map.set(key, value)
  }
  return map
}

const literalValue = (node: AcornNode, label: string): unknown => {
  if (node.type === 'Literal') return node.value
  if (node.type === 'ObjectExpression') {
    const out: Record<string, unknown> = {}
    for (const [key, value] of objectProperties(node, label))
      out[key] = literalValue(value, `${label}.${key}`)
    return out
  }
  if (node.type === 'ArrayExpression') {
    return ((node.elements as ReadonlyArray<AcornNode | null> | undefined) ?? []).map(
      (item, index) => (item ? literalValue(item, `${label}[${index}]`) : null)
    )
  }
  issue(`${label} must use static literals in v1.`)
}

const requireString = (
  props: ReadonlyMap<string, AcornNode>,
  key: string,
  label: string
): string => {
  const node = props.get(key)
  const value = node?.type === 'Literal' ? node.value : undefined
  if (typeof value !== 'string' || value.trim() === '') {
    issue(`${label}.${key} must be a non-empty string literal.`)
  }
  return (value as string).trim()
}

const optionalString = (
  props: ReadonlyMap<string, AcornNode>,
  key: string,
  label: string
): string | undefined => {
  const node = props.get(key)
  if (!node) return undefined
  const value = node.type === 'Literal' ? node.value : undefined
  if (typeof value !== 'string') issue(`${label}.${key} must be a string literal when provided.`)
  return value as string
}

const optionalStringArray = (
  props: ReadonlyMap<string, AcornNode>,
  key: string,
  label: string
): string[] | undefined => {
  const node = props.get(key)
  if (!node) return undefined
  if (node.type !== 'ArrayExpression') issue(`${label}.${key} must be a string array literal.`)
  return ((node.elements as ReadonlyArray<AcornNode | null> | undefined) ?? []).map((item) => {
    const value = item?.type === 'Literal' ? item.value : undefined
    if (typeof value !== 'string') issue(`${label}.${key} must contain only string literals.`)
    return value as string
  })
}

const optionalLiteralObject = (
  props: ReadonlyMap<string, AcornNode>,
  key: string,
  label: string
): Record<string, unknown> | undefined => {
  const node = props.get(key)
  if (!node) return undefined
  const value = literalValue(node, `${label}.${key}`)
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
    issue(`${label}.${key} must be an object literal when provided.`)
  }
  return value as Record<string, unknown>
}

const toCanonicalAppName = (raw: string): string => {
  if (APP_NAME_PATTERN.test(raw)) return raw
  if (!CAMEL_APP_NAME_PATTERN.test(raw)) {
    issue('deployApp.id must be kebab-case, or camelCase so Harbor can canonicalize it.')
  }
  const kebab = raw
    .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
    .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
    .toLowerCase()
  if (!APP_NAME_PATTERN.test(kebab)) issue('deployApp.id could not be canonicalized to kebab-case.')
  return kebab
}

const stringEnum = (
  value: string | undefined,
  allowed: ReadonlySet<string>,
  fallback: string,
  label: string
): string => {
  const selected = value ?? fallback
  if (!allowed.has(selected)) issue(`${label} must be one of: ${[...allowed].join(', ')}.`)
  return selected
}

const defaultInputForMethod = (method: string): 'none' | 'json' =>
  method === 'GET' || method === 'DELETE' || method === 'OPTIONS' ? 'none' : 'json'

const defaultOutputForMethod = (method: string): 'html' | 'json' =>
  method === 'GET' ? 'html' : 'json'

const routePartsFromKey = (
  key: string,
  routeProps: ReadonlyMap<string, AcornNode>,
  label: string
) => {
  const match = ROUTE_KEY_PATTERN.exec(key)
  const methodFromKey = match?.[1]
  const pathFromKey = match?.[2]
  const method = optionalString(routeProps, 'method', label)?.toUpperCase() ?? methodFromKey
  const path = optionalString(routeProps, 'path', label) ?? pathFromKey
  if (!method || !HTTP_METHODS.has(method))
    issue(`${label} route key must look like "GET /path" or include a valid method.`)
  if (!path || !path.startsWith('/')) issue(`${label} route path must start with /.`)
  return { method: method as string, path: path as string }
}

const hasFunctionValue = (node: AcornNode | undefined): boolean =>
  Boolean(node && ['FunctionExpression', 'ArrowFunctionExpression'].includes(node.type))

const normalizeJobs = (node: AcornNode | undefined): Record<string, OrbitAppJobRef> => {
  if (!node) return {}
  const raw = literalValue(node, 'deployApp.jobs')
  if (!raw || typeof raw !== 'object' || Array.isArray(raw))
    issue('deployApp.jobs must be an object literal.')
  return raw as Record<string, OrbitAppJobRef>
}

type NormalizedRoutes = {
  readonly routes: OrbitAppRoute[]
  readonly hasHandlers: boolean
}

const normalizeRoutes = (node: AcornNode, defaultAuth: string): NormalizedRoutes => {
  const props = objectProperties(node, 'deployApp.routes')
  const routes: OrbitAppRoute[] = []
  let hasHandlers = false
  for (const [key, value] of props) {
    const label = `deployApp.routes[${JSON.stringify(key)}]`
    const routeProps = objectProperties(value, label)
    const { method, path } = routePartsFromKey(key, routeProps, label)
    const inputLiteral = optionalString(routeProps, 'input', label)
    const outputLiteral = optionalString(routeProps, 'output', label)
    const authLiteral = optionalString(routeProps, 'auth', label)
    const runNode = routeProps.get('run')
    const hasHandler = hasFunctionValue(runNode)
    const job = optionalString(routeProps, 'job', label)
    const staticHtml =
      optionalString(routeProps, 'static_html', label) ??
      optionalString(routeProps, 'staticHtml', label)
    if (runNode && !hasHandler) issue(`${label}.run must be an inline function or async method.`)
    if (hasHandler && (job || staticHtml !== undefined)) {
      issue(`${label} must not combine run with job/staticHtml.`)
    }
    if (!hasHandler && !job && staticHtml === undefined) {
      issue(`${label} must declare run, job, or staticHtml.`)
    }
    hasHandlers = hasHandlers || hasHandler
    const route: OrbitAppRoute = {
      method: method as OrbitAppRoute['method'],
      path,
      auth: stringEnum(
        authLiteral,
        ROUTE_AUTH_VALUES,
        defaultAuth,
        `${label}.auth`
      ) as OrbitAppRoute['auth'],
      input: stringEnum(
        inputLiteral,
        INPUT_ADAPTERS,
        defaultInputForMethod(method),
        `${label}.input`
      ) as OrbitAppRoute['input'],
      output: stringEnum(
        outputLiteral,
        OUTPUT_ADAPTERS,
        defaultOutputForMethod(method),
        `${label}.output`
      ) as OrbitAppRoute['output'],
      ...(optionalString(routeProps, 'id', label)
        ? { id: optionalString(routeProps, 'id', label) }
        : {}),
      ...(optionalString(routeProps, 'title', label)
        ? { title: optionalString(routeProps, 'title', label) }
        : {}),
      ...(optionalStringArray(routeProps, 'tags', label)
        ? { tags: optionalStringArray(routeProps, 'tags', label) }
        : {}),
      ...(optionalLiteralObject(routeProps, 'rate_limit', label)
        ? {
            rate_limit: optionalLiteralObject(
              routeProps,
              'rate_limit',
              label
            ) as OrbitAppRoute['rate_limit'],
          }
        : {}),
      ...(job ? { job } : {}),
      ...(staticHtml !== undefined ? { static_html: staticHtml } : {}),
    }
    routes.push(route)
  }
  if (routes.length === 0) issue('deployApp.routes must contain at least one route.')
  return { routes, hasHandlers }
}

const findTopLevelDeployApp = (ast: AcornNode): { statement: AcornNode; call: AcornNode } => {
  const body = ast.body as ReadonlyArray<AcornNode>
  let found: { statement: AcornNode; call: AcornNode } | undefined
  for (const statement of body) {
    if (statement.type === 'ImportDeclaration')
      issue('deployApp authoring does not support imports in v1.')
    const expression =
      statement.type === 'ExpressionStatement' ? (statement.expression as AcornNode) : undefined
    const isDeployApp =
      expression?.type === 'CallExpression' &&
      isNode(expression.callee) &&
      expression.callee.type === 'Identifier' &&
      expression.callee.name === 'deployApp'
    if (isDeployApp) {
      if (found) issue('Exactly one top-level deployApp(...) call is allowed.')
      found = { statement, call: expression }
    }
  }
  return found ?? issue('Expected one top-level deployApp({...}) call.')
}

const buildGeneratedSource = (opts: {
  readonly transformed: string
  readonly statement: AcornNode
  readonly call: AcornNode
  readonly appName: string
  readonly description?: string | undefined
  readonly routes: readonly OrbitAppRoute[]
  readonly jobs: Readonly<Record<string, OrbitAppJobRef>>
  readonly theme?: Record<string, unknown> | undefined
  readonly hasHandlers: boolean
}): string => {
  const args = (opts.call.arguments as ReadonlyArray<AcornNode> | undefined) ?? []
  const arg = args[0]!
  const definitionSource = opts.transformed.slice(Number(arg.start), Number(arg.end))
  const routeKeys = opts.routes.map((route) => `${route.method} ${route.path}`)
  const fetchBlock = opts.hasHandlers
    ? [
        'async fetch(request, env, ctx) {',
        '  const { hrbr, orbit } = env?.hrbr && env?.orbit ? { hrbr: env.hrbr, orbit: env.orbit } : __buildRuntime(request, env);',
        '  void orbit;',
        '  const url = new URL(request.url);',
        '  const key = request.method + " " + (url.pathname || "/");',
        '  const __hrbrDeployAppDefinition = ' + definitionSource + ';',
        '  const route = __hrbrDeployAppDefinition.routes[key];',
        '  if (!route || typeof route.run !== "function") return undefined;',
        '  return await route.run(request, env, ctx);',
        '}',
      ].join('\n')
    : ''
  const replacement = [
    'export default defineOrbitApp({',
    '  name: ' + JSON.stringify(opts.appName) + ',',
    ...(opts.description !== undefined
      ? ['  description: ' + JSON.stringify(opts.description) + ',']
      : []),
    '  jobs: ' + JSON.stringify(opts.jobs) + ',',
    '  routes: ' + JSON.stringify(opts.routes) + ',',
    ...(opts.theme !== undefined ? ['  theme: ' + JSON.stringify(opts.theme) + ','] : []),
    ...(opts.hasHandlers ? ['  ' + fetchBlock.replace(/\n/g, '\n  ') + ','] : []),
    '});',
    'void ' + JSON.stringify(routeKeys) + ';',
  ].join('\n')

  return (
    opts.transformed.slice(0, Number(opts.statement.start)) +
    replacement +
    opts.transformed.slice(Number(opts.statement.end))
  )
}

export const looksLikeDeployAppSource = (source: string): boolean =>
  DEPLOY_APP_PATTERN.test(stripStringsAndComments(source))

export function compileDeployAppPublish(source: string): DeployAppCompileResult {
  const totalStarted = Date.now()
  const validateStarted = Date.now()
  if (!looksLikeDeployAppSource(source)) issue('Expected deployApp({...}) in app source.')
  const strippedSource = stripStringsAndComments(source)
  if (
    /(^|[;\n])\s*import(?:\s|\{|\*|[\w$])/.test(strippedSource) ||
    /\bimport\s*\(/.test(strippedSource)
  ) {
    issue('deployApp authoring does not support imports in v1.')
  }
  const transformed = prepareRuntimeSource(source, 'deployApp').code
  const ast = parse(transformed, {
    ecmaVersion: 'latest',
    sourceType: 'module',
    allowAwaitOutsideFunction: true,
    allowReturnOutsideFunction: true,
  }) as unknown as AcornNode
  const found = findTopLevelDeployApp(ast)
  const args = (found.call.arguments as ReadonlyArray<AcornNode> | undefined) ?? []
  if (args.length !== 1) issue('deployApp expects exactly one object literal argument.')
  const props = objectProperties(args[0]!, 'deployApp')
  const rawName =
    optionalString(props, 'id', 'deployApp') ?? optionalString(props, 'name', 'deployApp')
  if (!rawName) issue('deployApp.id must be a non-empty string literal.')
  const name = toCanonicalAppName(rawName as string)
  const routesNode = props.get('routes') ?? issue('deployApp.routes is required.')
  const access = optionalString(props, 'access', 'deployApp')
  const defaultAuth = stringEnum(
    access,
    new Set(['public', 'workspace_member']),
    'workspace_member',
    'deployApp.access'
  )
  const validateMs = Date.now() - validateStarted

  const manifestStarted = Date.now()
  const description =
    optionalString(props, 'description', 'deployApp') ?? optionalString(props, 'title', 'deployApp')
  const jobs = normalizeJobs(props.get('jobs'))
  const { routes, hasHandlers } = normalizeRoutes(routesNode, defaultAuth)
  const theme = optionalLiteralObject(props, 'theme', 'deployApp')
  const allowedOrigins =
    optionalStringArray(props, 'allowed_origins', 'deployApp') ??
    optionalStringArray(props, 'allowedOrigins', 'deployApp')
  const idempotencyKey =
    optionalString(props, 'idempotency_key', 'deployApp') ??
    optionalString(props, 'idempotencyKey', 'deployApp')
  if (props.has('runtime')) {
    issue(
      'deployApp.runtime is not a public authoring field; Harbor selects app execution internally.'
    )
  }
  const manifestMs = Date.now() - manifestStarted
  const code = buildGeneratedSource({
    transformed,
    statement: found.statement,
    call: found.call,
    appName: name,
    description,
    routes,
    jobs,
    theme,
    hasHandlers,
  })

  return {
    timing: {
      validate_ms: validateMs,
      manifest_normalize_ms: manifestMs,
      total_ms: Date.now() - totalStarted,
    },
    publish: {
      name,
      ...(description !== undefined ? { description } : {}),
      code,
      routes,
      jobs,
      ...(theme !== undefined ? { theme } : {}),
      ...(allowedOrigins !== undefined ? { allowed_origins: allowedOrigins } : {}),
      ...(idempotencyKey !== undefined ? { idempotency_key: idempotencyKey } : {}),
    },
  }
}
