import crypto from 'node:crypto'
import dns from 'node:dns/promises'
import net from 'node:net'
import { URL } from 'node:url'
import express from 'express'

const app = express()

const PORT = Math.max(1, Number(process.env.PORT || 8787) || 8787)
const PROXY_TOKEN = String(process.env.PROXY_TOKEN || '').trim()
const CONTOUR_API_BASE_URL = String(
  process.env.CONTOUR_API_BASE_URL || 'https://api-zakupki.kontur.ru/external/v1',
).trim().replace(/\/+$/, '')
const CONTOUR_API_KEY = String(process.env.CONTOUR_API_KEY || '').trim()
const REQUEST_TIMEOUT_MS = Math.max(1000, Number(process.env.REQUEST_TIMEOUT_MS || 30000) || 30000)
const MAX_REDIRECTS = Math.max(0, Number(process.env.MAX_REDIRECTS || 5) || 5)
const MAX_JSON_RESPONSE_BYTES = Math.max(
  1024 * 64,
  Number(process.env.MAX_JSON_RESPONSE_BYTES || 10 * 1024 * 1024) || (10 * 1024 * 1024),
)
const MAX_FETCH_BYTES = Math.max(
  1024 * 256,
  Number(process.env.MAX_FETCH_BYTES || 50 * 1024 * 1024) || (50 * 1024 * 1024),
)
const MAX_CESIUM_FETCH_BYTES = Math.max(
  1024 * 256,
  Number(process.env.MAX_CESIUM_FETCH_BYTES || 128 * 1024 * 1024) || (128 * 1024 * 1024),
)
const BLOCK_PRIVATE_NETWORKS = String(process.env.BLOCK_PRIVATE_NETWORKS || '1').trim() !== '0'
const LOG_LEVEL = String(process.env.LOG_LEVEL || 'info').trim().toLowerCase() || 'info'
const DEBUG_FETCH = ['1', 'true', 'yes', 'on'].includes(String(process.env.DEBUG_FETCH || '').trim().toLowerCase())
// Map Gateway has a dedicated outbound route.  It is deliberately separate
// from /proxy/fetch: only the provider hosts required by the canonical Map
// Page can use it, and Cesium credentials never become generic proxy headers.
const CESIUM_EGRESS_HOSTS = new Set([
  'api.cesium.com',
  'assets.ion.cesium.com',
  'dev.virtualearth.net',
  'ecn.t0.tiles.virtualearth.net',
  'ecn.t1.tiles.virtualearth.net',
  'ecn.t2.tiles.virtualearth.net',
  'ecn.t3.tiles.virtualearth.net',
])
const CREDENTIAL_QUERY_KEYS = new Set([
  'access_token',
  'accesstoken',
  'iontoken',
  'token',
  'key',
  'apikey',
  'api_key',
  'signature',
  'sig',
])
const LOG_LEVEL_WEIGHT = {
  debug: 10,
  info: 20,
  warn: 30,
  error: 40,
}

app.set('trust proxy', true)
app.disable('x-powered-by')
app.use(express.json({ limit: '2mb', type: ['application/json', 'application/*+json'] }))

function shouldLog(level = 'info') {
  const wanted = LOG_LEVEL_WEIGHT[String(LOG_LEVEL || '').toLowerCase()] || LOG_LEVEL_WEIGHT.info
  const current = LOG_LEVEL_WEIGHT[String(level || '').toLowerCase()] || LOG_LEVEL_WEIGHT.info
  return current >= wanted
}

function log(level, message, fields = {}) {
  if (!shouldLog(level)) return
  const line = {
    ts: new Date().toISOString(),
    level,
    message,
    ...fields,
  }
  // eslint-disable-next-line no-console
  console.log(JSON.stringify(line))
}

function getRequestId(req) {
  const fromHeader = String(req.headers['x-request-id'] || '').trim()
  return fromHeader || crypto.randomUUID()
}

function sanitizeErrorMessage(error, fallback = 'proxy_request_failed') {
  const text = String(error?.message || '').trim()
  if (!text) return fallback
  return text.slice(0, 500)
}

function parseBoolean(raw, fallback = false) {
  const text = String(raw ?? '').trim().toLowerCase()
  if (!text) return fallback
  if (['1', 'true', 'yes', 'on'].includes(text)) return true
  if (['0', 'false', 'no', 'off'].includes(text)) return false
  return fallback
}

function isIpv4Private(address) {
  const parts = String(address || '').split('.').map((s) => Number(s))
  if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false
  const [a, b] = parts
  if (a === 10) return true
  if (a === 127) return true
  if (a === 0) return true
  if (a === 169 && b === 254) return true
  if (a === 172 && b >= 16 && b <= 31) return true
  if (a === 192 && b === 168) return true
  if (a === 100 && b >= 64 && b <= 127) return true
  if (a === 198 && (b === 18 || b === 19)) return true
  if (a >= 224) return true
  return false
}

function ipv6ToBigInt(address) {
  const input = String(address || '').toLowerCase()
  if (!input) return null
  const percent = input.indexOf('%')
  const src = percent >= 0 ? input.slice(0, percent) : input
  if (!src.includes(':')) return null

  const hasDouble = src.includes('::')
  if (hasDouble && src.indexOf('::') !== src.lastIndexOf('::')) return null

  const [headRaw, tailRaw] = hasDouble ? src.split('::') : [src, '']
  const head = headRaw ? headRaw.split(':').filter(Boolean) : []
  const tail = tailRaw ? tailRaw.split(':').filter(Boolean) : []
  const missing = 8 - (head.length + tail.length)
  if (missing < 0) return null

  const chunks = hasDouble
    ? [...head, ...new Array(missing).fill('0'), ...tail]
    : src.split(':')
  if (chunks.length !== 8) return null

  let out = 0n
  for (const chunk of chunks) {
    if (!/^[0-9a-f]{1,4}$/i.test(chunk)) return null
    const value = BigInt(parseInt(chunk, 16))
    out = (out << 16n) + value
  }
  return out
}

function isIpv6Private(address) {
  const normalized = String(address || '').toLowerCase()
  if (!normalized) return false
  if (normalized === '::1' || normalized === '::') return true
  const value = ipv6ToBigInt(normalized)
  if (value === null) return false

  // fc00::/7
  if ((value & 0xfe00_0000_0000_0000_0000_0000_0000_0000n) === 0xfc00_0000_0000_0000_0000_0000_0000_0000n) return true
  // fe80::/10
  if ((value & 0xffc0_0000_0000_0000_0000_0000_0000_0000n) === 0xfe80_0000_0000_0000_0000_0000_0000_0000n) return true
  // ff00::/8 multicast
  if ((value & 0xff00_0000_0000_0000_0000_0000_0000_0000n) === 0xff00_0000_0000_0000_0000_0000_0000_0000n) return true
  return false
}

function isPrivateAddress(address) {
  const kind = net.isIP(address)
  if (kind === 4) return isIpv4Private(address)
  if (kind === 6) return isIpv6Private(address)
  return false
}

async function assertUrlIsSafe(targetUrl) {
  let parsed = null
  try {
    parsed = new URL(String(targetUrl || ''))
  } catch {
    const err = new Error('invalid_target_url')
    err.statusCode = 400
    throw err
  }

  const protocol = String(parsed.protocol || '').toLowerCase()
  if (protocol !== 'http:' && protocol !== 'https:') {
    const err = new Error('unsupported_target_protocol')
    err.statusCode = 400
    throw err
  }

  const hostname = String(parsed.hostname || '').trim()
  if (!hostname) {
    const err = new Error('target_hostname_required')
    err.statusCode = 400
    throw err
  }

  if (!BLOCK_PRIVATE_NETWORKS) return parsed

  const lowerHost = hostname.toLowerCase()
  if (lowerHost === 'localhost' || lowerHost.endsWith('.localhost')) {
    const err = new Error('target_host_not_allowed')
    err.statusCode = 403
    throw err
  }

  const literalIpKind = net.isIP(hostname)
  if (literalIpKind && isPrivateAddress(hostname)) {
    const err = new Error('target_private_ip_not_allowed')
    err.statusCode = 403
    throw err
  }

  let resolved = []
  try {
    resolved = await dns.lookup(hostname, { all: true, verbatim: true })
  } catch {
    // If DNS cannot resolve now, let fetch handle it later.
    return parsed
  }

  for (const entry of resolved) {
    const address = String(entry?.address || '').trim()
    if (!address) continue
    if (isPrivateAddress(address)) {
      const err = new Error('target_resolves_to_private_network')
      err.statusCode = 403
      throw err
    }
  }

  return parsed
}

function assertCesiumEgressTarget(target) {
  const hostname = String(target?.hostname || '').trim().toLowerCase()
  if (CESIUM_EGRESS_HOSTS.has(hostname)) return target
  const err = new Error('cesium_egress_host_not_allowed')
  err.statusCode = 403
  throw err
}

function redactCredentialUrl(rawUrl) {
  try {
    const parsed = new URL(String(rawUrl || ''))
    for (const key of [...parsed.searchParams.keys()]) {
      if (CREDENTIAL_QUERY_KEYS.has(key.toLowerCase())) parsed.searchParams.set(key, '<redacted>')
    }
    return parsed.toString()
  } catch {
    return '[invalid-target-url]'
  }
}

function mapEgressReferer(rawReferer) {
  const value = String(rawReferer || '').trim()
  if (!value) return ''
  let parsed
  try {
    parsed = new URL(value)
  } catch {
    const err = new Error('invalid_map_egress_referer')
    err.statusCode = 400
    throw err
  }
  if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
    const err = new Error('invalid_map_egress_referer')
    err.statusCode = 400
    throw err
  }
  parsed.search = ''
  parsed.hash = ''
  return parsed.toString()
}

function mapEgressHeaders(req, target, requestId) {
  const authorization = String(req.headers['x-nodedc-cesium-authorization'] || '').trim()
  if (authorization && !/^Bearer\s+[A-Za-z0-9._~+/=-]{16,4096}$/i.test(authorization)) {
    const err = new Error('invalid_cesium_egress_authorization')
    err.statusCode = 400
    throw err
  }
  const range = String(req.headers.range || '').trim()
  if (range && !/^bytes=\d*-\d*(?:,\d*-\d*)*$/i.test(range)) {
    const err = new Error('invalid_map_egress_range')
    err.statusCode = 400
    throw err
  }
  const accept = String(req.headers.accept || 'application/json, application/octet-stream, image/*, */*;q=0.5').trim().slice(0, 512)
  const referer = mapEgressReferer(req.headers['x-nodedc-map-referer'])
  const headers = {
    accept: accept || '*/*',
    'user-agent': 'nodedc-map-egress/1.0',
    'x-request-id': requestId,
    ...(referer ? { referer } : {}),
    ...(range ? { range } : {}),
  }
  // The master Ion token may only be sent to the Ion API.  Redirects are
  // validated per hop and never carry it to an asset/Bing host.
  if (String(target?.hostname || '').toLowerCase() === 'api.cesium.com' && authorization) {
    headers.authorization = authorization
  }
  return headers
}

function getProxyTokenFromReq(req) {
  const fromHeader = String(req.headers['x-proxy-token'] || '').trim()
  if (fromHeader) return fromHeader
  const auth = String(req.headers.authorization || '').trim()
  if (!auth) return ''
  const m = /^Bearer\s+(.+)$/i.exec(auth)
  return m ? String(m[1] || '').trim() : ''
}

function setAccessLogExtra(req, fields = {}) {
  if (!req || typeof req !== 'object') return
  const extra = req.accessLogExtra && typeof req.accessLogExtra === 'object'
    ? req.accessLogExtra
    : {}
  req.accessLogExtra = {
    ...extra,
    ...(fields && typeof fields === 'object' && !Array.isArray(fields) ? fields : {}),
  }
}

function authMiddleware(req, res, next) {
  if (!PROXY_TOKEN) return next()
  const token = getProxyTokenFromReq(req)
  if (!token || token !== PROXY_TOKEN) {
    return res.status(401).json({
      ok: false,
      error: 'unauthorized',
      message: 'Invalid proxy token',
    })
  }
  return next()
}

app.use((req, res, next) => {
  const requestId = getRequestId(req)
  req.requestId = requestId
  res.setHeader('x-request-id', requestId)
  next()
})

app.use((req, res, next) => {
  const startedAt = process.hrtime.bigint()
  req.accessLogExtra = {}
  res.on('finish', () => {
    const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6
    const payload = {
      requestId: req.requestId || '',
      method: req.method,
      path: req.path,
      status: Number(res.statusCode || 0),
      durationMs: Math.round(durationMs * 1000) / 1000,
      ...(req.accessLogExtra && typeof req.accessLogExtra === 'object' ? req.accessLogExtra : {}),
    }
    const level = Number(res.statusCode || 0) >= 500
      ? 'error'
      : (Number(res.statusCode || 0) >= 400 ? 'warn' : 'info')
    log(level, 'proxy access', payload)
  })
  next()
})

app.use(authMiddleware)

app.get('/healthz', (req, res) => {
  res.json({
    ok: true,
    service: 'proxy-contur',
    time: new Date().toISOString(),
    mode: {
      blockPrivateNetworks: BLOCK_PRIVATE_NETWORKS,
      timeoutMs: REQUEST_TIMEOUT_MS,
      maxRedirects: MAX_REDIRECTS,
      maxFetchBytes: MAX_FETCH_BYTES,
      hasContourApiKey: Boolean(CONTOUR_API_KEY),
    },
  })
})

function pickApiKey(req) {
  if (CONTOUR_API_KEY) return CONTOUR_API_KEY
  const headerApiKey = String(req.headers['x-kontur-apikey'] || '').trim()
  if (headerApiKey) return headerApiKey
  const queryApiKey = String(req.query?.apiKey || req.query?.api_key || '').trim()
  if (queryApiKey) return queryApiKey
  const bodyApiKey = String(req.body?.apiKey || req.body?.api_key || '').trim()
  return bodyApiKey
}

async function fetchWithTimeout(url, {
  method = 'GET',
  headers = {},
  body = undefined,
  timeoutMs = REQUEST_TIMEOUT_MS,
  redirect = 'manual',
} = {}) {
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), Math.max(1000, timeoutMs))
  try {
    return await fetch(url, {
      method,
      headers,
      body,
      redirect,
      signal: controller.signal,
    })
  } finally {
    clearTimeout(timer)
  }
}

async function readResponseTextLimited(response, maxBytes = MAX_JSON_RESPONSE_BYTES) {
  if (!response?.body) return ''
  const reader = response.body.getReader()
  const chunks = []
  let total = 0
  try {
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      const buf = Buffer.from(value)
      total += buf.length
      if (total > maxBytes) {
        const err = new Error('upstream_response_too_large')
        err.statusCode = 413
        throw err
      }
      chunks.push(buf)
    }
  } finally {
    reader.releaseLock()
  }
  return Buffer.concat(chunks).toString('utf8')
}

function setJsonPassthroughHeaders(res, upstream) {
  const contentType = upstream.headers.get('content-type') || 'application/json; charset=utf-8'
  res.setHeader('content-type', contentType)
}

app.post('/proxy/contour/search', async (req, res) => {
  const requestId = req.requestId
  const apiKey = pickApiKey(req)
  if (!apiKey) {
    return res.status(400).json({
      ok: false,
      error: 'contour_apikey_required',
      message: 'X-Kontur-Apikey is required',
      requestId,
    })
  }

  const targetUrl = `${CONTOUR_API_BASE_URL}/search`
  const timeoutMs = Math.max(1000, Number(req.query?.timeoutMs || REQUEST_TIMEOUT_MS) || REQUEST_TIMEOUT_MS)
  try {
    await assertUrlIsSafe(targetUrl)
    const upstream = await fetchWithTimeout(targetUrl, {
      method: 'POST',
      timeoutMs,
      headers: {
        accept: 'application/json, text/plain, */*',
        'content-type': 'application/json',
        'x-kontur-apikey': apiKey,
        'x-request-id': requestId,
      },
      body: JSON.stringify(req.body && typeof req.body === 'object' ? req.body : {}),
    })
    const bodyText = await readResponseTextLimited(upstream)
    setAccessLogExtra(req, {
      route: 'proxy_contour_search',
      upstreamStatus: Number(upstream.status || 0),
      responseBytes: Buffer.byteLength(bodyText || '', 'utf8'),
    })
    setJsonPassthroughHeaders(res, upstream)
    return res.status(upstream.status).send(bodyText)
  } catch (error) {
    const statusCode = Number(error?.statusCode || 502) || 502
    log('error', 'proxy contour search failed', {
      requestId,
      statusCode,
      message: sanitizeErrorMessage(error),
    })
    return res.status(statusCode).json({
      ok: false,
      error: 'proxy_contour_search_failed',
      message: sanitizeErrorMessage(error),
      requestId,
    })
  }
})

app.get('/proxy/contour/purchases/:purchaseId', async (req, res) => {
  const requestId = req.requestId
  const purchaseId = String(req.params?.purchaseId || '').trim()
  if (!purchaseId) {
    return res.status(400).json({
      ok: false,
      error: 'purchase_id_required',
      message: 'purchaseId is required',
      requestId,
    })
  }
  const apiKey = pickApiKey(req)
  if (!apiKey) {
    return res.status(400).json({
      ok: false,
      error: 'contour_apikey_required',
      message: 'X-Kontur-Apikey is required',
      requestId,
    })
  }
  const targetUrl = `${CONTOUR_API_BASE_URL}/purchases/${encodeURIComponent(purchaseId)}`
  const timeoutMs = Math.max(1000, Number(req.query?.timeoutMs || REQUEST_TIMEOUT_MS) || REQUEST_TIMEOUT_MS)
  try {
    await assertUrlIsSafe(targetUrl)
    const upstream = await fetchWithTimeout(targetUrl, {
      method: 'GET',
      timeoutMs,
      headers: {
        accept: 'application/json, text/plain, */*',
        'x-kontur-apikey': apiKey,
        'x-request-id': requestId,
      },
    })
    const bodyText = await readResponseTextLimited(upstream)
    setAccessLogExtra(req, {
      route: 'proxy_contour_purchase',
      purchaseId,
      upstreamStatus: Number(upstream.status || 0),
      responseBytes: Buffer.byteLength(bodyText || '', 'utf8'),
    })
    setJsonPassthroughHeaders(res, upstream)
    return res.status(upstream.status).send(bodyText)
  } catch (error) {
    const statusCode = Number(error?.statusCode || 502) || 502
    log('error', 'proxy contour purchase failed', {
      requestId,
      statusCode,
      purchaseId,
      message: sanitizeErrorMessage(error),
    })
    return res.status(statusCode).json({
      ok: false,
      error: 'proxy_contour_purchase_failed',
      message: sanitizeErrorMessage(error),
      requestId,
    })
  }
})

app.get('/proxy/contour/limit-groups', async (req, res) => {
  const requestId = req.requestId
  const apiKey = pickApiKey(req)
  if (!apiKey) {
    return res.status(400).json({
      ok: false,
      error: 'contour_apikey_required',
      message: 'X-Kontur-Apikey is required',
      requestId,
    })
  }
  const targetUrl = `${CONTOUR_API_BASE_URL}/limitGroups`
  const timeoutMs = Math.max(1000, Number(req.query?.timeoutMs || REQUEST_TIMEOUT_MS) || REQUEST_TIMEOUT_MS)
  try {
    await assertUrlIsSafe(targetUrl)
    const upstream = await fetchWithTimeout(targetUrl, {
      method: 'GET',
      timeoutMs,
      headers: {
        accept: 'application/json, text/plain, */*',
        'x-kontur-apikey': apiKey,
        'x-request-id': requestId,
      },
    })
    const bodyText = await readResponseTextLimited(upstream)
    setAccessLogExtra(req, {
      route: 'proxy_contour_limit_groups',
      upstreamStatus: Number(upstream.status || 0),
      responseBytes: Buffer.byteLength(bodyText || '', 'utf8'),
    })
    setJsonPassthroughHeaders(res, upstream)
    return res.status(upstream.status).send(bodyText)
  } catch (error) {
    const statusCode = Number(error?.statusCode || 502) || 502
    log('error', 'proxy contour limit groups failed', {
      requestId,
      statusCode,
      message: sanitizeErrorMessage(error),
    })
    return res.status(statusCode).json({
      ok: false,
      error: 'proxy_contour_limit_groups_failed',
      message: sanitizeErrorMessage(error),
      requestId,
    })
  }
})

function copyFetchHeaders(res, upstreamHeaders, { omitContentLength = false } = {}) {
  const passHeaders = [
    'content-type',
    'content-length',
    'content-disposition',
    'cache-control',
    'etag',
    'last-modified',
    'accept-ranges',
  ]
  for (const name of passHeaders) {
    if (omitContentLength && name === 'content-length') continue
    const value = upstreamHeaders.get(name)
    if (!value) continue
    res.setHeader(name, value)
  }
}

async function fetchWithSafeRedirects(initialUrl, {
  timeoutMs = REQUEST_TIMEOUT_MS,
  maxRedirects = MAX_REDIRECTS,
  headers = {},
  headersForTarget = null,
  validateTarget = null,
  redactForLog = null,
  requestId = '',
} = {}) {
  let currentUrl = String(initialUrl || '')
  let redirects = 0

  while (true) {
    const parsed = await assertUrlIsSafe(currentUrl)
    if (typeof validateTarget === 'function') validateTarget(parsed)
    const requestHeaders = typeof headersForTarget === 'function'
      ? headersForTarget(parsed)
      : headers
    const upstream = await fetchWithTimeout(parsed.toString(), {
      method: 'GET',
      timeoutMs,
      redirect: 'manual',
      headers: requestHeaders,
    })
    const status = Number(upstream.status || 0)
    const location = String(upstream.headers.get('location') || '').trim()
    const isRedirect = status >= 300 && status < 400 && location
    if (!isRedirect) {
      return { upstream, finalUrl: parsed.toString(), redirects }
    }

    if (redirects >= maxRedirects) {
      const err = new Error('too_many_redirects')
      err.statusCode = 508
      throw err
    }
    let nextUrl = ''
    try {
      nextUrl = new URL(location, parsed.toString()).toString()
    } catch {
      const err = new Error('invalid_redirect_url')
      err.statusCode = 502
      throw err
    }
    if (DEBUG_FETCH) {
      const safe = typeof redactForLog === 'function' ? redactForLog : (value) => value
      log('debug', 'proxy fetch redirect', {
        requestId: String(requestId || ''),
        from: String(safe(parsed.toString())).slice(0, 512),
        to: String(safe(nextUrl)).slice(0, 512),
        status,
        redirects: redirects + 1,
      })
    }
    currentUrl = nextUrl
    redirects += 1
  }
}

function createFetchRoute({
  route,
  errorCode,
  buildHeaders,
  validateTarget = null,
  redactTarget = false,
  allowRequestTuning = true,
  omitContentLength = false,
  fixedMaxBytes = MAX_FETCH_BYTES,
}) {
  return async (req, res) => {
  const requestId = req.requestId
  const rawUrl = String(req.query?.url || '').trim()
  const safeTarget = redactTarget ? redactCredentialUrl(rawUrl) : rawUrl.slice(0, 400)
  if (!rawUrl) {
    return res.status(400).json({
      ok: false,
      error: 'url_required',
      message: 'query parameter "url" is required',
      requestId,
    })
  }

  const timeoutMs = allowRequestTuning
    ? Math.max(1000, Number(req.query?.timeoutMs || REQUEST_TIMEOUT_MS) || REQUEST_TIMEOUT_MS)
    : REQUEST_TIMEOUT_MS
  const maxBytes = allowRequestTuning
    ? Math.max(1024 * 64, Number(req.query?.maxBytes || MAX_FETCH_BYTES) || MAX_FETCH_BYTES)
    : fixedMaxBytes

  try {
    const { upstream, finalUrl, redirects } = await fetchWithSafeRedirects(rawUrl, {
      timeoutMs,
      maxRedirects: MAX_REDIRECTS,
      headersForTarget: (target) => buildHeaders(req, target, requestId),
      validateTarget,
      redactForLog: redactTarget ? redactCredentialUrl : null,
      requestId,
    })
    const safeFinalUrl = redactTarget ? redactCredentialUrl(finalUrl) : finalUrl
    const contentType = String(upstream.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()

    const contentLength = Number(upstream.headers.get('content-length') || 0)
    if (Number.isFinite(contentLength) && contentLength > 0 && contentLength > maxBytes) {
      setAccessLogExtra(req, {
        route,
        upstreamStatus: Number(upstream.status || 0),
        finalUrl: String(safeFinalUrl || '').slice(0, 512),
        redirects,
        contentType,
        bytes: contentLength,
        target: safeTarget,
        error: 'fetch_too_large',
      })
      return res.status(413).json({
        ok: false,
        error: 'fetch_too_large',
        message: 'Content-Length exceeds maxBytes',
        requestId,
        finalUrl: safeFinalUrl,
        contentLength,
        maxBytes,
      })
    }

    copyFetchHeaders(res, upstream.headers, { omitContentLength })
    res.setHeader('x-proxy-final-url', safeFinalUrl)
    res.setHeader('x-proxy-redirects', String(redirects))
    res.status(upstream.status)

    if (!upstream.body) {
      setAccessLogExtra(req, {
        route,
        upstreamStatus: Number(upstream.status || 0),
        finalUrl: String(safeFinalUrl || '').slice(0, 512),
        redirects,
        contentType,
        bytes: 0,
        target: safeTarget,
      })
      return res.end()
    }

    const reader = upstream.body.getReader()
    let total = 0
    const onClose = () => {
      reader.cancel().catch(() => {})
    }
    req.on('close', onClose)
    try {
      while (true) {
        const { done, value } = await reader.read()
        if (done) break
        const chunk = Buffer.from(value)
        total += chunk.length
        if (total > maxBytes) {
          setAccessLogExtra(req, {
            route,
            upstreamStatus: Number(upstream.status || 0),
            finalUrl: String(safeFinalUrl || '').slice(0, 512),
            redirects,
            contentType,
            bytes: total,
            target: safeTarget,
            error: 'fetch_too_large_stream',
          })
          return res.status(413).end()
        }
        if (!res.write(chunk)) {
          await new Promise((resolve) => res.once('drain', resolve))
        }
      }
      setAccessLogExtra(req, {
        route,
        upstreamStatus: Number(upstream.status || 0),
        finalUrl: String(safeFinalUrl || '').slice(0, 512),
        redirects,
        contentType,
        bytes: total,
        target: safeTarget,
      })
      return res.end()
    } finally {
      req.off('close', onClose)
      reader.releaseLock()
    }
  } catch (error) {
    const statusCode = Number(error?.statusCode || 502) || 502
    setAccessLogExtra(req, {
      route,
      target: safeTarget,
      error: sanitizeErrorMessage(error),
    })
    log('error', 'proxy fetch failed', {
      requestId,
      statusCode,
      message: sanitizeErrorMessage(error),
      target: safeTarget,
    })
    if (res.headersSent) return res.end()
    return res.status(statusCode).json({
      ok: false,
      error: errorCode,
      message: sanitizeErrorMessage(error),
      requestId,
    })
  }
  }
}

app.get('/proxy/fetch', createFetchRoute({
  route: 'proxy_fetch',
  errorCode: 'proxy_fetch_failed',
  buildHeaders: (req, _target, requestId) => {
    const includeApiKey = parseBoolean(req.query?.includeApiKey, true)
    const apiKey = includeApiKey ? pickApiKey(req) : ''
    return {
      accept: String(req.headers.accept || '*/*'),
      'user-agent': String(req.headers['user-agent'] || 'proxy-contur/0.1'),
      ...(apiKey ? { 'x-kontur-apikey': apiKey } : {}),
      'x-request-id': requestId,
    }
  },
}))

app.get('/proxy/cesium/fetch', createFetchRoute({
  route: 'proxy_cesium_fetch',
  errorCode: 'proxy_cesium_fetch_failed',
  buildHeaders: mapEgressHeaders,
  validateTarget: assertCesiumEgressTarget,
  redactTarget: true,
  allowRequestTuning: false,
  omitContentLength: true,
  fixedMaxBytes: MAX_CESIUM_FETCH_BYTES,
}))

app.use((req, res) => {
  res.status(404).json({
    ok: false,
    error: 'not_found',
    message: 'Route is not defined',
  })
})

app.use((error, req, res, _next) => {
  const requestId = req?.requestId || ''
  const statusCode = Number(error?.statusCode || 500) || 500
  log('error', 'unhandled error', {
    requestId,
    statusCode,
    message: sanitizeErrorMessage(error, 'internal_error'),
  })
  res.status(statusCode).json({
    ok: false,
    error: 'internal_error',
    message: sanitizeErrorMessage(error, 'internal_error'),
    requestId,
  })
})

app.listen(PORT, () => {
  log('info', 'proxy-contur started', {
    port: PORT,
    contourApiBaseUrl: CONTOUR_API_BASE_URL,
    blockPrivateNetworks: BLOCK_PRIVATE_NETWORKS,
    hasProxyToken: Boolean(PROXY_TOKEN),
    hasContourApiKey: Boolean(CONTOUR_API_KEY),
    logLevel: LOG_LEVEL,
    debugFetch: DEBUG_FETCH,
  })
})
