import { parse } from 'acorn'
import { Schema } from 'effect'
import type { OrbitJobCapability, OrbitJsonSchema, OrbitJobPublishBody } from '@hrbr/core/orbit'
import { TriggerableJobManifest } from '@hrbr/core/trigger'
import { prepareRuntimeSource } from '@hrbr/runtime-planner/source-prep'

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

export type DefineJobCompileTiming = {
  readonly validate_ms: number
  readonly schema_normalize_ms: number
  readonly total_ms: number
}

export type DefineJobCompileResult = {
  readonly publish: Omit<OrbitJobPublishBody, 'workspace_id'>
  readonly timing: DefineJobCompileTiming
}

const DEFINE_JOB_PATTERN = /\bdefineJob\s*\(/
const JOB_NAME_PATTERN = /^[a-z][a-z0-9-]{0,127}$/
const CAMEL_JOB_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9]{0,127}$/
const SCALAR_TYPES = new Set(['string', 'number', 'boolean', 'unknown'])
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'

export class OrbitAuthoringValidationError extends Error {
  constructor(public readonly issues: readonly string[]) {
    super(issues.join('; '))
    this.name = 'OrbitAuthoringValidationError'
  }
}

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): ReadonlyMap<string, AcornNode> => {
  if (node.type !== 'ObjectExpression') issue('defineJob must receive an object literal.')
  const map = new Map<string, AcornNode>()
  for (const prop of (node.properties as ReadonlyArray<AcornNode> | undefined) ?? []) {
    if (prop.type === 'SpreadElement') issue('defineJob does not support spread properties in v1.')
    const maybeKey = propName(prop.key as AcornNode)
    const key =
      maybeKey || issue('defineJob 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): unknown => {
  if (node.type === 'Literal') return node.value
  if (node.type === 'ObjectExpression') {
    const out: Record<string, unknown> = {}
    for (const [key, value] of objectProperties(node)) out[key] = literalValue(value)
    return out
  }
  if (node.type === 'ArrayExpression') {
    return ((node.elements as ReadonlyArray<AcornNode | null> | undefined) ?? []).map((item) =>
      item ? literalValue(item) : null
    )
  }
  issue('defineJob schema and metadata must use static literals in v1.')
}

const scalarSchema = (
  raw: string,
  path: string
): { schema: OrbitJsonSchema; optional: boolean } => {
  const optional = raw.endsWith('?')
  const type = optional ? raw.slice(0, -1) : raw
  if (!SCALAR_TYPES.has(type)) {
    issue(
      `${path} uses unsupported schema shortcut "${raw}". Use string, number, boolean, unknown, optional ? suffix, nested objects, or full JSON Schema.`
    )
  }
  return {
    optional,
    schema: type === 'unknown' ? {} : ({ type } as OrbitJsonSchema),
  }
}

const normalizeSchemaNode = (
  node: AcornNode,
  path: string
): { schema: OrbitJsonSchema; optional: boolean } => {
  if (node.type === 'Literal' && typeof node.value === 'string') {
    return scalarSchema(node.value, path)
  }
  if (node.type !== 'ObjectExpression') {
    issue(`${path} must be a schema literal object, scalar shortcut, or full JSON Schema object.`)
  }
  const props = objectProperties(node)
  const typeProp = props.get('type')
  if (typeProp) {
    const full = literalValue(node)
    if (!full || typeof full !== 'object' || Array.isArray(full)) {
      issue(`${path} full JSON Schema must be an object.`)
    }
    return { schema: full as OrbitJsonSchema, optional: false }
  }

  const properties: Record<string, OrbitJsonSchema> = {}
  const required: string[] = []
  for (const [key, child] of props) {
    const normalized = normalizeSchemaNode(child, `${path}.${key}`)
    properties[key] = normalized.schema
    if (!normalized.optional) required.push(key)
  }
  return {
    optional: false,
    schema: {
      type: 'object',
      properties,
      required,
      additionalProperties: false,
    },
  }
}

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

const optionalString = (props: ReadonlyMap<string, AcornNode>, key: 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(`defineJob.${key} must be a string literal when provided.`)
  }
  return value as string
}

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

const optionalStringArray = (
  props: ReadonlyMap<string, AcornNode>,
  key: string
): string[] | undefined => {
  const node = props.get(key)
  if (!node) return undefined
  if (node.type !== 'ArrayExpression') issue(`defineJob.${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(`defineJob.${key} must contain only string literals.`)
    }
    return value as string
  })
}

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

const optionalTriggerManifest = (
  props: ReadonlyMap<string, AcornNode>
): OrbitJobPublishBody['trigger_manifest'] | undefined => {
  const node = props.get('triggers')
  if (!node) return undefined
  const value = literalValue(node)
  if (!Array.isArray(value)) issue('defineJob.triggers must be an array literal when provided.')
  try {
    return Schema.decodeUnknownSync(TriggerableJobManifest)({ version: 1, events: value })
  } catch {
    issue('defineJob.triggers must match the TriggerableJobManifest event binding contract.')
  }
}

const toCanonicalJobName = (raw: string): string => {
  if (JOB_NAME_PATTERN.test(raw)) return raw
  if (!CAMEL_JOB_NAME_PATTERN.test(raw)) {
    issue('defineJob.name 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 (!JOB_NAME_PATTERN.test(kebab))
    issue('defineJob.name could not be canonicalized to kebab-case.')
  return kebab
}

const inferCapabilities = (code: string): OrbitJobCapability[] => {
  const caps = new Set<OrbitJobCapability>()
  if (/\bhrbr\s*\.\s*storage\b/.test(code) || /\borbit\s*\.\s*storage\b/.test(code)) {
    caps.add('storage')
  }
  if (/\bhrbr\s*\.\s*cache\b/.test(code) || /\borbit\s*\.\s*cache\b/.test(code)) {
    caps.add('memory')
  }
  if (/\bhrbr\s*\.\s*db\b/.test(code) || /\borbit\s*\.\s*db\b/.test(code)) {
    caps.add('data')
  }
  if (/\bhrbr\s*\.\s*ai\b/.test(code) || /\borbit\s*\.\s*ai\b/.test(code)) {
    caps.add('ai')
  }
  if (/\bhrbr\s*\.\s*tools\b/.test(code) || /\bplugins\s*\.\s*call\b/.test(code)) {
    caps.add('plugins')
  }
  return [...caps]
}

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

export const looksLikeDefineJobSource = (source: string): boolean =>
  DEFINE_JOB_PATTERN.test(stripStringsAndComments(source))

export function compileDefineJobPublish(source: string): DefineJobCompileResult {
  const totalStarted = Date.now()
  const validateStarted = Date.now()
  if (!looksLikeDefineJobSource(source)) issue('Expected defineJob({...}) in job source.')
  const strippedSource = stripStringsAndComments(source)
  if (
    /(^|[;\n])\s*import(?:\s|\{|\*|[\w$])/.test(strippedSource) ||
    /\bimport\s*\(/.test(strippedSource)
  ) {
    issue('defineJob authoring does not support imports in v1.')
  }

  const transformed = prepareRuntimeSource(source, 'defineJob').code
  const ast = parse(transformed, {
    ecmaVersion: 'latest',
    sourceType: 'module',
    allowAwaitOutsideFunction: true,
    allowReturnOutsideFunction: true,
  }) as unknown as AcornNode
  const call = findTopLevelDefineJob(ast)
  const args = (call.arguments as ReadonlyArray<AcornNode> | undefined) ?? []
  if (args.length !== 1) issue('defineJob expects exactly one object literal argument.')
  const props = objectProperties(args[0]!)
  const name = toCanonicalJobName(requireString(props, 'name'))
  const run = props.get('run')
  if (!run || !['FunctionExpression', 'ArrowFunctionExpression'].includes(run.type)) {
    issue('defineJob.run must be an inline function or async method.')
  }
  const input = props.get('input') ?? issue('defineJob.input is required.')
  const validateMs = Date.now() - validateStarted

  const schemaStarted = Date.now()
  const inputSchema = normalizeSchemaNode(input, 'defineJob.input').schema
  const outputSchemaNode = props.get('output')
  const outputSchema = outputSchemaNode
    ? normalizeSchemaNode(outputSchemaNode, 'defineJob.output').schema
    : undefined
  const schemaNormalizeMs = Date.now() - schemaStarted

  const description = optionalString(props, 'description')
  const tags = optionalStringArray(props, 'tags')
  const timeoutMs = optionalNumber(props, 'timeout_ms')
  const retry = optionalLiteralObject(props, 'retry') as OrbitJobPublishBody['retry'] | undefined
  const retention = optionalLiteralObject(props, 'retention') as
    | OrbitJobPublishBody['retention']
    | undefined
  const triggerManifest = optionalTriggerManifest(props)

  return {
    timing: {
      validate_ms: validateMs,
      schema_normalize_ms: schemaNormalizeMs,
      total_ms: Date.now() - totalStarted,
    },
    publish: {
      name,
      input_schema: inputSchema,
      ...(description !== undefined ? { description } : {}),
      ...(outputSchema ? { output_schema: outputSchema } : {}),
      capabilities: inferCapabilities(transformed),
      ...(tags !== undefined ? { tags } : {}),
      ...(timeoutMs !== undefined ? { timeout_ms: timeoutMs } : {}),
      ...(retry !== undefined ? { retry } : {}),
      ...(retention !== undefined ? { retention } : {}),
      ...(triggerManifest !== undefined ? { trigger_manifest: triggerManifest } : {}),
      code: transformed,
      runtime: 'define_job',
    },
  }
}
