# encoding: UTF-8
#
# RDC 提案搭子 - SketchUp 扩展极小入口（T18 fw-2 + fw-5 健壮性增强）
#
# 职责：
#   1. 应用 staging 中的待生效更新（必须先于 register_extension）
#      - ~/rdc_data/rdc_hub_staging/hub/    → plugins/rdc_main/
#      - ~/rdc_data/rdc_hub_staging/modules/<id>/ → plugins/rdc_main/modules/<id>/
#   2. 注册 SketchupExtension 指向 main/boot.rb
#
# 设计要点（fw-5 健壮性）：
#   - 极小入口：不加密、不依赖 main/ 内部模块（main/ 自身可能在 staging 中被更新）
#   - apply 前完整性校验：hub 必须有 main/boot.rb；module 必须有 .rb 或 manifest.json
#   - apply 前自动备份当前 hub 到 backup/<timestamp>/，仅保留最近 3 份
#   - apply 失败立即从备份回滚（保护当前可工作版本）
#   - 校验失败的 staging 不删源（留待下次重试），日志记录失败原因
#   - 结构化日志写入 ~/rdc_data/rdc_hub_apply_log.json，供 boot.rb 上报 UI
#   - 任何错误都用 warn 记录，绝不抛异常阻塞 SU 启动

require 'sketchup.rb'
require 'extensions.rb'
require 'fileutils'
require 'json'

# ===== 路径计算（仅本文件作用域，不污染顶层命名空间）=====
_plugin_dir  = File.dirname(__FILE__)
_hub_dir     = File.join(_plugin_dir, 'rdc_main')

# 家目录探测（与 main/boot.rb 的 RDC_HOME_CANDIDATES 保持一致）：
# Windows 优先 USERPROFILE（系统登录会话下发，篡改不了），避免 ENV['HOME'] 被
# Git/MSYS 篡改后 ~ 展开跟错基准导致路径嵌套。
_home_candidates = []
if RUBY_PLATFORM =~ /mswin|mingw/
  _home_candidates << ENV['USERPROFILE']
  _drv_home = ENV['HOMEDRIVE'].to_s + ENV['HOMEPATH'].to_s
  _home_candidates << _drv_home unless _drv_home.empty?
end
_home_candidates << File.expand_path('~')
_home_dir    = _home_candidates.find { |_c| _c && !_c.empty? && Dir.exist?(_c) } || File.expand_path('~')
_data_dir    = File.join(_home_dir, 'rdc_data')
_staging_dir = File.join(_data_dir, 'rdc_hub_staging')
_backup_root = File.join(_data_dir, 'rdc_hub_backup')
_log_path    = File.join(_data_dir, 'rdc_hub_apply_log.json')
_max_backups = 3

# ===== fw-5：完整性校验 =====
_validate_hub = lambda do |src|
  return [false, '主入口缺失：main/boot.rb'] unless File.exist?(File.join(src, 'main', 'boot.rb'))
  [true, nil]
end

# ===== fw-5b：兼容 .rbz 标准结构（根目录 = <product>.rb + <product>/）
# 若 src 直接含 main/boot.rb，原样返回；
# 否则若恰有一个子目录里含 main/boot.rb，则返回该子目录（视为真实 hub 内容根）。
# 这样无论 updater 解压时是否拉平，apply 都能正确定位真实 hub 根。
_resolve_hub_src = lambda do |src|
  return src unless Dir.exist?(src)
  return src if File.exist?(File.join(src, 'main', 'boot.rb'))
  subs = (Dir.entries(src) - %w[. ..]).map { |e| File.join(src, e) }.select { |p| File.directory?(p) }
  hit = subs.find { |p| File.exist?(File.join(p, 'main', 'boot.rb')) }
  hit || src
end

_validate_module = lambda do |src|
  entries = (Dir.entries(src) - %w[. ..])
  return [false, '空目录'] if entries.empty?
  has_manifest = File.exist?(File.join(src, 'manifest.json'))
  # Windows 坑：Dir.glob 把反斜杠当转义符会静默返回 []，glob 前统一正斜杠
  has_rb       = !Dir.glob(File.join(src.to_s.tr('\\', '/'), '**', '*.rb')).empty?
  return [false, '缺少 .rb 入口或 manifest.json'] unless has_rb || has_manifest
  [true, nil]
end

# ===== fw-5：递归复制 src 树到 target =====
# Windows 坑：Dir.glob 把模式串里的反斜杠当转义符，静默返回 []；
# 家目录派生路径（USERPROFILE）带反斜杠，glob 前必须统一成正斜杠。
# 拷贝后计数兜底：源非空却 0 拷贝则 raise，杜绝"空应用 + 版本号假升"。
_copy_tree = lambda do |src, target|
  FileUtils.mkdir_p(target)
  src_glob = src.to_s.tr('\\', '/')
  copied = 0
  Dir.glob(File.join(src_glob, '**', '*')).each do |f|
    next if File.directory?(f)
    rel = f[(src_glob.length + 1)..-1]
    dst = File.join(target, rel)
    FileUtils.mkdir_p(File.dirname(dst))
    FileUtils.cp(f, dst)
    copied += 1
  end
  if copied.zero? && !(Dir.entries(src) - %w[. ..]).empty?
    raise "拷贝异常：源目录非空但拷贝 0 个文件 src=#{src}"
  end
  copied
end

# ===== fw-5：备份当前 hub_dir 到 backup_root/<timestamp>/，并清理超额备份 =====
_create_backup = lambda do
  return [nil, 'hub 目录不存在'] unless Dir.exist?(_hub_dir)
  ts = Time.now.strftime('%Y%m%d_%H%M%S')
  backup_path = File.join(_backup_root, ts)
  # 时间戳冲突时追加 _N
  i = 1
  while Dir.exist?(backup_path)
    backup_path = File.join(_backup_root, "#{ts}_#{i}")
    i += 1
  end
  begin
    _copy_tree.call(_hub_dir, backup_path)
    # 仅保留最近 _max_backups 份，按目录名（含时间戳）字典序倒排
    if Dir.exist?(_backup_root)
      all = (Dir.entries(_backup_root) - %w[. ..]).select { |x| File.directory?(File.join(_backup_root, x)) }.sort
      excess = all.size - _max_backups
      if excess > 0
        all.first(excess).each { |old| FileUtils.rm_rf(File.join(_backup_root, old)) }
      end
    end
    [backup_path, nil]
  rescue => e
    [nil, "备份失败：#{e.message}"]
  end
end

# ===== fw-5：从备份恢复 hub_dir =====
_restore_backup = lambda do |backup_path|
  return false unless backup_path && Dir.exist?(backup_path)
  begin
    FileUtils.rm_rf(_hub_dir) if Dir.exist?(_hub_dir)
    _copy_tree.call(backup_path, _hub_dir)
    true
  rescue => e
    warn "[RDC 提案搭子] 回滚失败：#{e.message}"
    false
  end
end

# ===== fw-5：写结构化 apply_log（覆盖最新一次结果）=====
_write_log = lambda do |entry|
  begin
    FileUtils.mkdir_p(_data_dir)
    File.open(_log_path, 'w:UTF-8') { |f| f.write(JSON.pretty_generate(entry)) }
  rescue => e
    warn "[RDC 提案搭子] 写 apply_log 失败：#{e.message}"
  end
end

# ===== fw-5：apply 一个 staging（hub 或 module），返回结果哈希 =====
# kind = :hub | :module
# src  = staging/hub  或 staging/modules/<id>
# dst  = hub_dir       或 hub_dir/modules/<id>
_apply_one = lambda do |kind, src, dst, validator|
  return { 'applied' => false, 'reason' => '源目录不存在', 'skipped' => true } unless Dir.exist?(src)
  entries = (Dir.entries(src) - %w[. ..])
  return { 'applied' => false, 'reason' => '源目录为空', 'skipped' => true } if entries.empty?

  ok, reason = validator.call(src)
  unless ok
    warn "[RDC 提案搭子] staging #{kind} 校验失败：#{reason}（保留源以便重试）"
    return { 'applied' => false, 'reason' => "校验失败：#{reason}" }
  end

  backup_path = nil
  if kind == :hub
    backup_path, err = _create_backup.call
    if err
      warn "[RDC 提案搭子] hub 备份失败：#{err}（中止 apply）"
      return { 'applied' => false, 'reason' => err }
    end
  end

  begin
    FileUtils.rm_rf(dst) if Dir.exist?(dst) && kind == :module
    _copy_tree.call(src, dst)
    FileUtils.rm_rf(src)
    result = { 'applied' => true }
    result['backup'] = backup_path if backup_path
    result
  rescue => e
    warn "[RDC 提案搭子] staging #{kind} 应用失败：#{e.message}"
    if kind == :hub && backup_path
      restored = _restore_backup.call(backup_path)
      return { 'applied' => false, 'reason' => "应用失败：#{e.message}", 'rolled_back' => restored, 'backup' => backup_path }
    end
    { 'applied' => false, 'reason' => "应用失败：#{e.message}" }
  end
end

# ===== 版本提取工具（用于 staging 诊断日志）=====
# 优先从 VERSION.txt 读取（staging apply 后写入），其次从 boot.rb 正则提取
_extract_version = lambda do |boot_path|
  # 优先检查同目录下的 VERSION.txt
  if boot_path && File.exist?(boot_path)
    ver_txt = File.join(File.dirname(boot_path), '..', 'VERSION.txt')
    return File.read(ver_txt, mode: 'r:UTF-8').strip if File.exist?(ver_txt)
  end
  return '?' unless boot_path && File.exist?(boot_path)
  File.read(boot_path, mode: 'r:UTF-8')[/VERSION\s*=\s*'([^']+)'/, 1] || '?'
rescue => _e
  '?'
end

# ===== 入口文件版本回写（staging apply 成功后同步 _ext.version）=====
# 解决：扩展管理器永远显示首次安装版本号
# 原理：本文件 (rdc_main.rb) 在 staging/ 之外，staging apply 不会替换它，
#       所以 _ext.version 永远是首次安装时写入的值。
#       此处在 hub staging 成功后，从新 boot.rb 提取版本号，回写到本文件。
_entry_file = File.join(_plugin_dir, 'rdc_main.rb')
_sync_entry_version = lambda do |new_version|
  return unless File.exist?(_entry_file)
  content = File.read(_entry_file, mode: 'r:UTF-8')
  new_content = content.sub(/(_ext\.version\s*=\s*')[^']*(')/, "\\1#{new_version}\\2")
  if content != new_content
    File.write(_entry_file, new_content, mode: 'w:UTF-8')
    warn "[RDC 提案搭子] 入口文件版本已同步：→ #{new_version}"
  end
rescue => e
  warn "[RDC 提案搭子] 入口文件版本同步失败：#{e.message}"
end

# ===== fw-5：执行整个 staging apply 流程 =====
_run_apply = lambda do
  log_entry = {
    'timestamp' => Time.now.strftime('%Y-%m-%dT%H:%M:%S%z'),
    'hub'       => nil,
    'plugins'   => []
  }

  # 1) hub 优先（子插件可能依赖新版 hub）
  hub_src_root = File.join(_staging_dir, 'hub')
  hub_src      = _resolve_hub_src.call(hub_src_root)  # fw-5b：兼容 .rbz 嵌套结构
  hub_dst      = _hub_dir
  hub_result   = _apply_one.call(:hub, hub_src, hub_dst, _validate_hub)

  # 诊断：记录 staging 和 apply 后的 boot.rb 版本号
  if hub_result && !hub_result['skipped']
    src_boot  = File.join(hub_src, 'main', 'boot.rb')
    dst_boot  = File.join(hub_dst, 'main', 'boot.rb')
    src_ver   = _extract_version.call(src_boot)
    dst_ver   = _extract_version.call(dst_boot)
    hub_result['src_version']  = src_ver
    hub_result['dst_version']  = dst_ver
    if hub_result['applied'] && src_ver != '?' && src_ver != dst_ver
      warn "[RDC 提案搭子] ⚠ staging apply 后版本未更新！src=#{src_ver} dst=#{dst_ver}"
    end
    log_entry['hub'] = hub_result
  end

  # 1.5) 入口文件版本同步：staging apply 成功后，把新版本号回写到本文件的 _ext.version
  #      解决"扩展管理器永远显示首次安装版本号"的问题
  #      VERSION.txt 由 hub_updater 写入 staging 根目录，此处先复制到 hub 目录再读取
  if hub_result && hub_result['applied']
    staging_ver_txt = File.join(_staging_dir, 'VERSION.txt')
    if File.exist?(staging_ver_txt)
      new_ver = File.read(staging_ver_txt, mode: 'r:UTF-8').strip rescue nil
      if new_ver && !new_ver.empty?
        # 复制到 hub 目录（便于下次 _extract_version 读取）
        hub_ver_txt = File.join(hub_dst, 'VERSION.txt')
        File.write(hub_ver_txt, new_ver, mode: 'w:UTF-8') rescue nil
        # 同步入口文件的 _ext.version
        _sync_entry_version.call(new_ver)
        hub_result['src_version'] = new_ver
      end
    end
  end

  # 2) modules
  modules_root = File.join(_staging_dir, 'modules')
  if Dir.exist?(modules_root)
    ids = (Dir.entries(modules_root) - %w[. ..]).select { |x| File.directory?(File.join(modules_root, x)) }.sort
    ids.each do |id|
      next unless id =~ /\A[a-z][a-z0-9_]{1,63}\z/
      src = File.join(modules_root, id)
      dst = File.join(_hub_dir, 'modules', id)
      r = _apply_one.call(:module, src, dst, _validate_module)
      next if r['skipped']
      r['id'] = id
      log_entry['plugins'] << r
    end
    # 清理 modules 顶层（如果已经全部处理完且为空）
    begin
      remaining = (Dir.entries(modules_root) - %w[. ..])
      FileUtils.rm_rf(modules_root) if remaining.empty?
    rescue
    end
  end

  # 3) 写日志（仅当有任何 staging 处理过时）
  if log_entry['hub'] || !log_entry['plugins'].empty?
    _write_log.call(log_entry)
    failed = (log_entry['hub'] && !log_entry['hub']['applied']) || log_entry['plugins'].any? { |p| !p['applied'] }
    if failed
      warn "[RDC 提案搭子] staging apply 存在失败项，详见 #{_log_path}"
    else
      warn "[RDC 提案搭子] staging apply 全部完成"
    end
  end

  # 4) staging 顶层清理（仅当 hub/modules 都已不存在或为空）
  begin
    if Dir.exist?(_staging_dir)
      hub_left = Dir.exist?(File.join(_staging_dir, 'hub'))
      mod_left = Dir.exist?(File.join(_staging_dir, 'modules'))
      FileUtils.rm_rf(_staging_dir) unless hub_left || mod_left
    end
  rescue
  end
end

# ===== 执行 staging apply（任何异常都不阻塞 SU 启动）=====
begin
  _run_apply.call
rescue => e
  warn "[RDC 提案搭子] staging apply 顶层异常：#{e.message}"
  warn e.backtrace.first(5).join("\n") if e.backtrace
end

# ===== 注册扩展 =====
# 注意：SketchupExtension 第二个参数必须是相对于 Plugins 目录的路径，不带 .rb
#       使用绝对路径或带 .rb 会导致扩展管理器无法正确跟踪启用/禁用状态
_boot_check = File.join(_hub_dir, 'main', 'boot.rb')
unless File.exist?(_boot_check)
  warn "[RDC 提案搭子] boot.rb 不存在：#{_boot_check}（扩展注册中止）"
  return
end

_ext = SketchupExtension.new('RDC提案搭子', 'rdc_main/main/boot')
_ext.version     = '1.2.2'
# staging apply 已同步入口文件，此处用磁盘实际值覆盖（首次安装时与上方硬编码一致）
_ext_ver = (File.read(_entry_file, mode: 'r:UTF-8')[/_ext\.version\s*=\s*'([^']+)'/, 1] rescue nil)
_ext.version = _ext_ver if _ext_ver
_ext.creator     = 'RDC住宅设计中心'
_ext.copyright   = "\u00a9 2026 RDC"
_ext.description = 'RDC提案搭子，SU互动提案建模，就这么简单！'
Sketchup.register_extension(_ext, true)
