import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import type { InstalledRecord } from '../../../domain/models/InstalledRecord'
import { WEB_MODE } from '../../../domain/models/StoreConfiguration'
import type { StoreFileSystem } from '../../files/StoreFileSystem'
import { escapeForXml, quoteForShell } from './ShellQuoting'

const RUNNER_MODE = 0o755
const PLIST_MODE = 0o644
const ICON_SIZE = '512'
const SIPS_TIMEOUT_MS = 60_000
const NAME_LIMIT = 255

/**
 * macOS: link the archive's own bundle, or wrap a bare binary in one.
 *
 * A LÖVE or Godot build already ships a signed `.app`; copying it again would double
 * the disk use and lose nothing but the icon, so it is symlinked instead. A TIC-80
 * export is a bare executable, and for that a four-file bundle is what makes it
 * double-clickable and Dock-able.
 */
export class MacBundleWriter {
  public constructor (
    private readonly storeId: string,
    private readonly files: StoreFileSystem,
    private readonly log: (line: string) => void
  ) {}

  public write (record: InstalledRecord, bundlePath: string, webUrl: string): string {
    replaceExisting(bundlePath)

    if (record.mode !== WEB_MODE && record.executableKind === 'bundle' && record.executable !== null) {
      fs.mkdirSync(path.dirname(bundlePath), { recursive: true })
      fs.symlinkSync(record.executable, bundlePath)
      return bundlePath
    }

    const contents = path.join(bundlePath, 'Contents')
    const macOsDirectory = path.join(contents, 'MacOS')
    const resources = path.join(contents, 'Resources')
    fs.mkdirSync(macOsDirectory, { recursive: true })
    fs.mkdirSync(resources, { recursive: true })

    this.files.writeAtomic(
      path.join(macOsDirectory, 'run'),
      Buffer.from(this.runnerScript(record, webUrl), 'utf8'),
      RUNNER_MODE
    )

    const iconWritten = this.writeIcon(record.icon, path.join(resources, 'icon.icns'))
    this.files.writeAtomic(
      path.join(contents, 'Info.plist'),
      Buffer.from(this.infoPlist(record, iconWritten), 'utf8'),
      PLIST_MODE
    )
    return bundlePath
  }

  private runnerScript (record: InstalledRecord, webUrl: string): string {
    if (record.mode === WEB_MODE || record.executable === null) {
      return `#!/bin/sh\nexec open ${quoteForShell(webUrl)}\n`
    }
    const directory = path.dirname(record.executable)
    const program = `./${path.basename(record.executable)}`
    return `#!/bin/sh\ncd ${quoteForShell(directory)} || exit 1\nexec ${quoteForShell(program)} "$@"\n`
  }

  /**
   * The bundle's `Info.plist`.
   *
   * Keys are emitted in alphabetical order because that is what wrote these files
   * before — `plistlib` sorts a dict — and a plist dict is unordered, so sorting costs
   * nothing and makes a bundle regenerated by either engine the same file.
   */
  private infoPlist (record: InstalledRecord, iconWritten: boolean): string {
    const version = record.version.length > 0 ? record.version : '1.0'
    const name = record.title.slice(0, NAME_LIMIT)
    const entries: readonly (readonly [string, string])[] = [
      ['CFBundleName', `<string>${escapeForXml(name)}</string>`],
      ['CFBundleDisplayName', `<string>${escapeForXml(name)}</string>`],
      ['CFBundleExecutable', '<string>run</string>'],
      ['CFBundleIdentifier', `<string>org.${this.storeId}.store.${record.name}</string>`],
      ['CFBundleInfoDictionaryVersion', '<string>6.0</string>'],
      ['CFBundlePackageType', '<string>APPL</string>'],
      ['CFBundleShortVersionString', `<string>${escapeForXml(version)}</string>`],
      ['CFBundleVersion', `<string>${escapeForXml(version)}</string>`],
      ['NSHighResolutionCapable', '<true/>'],
      ...(iconWritten ? [['CFBundleIconFile', '<string>icon</string>'] as const] : [])
    ]
    const body = [...entries]
      .sort((left: readonly [string, string], right: readonly [string, string]): number =>
        left[0] < right[0] ? -1 : 1)
      .map(([key, value]: readonly [string, string]): string => `\t<key>${key}</key>\n\t${value}`)
      .join('\n')
    return [
      '<?xml version="1.0" encoding="UTF-8"?>',
      '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
      '<plist version="1.0">',
      '<dict>',
      body,
      '</dict>',
      '</plist>',
      ''
    ].join('\n')
  }

  /**
   * Box art → `.icns` with `sips`, which needs a square source first.
   *
   * Without this a generated bundle gets the generic application icon. `sips` is part
   * of macOS, so there is nothing to install; if it fails we simply go without.
   */
  private writeIcon (iconPath: string | null, destination: string): boolean {
    if (iconPath === null || !fs.existsSync(iconPath)) return false
    const square = `${destination}.square.png`
    try {
      const steps: readonly (readonly string[])[] = [
        ['-z', ICON_SIZE, ICON_SIZE, iconPath, '--out', square],
        ['-s', 'format', 'icns', square, '--out', destination]
      ]
      for (const step of steps) {
        const result = spawnSync('sips', [...step], { timeout: SIPS_TIMEOUT_MS })
        if (result.status !== 0) {
          this.log('sips could not convert the box art — the bundle gets the generic icon')
          return false
        }
      }
      return fs.existsSync(destination)
    } catch {
      return false
    } finally {
      fs.rmSync(square, { force: true })
    }
  }
}

/** A bundle is a directory, so replacing one is not a plain overwrite. */
function replaceExisting (bundlePath: string): void {
  let stats: fs.Stats | null = null
  try {
    stats = fs.lstatSync(bundlePath)
  } catch {
    return
  }
  if (stats.isDirectory() && !stats.isSymbolicLink()) fs.rmSync(bundlePath, { recursive: true, force: true })
  else fs.rmSync(bundlePath, { force: true })
}
