const crypto = require('crypto')
const fs = require('fs/promises')
const path = require('path')

const NAVER_ARTICLE_URL = 'https://n.news.naver.com/article/'
const NAVER_WORLD_NEWS_URL = 'https://m.stock.naver.com/investment/news/worldnews/'

/**
 * NewsApiClient
 *
 * Fetches Naver Stock news and normalizes it to the existing NewsAPI-shaped
 * article contract consumed by the front-end compatibility routes.
 */
class NewsApiClient {
  constructor(apiBaseUrlOrOptions, _apiKey, _language, pageSize) {
    const options = typeof apiBaseUrlOrOptions === 'object'
      ? apiBaseUrlOrOptions
      : { pageSize }

    this._pageSize = options.pageSize || 20
    this._fetch = options.fetchImplementation || fetch
    this._imageStorage = options.imageStorage || new NewsImageStorage(options.dataDirectory)
    this._detailCache = options.detailCache || new NewsDetailCache(options.dataDirectory)
  }

  /**
   * @param {import('../config/CategoryCatalog').NewsCategory} category
   * @returns {Promise<object[]>}
   */
  async fetchArticlesByCategory(category) {
    const listUrl = this._createListUrl(category)
    const response = await this._fetch(listUrl)

    if (!response.ok) {
      throw new Error(`Naver news request failed with status ${response.status}`)
    }

    const payload = await response.json()
    const items = this._extractNewsItems(payload)

    const articles = []
    for (const item of items) {
      const article = await this._normalizeArticle(item, category)
      if (article) {
        articles.push(article)
      }
    }
    return articles
  }

  /**
   * @param {import('../config/CategoryCatalog').NewsCategory} category
   * @returns {string}
   */
  _createListUrl(category) {
    const url = new URL(category.apiUrl)
    url.searchParams.set('pageSize', String(this._pageSize))
    url.searchParams.set('page', '1')
    return url.toString()
  }

  /**
   * @param {object} payload
   * @returns {object[]}
   */
  _extractNewsItems(payload) {
    if (Array.isArray(payload)) {
      return payload
    }

    const directCandidates = [
      payload?.result?.newsList,
      payload?.result?.items,
      payload?.result?.list,
      payload?.result,
      payload?.items,
      payload?.newsList,
      payload?.list
    ]
    const direct = directCandidates.find((candidate) => Array.isArray(candidate))
    if (direct) {
      return direct
    }

    return this._findFirstNewsArray(payload) || []
  }

  /**
   * @param {unknown} value
   * @returns {object[] | null}
   */
  _findFirstNewsArray(value) {
    if (!value || typeof value !== 'object') {
      return null
    }
    if (Array.isArray(value)) {
      return value.some((item) => item && typeof item === 'object' && this._getSourceId(item))
        ? value
        : null
    }
    for (const child of Object.values(value)) {
      const found = this._findFirstNewsArray(child)
      if (found) {
        return found
      }
    }
    return null
  }

  /**
   * @param {object} item
   * @param {import('../config/CategoryCatalog').NewsCategory} category
   * @returns {Promise<object | null>}
   */
  async _normalizeArticle(item, category) {
    const sourceId = this._getSourceId(item)
    if (!sourceId) {
      return null
    }

    const detailUrl = this._createDetailUrl(category, sourceId)
    const content = await this._fetchArticleContent(detailUrl, category.worldNews)
    const title = this._firstString(item.titleFull, item.title, item.newsTitle, item.articleTitle, item.headline)
    const sourceName = this._firstString(item.officeName, item.pressName, item.providerName, item.sourceName)

    return {
      source: {
        id: sourceId.split('/')[0] || null,
        name: sourceName || null
      },
      author: this._firstString(item.author, item.writerName, item.reporter) || null,
      title,
      description: this._firstString(item.summary, item.description, item.body, item.subTitle) || '',
      url: detailUrl,
      urlToImage: this._firstString(item.imageUrl, item.thumbnailUrl, item.thumbnail, item.imageOriginLink) || '',
      publishedAt: normalizePublishedAt(this._firstString(item.datetime, item.dateTime, item.publishedAt, item.officeDateTime, item.regDate)) || null,
      content
    }
  }

  /**
   * @param {object} item
   * @returns {string}
   */
  _getSourceId(item) {
    const sourceId = this._firstString(item.sourceId, item.articleSourceId)
    if (sourceId) {
      return sourceId.replace(/^\/+/, '')
    }

    const officeId = this._firstString(item.officeId, item.pressId)
    const articleId = this._firstString(item.articleId, item.newsId)
    if (officeId && articleId) {
      return `${officeId}/${articleId}`
    }

    return ''
  }

  /**
   * @param {import('../config/CategoryCatalog').NewsCategory} category
   * @param {string} sourceId
   * @returns {string}
   */
  _createDetailUrl(category, sourceId) {
    const baseUrl = category.worldNews ? NAVER_WORLD_NEWS_URL : NAVER_ARTICLE_URL
    return `${baseUrl}${sourceId}`
  }

  /**
   * @param {string} detailUrl
   * @param {boolean} worldNews
   * @returns {Promise<string>}
   */
  async _fetchArticleContent(detailUrl, worldNews) {
    const cachedContent = await this._detailCache.get(detailUrl)
    if (cachedContent) {
      return cachedContent
    }

    let response
    try {
      response = await this._fetch(detailUrl)
    } catch (_error) {
      return ''
    }
    if (!response.ok) {
      return ''
    }
    const html = await response.text()
    const normalizedContent = await this._extractContentFromHtml(html, detailUrl, worldNews)
    if (normalizedContent) {
      await this._detailCache.set(detailUrl, normalizedContent)
    }
    return normalizedContent
  }

  /**
   * Extracts the article body from already-downloaded detail HTML and rewrites
   * its inline images to locally-proxied URLs.
   *
   * @param {string} html
   * @param {string} detailUrl
   * @param {boolean} worldNews
   * @returns {Promise<string>}
   */
  async _extractContentFromHtml(html, detailUrl, worldNews) {
    const content = worldNews
      ? this._extractElementHtmlById(html, 'content')
      : this._extractTagHtml(html, 'article')
    return this._replaceContentImages(content || '', detailUrl)
  }

  /**
   * Builds a normalized article object from a bare Naver article URL.
   *
   * Unlike {@link fetchArticlesByCategory}, no list payload is available, so the
   * article metadata (title, source, publish time, image) is parsed from the
   * detail page's Open Graph / meta tags. Used to pin a specific article.
   *
   * @param {string} articleUrl
   * @returns {Promise<object | null>}
   */
  async buildArticleFromUrl(articleUrl) {
    const sourceId = this._extractSourceIdFromUrl(articleUrl)
    if (!sourceId) {
      return null
    }

    const detailUrl = `${NAVER_ARTICLE_URL}${sourceId}`
    let response
    try {
      response = await this._fetch(detailUrl)
    } catch (_error) {
      return null
    }
    if (!response.ok) {
      return null
    }

    const html = await response.text()
    const content = await this._extractContentFromHtml(html, detailUrl, false)
    const title = this._extractMeta(html, 'og:title')
    if (!title) {
      return null
    }

    return {
      source: {
        id: sourceId.split('/')[0] || null,
        name: this._cleanSourceName(
          this._extractMeta(html, 'og:article:author')
          || this._extractMeta(html, 'twitter:creator')
          || this._extractMeta(html, 'og:site_name')
        ) || null
      },
      author: null,
      title,
      description: this._extractMeta(html, 'og:description') || this._extractMeta(html, 'description') || '',
      url: detailUrl,
      urlToImage: this._extractMeta(html, 'og:image') || '',
      publishedAt: normalizePublishedAt(this._extractArticleDateTime(html)) || null,
      content
    }
  }

  /**
   * Parses the `{officeId}/{articleId}` source id from any Naver article URL,
   * tolerating the `/article/`, `/mnews/article/` and query-string variants.
   *
   * @param {string} articleUrl
   * @returns {string}
   */
  _extractSourceIdFromUrl(articleUrl) {
    const match = String(articleUrl || '').match(/article\/(\d+)\/(\d+)/)
    return match ? `${match[1]}/${match[2]}` : ''
  }

  /**
   * Reads a `<meta>` content value by `property`/`name`, regardless of attribute
   * order, and decodes HTML entities.
   *
   * @param {string} html
   * @param {string} key
   * @returns {string}
   */
  _extractMeta(html, key) {
    const escapedKey = escapeRegExp(key)
    const patterns = [
      new RegExp(`<meta[^>]*(?:property|name)=["']${escapedKey}["'][^>]*content=["']([^"']*)["']`, 'i'),
      new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*(?:property|name)=["']${escapedKey}["']`, 'i')
    ]
    for (const pattern of patterns) {
      const match = html.match(pattern)
      if (match) {
        return decodeHtmlEntities(match[1].trim())
      }
    }
    return ''
  }

  /**
   * Extracts the article publish timestamp from meta tags or the Naver
   * `data-date-time` attribute on the article page.
   *
   * @param {string} html
   * @returns {string}
   */
  _extractArticleDateTime(html) {
    const metaDate = this._extractMeta(html, 'article:published_time')
    if (metaDate) {
      return metaDate
    }
    const attrMatch = html.match(/data-date-time=["']([^"']+)["']/i)
    return attrMatch ? attrMatch[1].trim() : ''
  }

  /**
   * Normalizes a Naver press name such as "매일경제 | 네이버" to just the press
   * name, matching the list-API `officeName` format.
   *
   * @param {string} name
   * @returns {string}
   */
  _cleanSourceName(name) {
    return String(name || '').split('|')[0].trim()
  }

  /**
   * @param {string} html
   * @param {string} tagName
   * @returns {string}
   */
  _extractTagHtml(html, tagName) {
    const expression = new RegExp(`<${tagName}\\b[^>]*>[\\s\\S]*?<\\/${tagName}>`, 'i')
    const match = html.match(expression)
    return match ? match[0] : ''
  }

  /**
   * @param {string} html
   * @param {string} id
   * @returns {string}
   */
  _extractElementHtmlById(html, id) {
    const startExpression = new RegExp(`<([a-z][\\w:-]*)\\b(?=[^>]*\\bid=["']${escapeRegExp(id)}["'])[^>]*>`, 'i')
    const startMatch = startExpression.exec(html)
    if (!startMatch) {
      return ''
    }

    const tagName = startMatch[1]
    const startIndex = startMatch.index
    const openTagEnd = startIndex + startMatch[0].length
    const tokenExpression = new RegExp(`<\\/?${escapeRegExp(tagName)}\\b[^>]*>`, 'ig')
    tokenExpression.lastIndex = openTagEnd
    let depth = 1
    let tokenMatch

    while ((tokenMatch = tokenExpression.exec(html)) !== null) {
      if (tokenMatch[0][1] === '/') {
        depth -= 1
        if (depth === 0) {
          return html.slice(startIndex, tokenExpression.lastIndex)
        }
      } else {
        depth += 1
      }
    }

    return ''
  }

  /**
   * @param {string} html
   * @returns {Promise<string>}
   */
  async _replaceContentImages(html, baseUrl) {
    const imageExpression = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/ig
    const replacements = []
    let match
    while ((match = imageExpression.exec(html)) !== null) {
      replacements.push({
        originalTag: match[0],
        originalUrl: match[1]
      })
    }

    let updatedHtml = html
    for (const replacement of replacements) {
      const localUrl = await this._downloadImage(replacement.originalUrl, baseUrl)
      if (localUrl) {
        updatedHtml = updatedHtml.replace(
          replacement.originalTag,
          replacement.originalTag.replace(replacement.originalUrl, localUrl)
        )
      }
    }
    return updatedHtml
  }

  /**
   * @param {string} imageUrl
   * @returns {Promise<string>}
   */
  async _downloadImage(imageUrl, baseUrl) {
    const absoluteImageUrl = toAbsoluteHttpUrl(imageUrl, baseUrl)
    if (!absoluteImageUrl) {
      return ''
    }

    let response
    try {
      response = await this._fetch(absoluteImageUrl)
    } catch (_error) {
      return ''
    }
    if (!response.ok) {
      return ''
    }
    const contentType = response.headers?.get?.('content-type') || 'application/octet-stream'
    const buffer = Buffer.from(await response.arrayBuffer())
    return this._imageStorage.saveImage(absoluteImageUrl, buffer, contentType)
  }

  /**
   * @param {...unknown} values
   * @returns {string}
   */
  _firstString(...values) {
    const value = values.find((item) => typeof item === 'string' && item.trim())
    return value ? value.trim() : ''
  }
}

class NewsImageStorage {
  constructor(dataDirectory) {
    this._imageDirectory = path.resolve(dataDirectory || './data', 'images')
  }

  /**
   * @param {string} imageUrl
   * @param {Buffer} buffer
   * @param {string} contentType
   * @returns {Promise<string>}
   */
  async saveImage(imageUrl, buffer, contentType) {
    await fs.mkdir(this._imageDirectory, { recursive: true })
    const extension = extensionFromContentType(contentType) || extensionFromUrl(imageUrl) || '.bin'
    const digest = crypto.createHash('sha256').update(imageUrl).digest('hex').slice(0, 24)
    const fileName = `${digest}${extension}`
    await fs.writeFile(path.join(this._imageDirectory, fileName), buffer)
    return `/api/news/images/${fileName}`
  }
}

class NewsDetailCache {
  constructor(dataDirectory) {
    this._cachePath = path.resolve(dataDirectory || './data', 'detail-cache.json')
    this._entries = null
  }

  /**
   * @param {string} detailUrl
   * @returns {Promise<string>}
   */
  async get(detailUrl) {
    const entries = await this._load()
    const entry = entries[detailUrl]
    return entry?.content || ''
  }

  /**
   * @param {string} detailUrl
   * @param {string} content
   * @returns {Promise<void>}
   */
  async set(detailUrl, content) {
    const entries = await this._load()
    entries[detailUrl] = {
      content,
      cachedAt: new Date().toISOString()
    }
    await fs.mkdir(path.dirname(this._cachePath), { recursive: true })
    await fs.writeFile(this._cachePath, `${JSON.stringify(entries, null, 2)}\n`)
  }

  /**
   * @returns {Promise<Record<string, {content: string, cachedAt: string}>>}
   */
  async _load() {
    if (this._entries) {
      return this._entries
    }

    try {
      const raw = await fs.readFile(this._cachePath, 'utf8')
      const parsed = JSON.parse(raw)
      this._entries = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
    } catch (error) {
      if (error.code !== 'ENOENT') {
        this._entries = {}
        return this._entries
      }
      this._entries = {}
    }
    return this._entries
  }
}

function extensionFromContentType(contentType) {
  const normalized = String(contentType || '').split(';')[0].trim().toLowerCase()
  const byContentType = {
    'image/jpeg': '.jpg',
    'image/jpg': '.jpg',
    'image/png': '.png',
    'image/gif': '.gif',
    'image/webp': '.webp',
    'image/svg+xml': '.svg'
  }
  return byContentType[normalized] || ''
}

function extensionFromUrl(imageUrl) {
  try {
    const extension = path.extname(new URL(imageUrl).pathname).toLowerCase()
    return extension.length <= 6 ? extension : ''
  } catch (_error) {
    return ''
  }
}

function toAbsoluteHttpUrl(value, baseUrl) {
  try {
    const url = new URL(value, baseUrl)
    return ['http:', 'https:'].includes(url.protocol) ? url.toString() : ''
  } catch (_error) {
    return ''
  }
}

function normalizePublishedAt(value) {
  if (!value) {
    return ''
  }
  if (/^\d{14}$/.test(value)) {
    const year = value.slice(0, 4)
    const month = value.slice(4, 6)
    const day = value.slice(6, 8)
    const hour = value.slice(8, 10)
    const minute = value.slice(10, 12)
    const second = value.slice(12, 14)
    return `${year}-${month}-${day}T${hour}:${minute}:${second}+09:00`
  }
  const spacedMatch = value.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})$/)
  if (spacedMatch) {
    return `${spacedMatch[1]}T${spacedMatch[2]}+09:00`
  }
  return value
}

function decodeHtmlEntities(value) {
  return String(value || '')
    .replace(/&#x([0-9a-f]+);/gi, (_match, hex) => safeFromCodePoint(parseInt(hex, 16)))
    .replace(/&#(\d+);/g, (_match, dec) => safeFromCodePoint(parseInt(dec, 10)))
    .replace(/&quot;/g, '"')
    .replace(/&apos;/g, "'")
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&nbsp;/g, ' ')
    .replace(/&amp;/g, '&')
}

function safeFromCodePoint(code) {
  try {
    return String.fromCodePoint(code)
  } catch (_error) {
    return ''
  }
}

function escapeRegExp(value) {
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

module.exports = {
  NewsApiClient,
  NewsImageStorage,
  NewsDetailCache
}
