import { EntryTree, isJsExpr, type EntryOptions } from '@cordisjs/plugin-loader'
import { Context, Service } from 'cordis'
import { extname } from 'node:path'
import { access, constants, readFile, rename, writeFile } from 'node:fs/promises'
import { fileURLToPath, pathToFileURL } from 'node:url'
import * as yaml from 'js-yaml'

const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
  kind: 'scalar',
  resolve: (data) => typeof data === 'string',
  construct: (data) => ({ __jsExpr: data }),
  predicate: isJsExpr,
  represent: (data) => data['__jsExpr'],
})

const schema = yaml.JSON_SCHEMA.extend(JsExpr)

const writable: Record<string, string> = {
  '.json': 'application/json',
  '.yaml': 'application/yaml',
  '.yml': 'application/yaml',
}

const supported = new Set(Object.keys(writable))

/** Runtime patch applied to entries loaded from an included config file. */
export interface PatchOptions {
  id?: string
  insert?: EntryOptions[]
  name?: string
  config?: any
  group?: boolean | null
  disabled?: boolean | null
  inject?: any
  intercept?: any
  isolate?: any
  [key: string]: any
}

/** Config namespace for the file-backed include loader. */
export namespace Include {
  /** Config for a file-backed loader subtree. */
  export interface Config {
    /** YAML or JSON path resolved from `ctx.baseUrl`. */
    path: string
    /** Entry list written when the file does not already exist. */
    initial?: any[]
    /** Runtime patches applied after reading the file. */
    patches?: PatchOptions[]
    /** Enables loader apply/reload/unload logs for this subtree. */
    enableLogs?: boolean
  }
}

/** Loader entry tree backed by a YAML or JSON file. */
export class Include extends EntryTree {
  static inject = ['loader']

  public filename: string
  private type?: string
  private readonly: boolean
  private content?: string
  private data?: EntryOptions[]
  private writeTask?: NodeJS.Timeout

  constructor(ctx: Context, public config: Include.Config) {
    super(ctx)
    this.enableLogs = config.enableLogs ?? ctx.fiber.entry?.parent.tree.enableLogs ?? false
    this.filename = fileURLToPath(new URL(this.config.path, this.ctx.baseUrl))
    const ext = extname(this.filename)
    if (!supported.has(ext)) {
      throw new Error(`extension "${ext}" not supported`)
    }
    this.type = writable[ext]
    this.readonly = !this.type
    this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href

    ctx.on('internal/update', (config, _, next) => {
      if (config.path !== this.config.path) return next()
      // Veto the fiber restart (children update in place), but persist the new
      // config ourselves — `Fiber.update` only assigns `this.config` behind
      // `next()`, and a stale `this.config.patches` would make the next
      // `refresh()` re-apply the old overlay.
      this.config = config
      this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => {
        this.ctx.logger.warn('config update at %C failed', this.filename)
        this.ctx.logger.warn(error)
      })
    })
  }

  private async checkAccess() {
    if (!this.type) return
    try {
      await access(this.filename, constants.W_OK)
    } catch {
      this.readonly = true
    }
  }

  private async read(forced = false) {
    const content = await readFile(this.filename, 'utf8')
    if (!forced && this.content === content) return false
    let data: any
    if (this.type === 'application/yaml') {
      data = yaml.load(content, { schema })
    } else if (this.type === 'application/json') {
      data = JSON.parse(content)
    } else {
      const module = await import(/* @vite-ignore */ this.filename)
      data = module.default || module
    }
    // An empty or truncated file (common mid-edit: editors and `sed -i` write
    // through temp states) parses to `undefined`, not an error; reject every
    // non-array shape here so callers see one "invalid file" signal. Content
    // and data commit only on success, so an edit that is later reverted to
    // the exact last good content correctly reads as "unchanged".
    if (!Array.isArray(data)) {
      throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`)
    }
    this.content = content
    this.data = data
    await this.checkAccess()
    return true
  }

  private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] {
    // Always detach from the cached parse: patching shared entry objects would
    // bake earlier patch values into `this.data`, so repeated application
    // (config hot-reloads) could never revert a removed or changed patch. The
    // supported extensions guarantee JSON-safe plain data, so `structuredClone`
    // cannot throw here.
    if (!patches?.length) return [...data]
    data = structuredClone(data)

    const entryMap = new Map<string, EntryOptions>()
    const buildMap = (entries: EntryOptions[]) => {
      for (const entry of entries) {
        if (entry.id) entryMap.set(entry.id, entry)
        if (entry.group && Array.isArray(entry.config)) {
          buildMap(entry.config)
        }
      }
    }
    buildMap(data)

    for (const patch of patches) {
      const { id, insert, name, ...overrides } = patch

      if (insert) {
        if (id) {
          const target = entryMap.get(id)
          if (!target) {
            this.ctx.root.logger?.('loader').warn('patch insert: entry %C not found', id)
            continue
          }
          if (!target.group) {
            this.ctx.root.logger?.('loader').warn('patch insert: entry %C is not a group', id)
            continue
          }
          if (!Array.isArray(target.config)) target.config = []
          target.config.push(...insert)
        } else {
          data.push(...insert)
        }
        continue
      }

      if (!id) {
        this.ctx.root.logger?.('loader').warn('patch: id is required for non-insert patches')
        continue
      }

      const target = entryMap.get(id)
      if (!target) {
        this.ctx.root.logger?.('loader').warn('patch: entry %C not found', id)
        continue
      }

      if (name && name !== target.name) {
        this.ctx.root.logger?.('loader').warn(
          'patch: name mismatch for %C (expected %C, got %C), skipping',
          id, target.name, name,
        )
        continue
      }

      for (const [key, value] of Object.entries(overrides)) {
        if (key === 'id') continue
        target[key] = value
      }
    }

    return data
  }

  async* [Service.init]() {
    try {
      await this.read()
    } catch (error) {
      // Only a missing file falls back to `initial` (or the not-found error):
      // an existing-but-invalid file must fail loud with its real parse error,
      // never be mislabelled as absent or silently overwritten.
      if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error
      if (this.config.initial) {
        this.writeFile(this.config.initial as any)
        await this.read()
      } else {
        throw new Error(`config file not found: ${this.filename}`)
      }
    }

    yield () => this.stop()
    await this.root.update(this.applyPatches(this.data!))
  }

  stop() {
    this.root.stop()
  }

  /**
   * Re-read the file and refresh child entries when content changed. An
   * unreadable or unparsable file logs a warning and keeps the last good
   * tree: a hot-reload of a live app must never take the process down.
   */
  async refresh() {
    try {
      if (!await this.read()) return
      await this.root.update(this.applyPatches(this.data!))
    } catch (error) {
      this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename)
      this.ctx.logger.warn(error)
    }
  }

  private async _writeFile(config: EntryOptions[]) {
    if (this.readonly) {
      throw new Error(`cannot overwrite readonly config`)
    }
    if (this.type === 'application/yaml') {
      this.content = yaml.dump(config, { schema })
    } else if (this.type === 'application/json') {
      this.content = JSON.stringify(config, null, 2)
    }
    await writeFile(this.filename + '.tmp', this.content!)
    await rename(this.filename + '.tmp', this.filename)
  }

  private writeFile(config: EntryOptions[]) {
    clearTimeout(this.writeTask)
    this.writeTask = setTimeout(() => {
      this.writeTask = undefined
      this._writeFile(config)
    }, 0)
  }

  /** Schedule a write of the current root entry data. */
  write() {
    this.context.emit('loader/config-update')
    return this.writeFile(this.root.data)
  }
}

export default Include
