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

/**
 * PinnedArticleService
 *
 * Builds and caches a fixed set of "pinned" articles that should always appear
 * at the top of the news list served to the front-end. Articles are built from
 * bare Naver URLs via the {@link NewsApiClient} and persisted to disk so the
 * (relatively expensive) detail-page fetch is not repeated on every request.
 */
class PinnedArticleService {
  /**
   * @param {import('./NewsApiClient').NewsApiClient} apiClient
   * @param {object} [options]
   * @param {string[]} [options.urls] - Naver article URLs to pin, in display order.
   * @param {string[] | null} [options.categoryKeys] - Category keys the pins apply to; null/empty means all.
   * @param {string} [options.dataDirectory] - Directory used for the persistent cache file.
   * @param {number} [options.ttlMs] - How long a built pin stays fresh before a rebuild is attempted.
   * @param {() => Date} [options.now] - Clock injection for testability.
   */
  constructor(apiClient, options = {}) {
    this._apiClient = apiClient
    this._urls = Array.isArray(options.urls) ? options.urls.filter(Boolean) : []
    this._categoryKeys = Array.isArray(options.categoryKeys) && options.categoryKeys.length > 0
      ? new Set(options.categoryKeys)
      : null
    this._ttlMs = Number.isFinite(options.ttlMs) && options.ttlMs > 0 ? options.ttlMs : 6 * 60 * 60 * 1000
    this._cachePath = path.resolve(options.dataDirectory || './data', 'pinned-cache.json')
    this._now = options.now || (() => new Date())
    this._entries = null
    this._buildPromise = null
  }

  /**
   * Returns the pinned articles applicable to a category, in display order.
   * Articles are (re)built lazily and cached; on build failure any previously
   * cached version is reused so the front-end never loses the pin.
   *
   * @param {string} categoryKey
   * @returns {Promise<object[]>}
   */
  async getPinnedArticles(categoryKey) {
    if (!this._appliesTo(categoryKey)) {
      return []
    }
    const articles = await this._resolveArticles()
    return articles.map((article) => ({ ...article }))
  }

  /**
   * @param {string} categoryKey
   * @returns {boolean}
   */
  _appliesTo(categoryKey) {
    if (this._urls.length === 0) {
      return false
    }
    return this._categoryKeys === null || this._categoryKeys.has(categoryKey)
  }

  /**
   * @returns {Promise<object[]>}
   */
  async _resolveArticles() {
    const entries = await this._load()
    if (!this._isStale(entries)) {
      return this._orderedArticles(entries)
    }

    if (!this._buildPromise) {
      this._buildPromise = this._rebuild(entries).finally(() => {
        this._buildPromise = null
      })
    }
    return this._buildPromise
  }

  /**
   * @param {Record<string, {article: object, builtAt: string}>} previousEntries
   * @returns {Promise<object[]>}
   */
  async _rebuild(previousEntries) {
    const nextEntries = {}
    for (const url of this._urls) {
      const article = await this._apiClient.buildArticleFromUrl(url)
      if (article) {
        nextEntries[url] = { article, builtAt: this._now().toISOString() }
      } else if (previousEntries[url]) {
        // Keep the stale-but-valid version rather than dropping the pin.
        nextEntries[url] = previousEntries[url]
      }
    }

    this._entries = nextEntries
    await this._persist(nextEntries)
    return this._orderedArticles(nextEntries)
  }

  /**
   * @param {Record<string, {article: object, builtAt: string}>} entries
   * @returns {object[]}
   */
  _orderedArticles(entries) {
    return this._urls
      .map((url) => entries[url]?.article)
      .filter(Boolean)
  }

  /**
   * Stale when any configured pin is missing a cached entry or its newest entry
   * has aged past the TTL.
   *
   * @param {Record<string, {article: object, builtAt: string}>} entries
   * @returns {boolean}
   */
  _isStale(entries) {
    const nowMs = this._now().getTime()
    for (const url of this._urls) {
      const entry = entries[url]
      if (!entry) {
        return true
      }
      const builtMs = Date.parse(entry.builtAt)
      if (!Number.isFinite(builtMs) || nowMs - builtMs > this._ttlMs) {
        return true
      }
    }
    return false
  }

  /**
   * @returns {Promise<Record<string, {article: object, builtAt: 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) {
      this._entries = {}
    }
    return this._entries
  }

  /**
   * @param {Record<string, {article: object, builtAt: string}>} entries
   * @returns {Promise<void>}
   */
  async _persist(entries) {
    try {
      await fs.mkdir(path.dirname(this._cachePath), { recursive: true })
      await fs.writeFile(this._cachePath, `${JSON.stringify(entries, null, 2)}\n`)
    } catch (_error) {
      // Persistence is a best-effort optimization; in-memory cache still serves.
    }
  }
}

module.exports = {
  PinnedArticleService
}
