#!/usr/bin/env ruby
# frozen_string_literal: true

require 'net/http'
require 'uri'
require 'json'
require 'openssl'
require 'pathname'
require 'securerandom'
require 'digest'
require 'set'

# ---------------------
# Konfiguration
# ---------------------
PIWIGO_URL      = 'https://bilder.oelssner.net'
PIWIGO_USER     = 'po002'
PIWIGO_PASSWORD = ENV['PIWIGO_PASSWORD'] || 'Maluli#42'
BILDER_WURZEL   = '/srv/Bilder'
ROOT_ALBUM_ID   = 121
VERIFY_SSL      = true
DEBUG           = true
PLAN_ONLY       = false
PLAN_JSON       = 'piwigo_upload_plan.json'
# ---------------------

abort("Bitte Passwort setzen: export PIWIGO_PASSWORD='DEIN_PASSWORT'") if PIWIGO_PASSWORD.empty?

def log(msg) = puts msg
def debug(msg) = puts("[DEBUG] #{msg}") if DEBUG

# --- Fortschrittsanzeige (schreibt auf STDERR, überschreibt Zeile mit \r) ---
def format_duration(secs)
  return '0s' if secs.nil? || secs <= 0
  if secs < 60
    "#{secs.round}s"
  elsif secs < 3600
    "#{(secs / 60).floor}m#{(secs % 60).round}s"
  else
    h = (secs / 3600).floor
    m = ((secs % 3600) / 60).floor
    "#{h}h#{m}m"
  end
end

class ProgressTracker
  def initialize(total, label, every: 1)
    @total = total
    @label = label
    @every = every
    @start = Time.now
    @current = 0
    update if @total > 0
  end

  def tick
    @current += 1
    update if @total > 0 && (@current % @every == 0 || @current >= @total)
  end

  def done(summary)
    $stderr.puts "\r\e[K#{@label}: #{summary} (#{format_duration(Time.now - @start)})"
  end

  private

  def update
    pct = (@current * 100.0 / @total).round
    filled = (@current * 30 / @total).round
    bar = '█' * filled + '░' * (30 - filled)
    elapsed = Time.now - @start
    eta = @current > 0 ? (elapsed / @current * (@total - @current)) : 0
    $stderr.print "\r\e[K#{@label}: |#{bar}| #{pct}% (#{@current}/#{@total}) #{format_duration(elapsed)}/~#{format_duration(eta)}"
  end
end

def sanitize_album_name(name)
  s = name.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
  s.gsub!(/[\/\\:\*\?"<>\|\r\n\t]/, ' ')
  s.gsub!(/\s+/, ' ')
  s.strip!
  s.gsub!(/\A[\. ]+|[\. ]+\z/, '')
  s = 'Unbenannt' if s.empty?
  s[0, 80]
end

def sanitize_image_name(name)
  s = name.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
  s.gsub!(/[\r\n\t]/, ' ')
  s.gsub!(/\s+/, ' ')
  s.strip!
  s = 'bild' if s.empty?
  s[0, 180]
end

def image_file?(path)
  %w[.jpg .jpeg .png .gif .webp].include?(File.extname(path).downcase)
end

def ignored_dir_name?(name)
  return true if name.start_with?('.')
  ignored = %w[.git .svn .cache __MACOSX]
  ignored_suffixes = ['.lrdata', '.lrcat-data']
  ignored.include?(name) || ignored_suffixes.any? { |s| name.downcase.end_with?(s) }
end

def ignored_file_name?(name)
  %w[.DS_Store Thumbs.db].include?(name)
end

def category_parent_id(cat)
  return cat['parent_id'] if cat.key?('parent_id') && !cat['parent_id'].nil?
  return cat['id_parent'] if cat.key?('id_parent') && !cat['id_parent'].nil?
  return cat['parent'] if cat.key?('parent') && !cat['parent'].nil?

  uppercats = cat['uppercats']
  return nil if uppercats.nil? || uppercats.to_s.strip.empty?

  parts = uppercats.to_s.split(',').map(&:strip)
  return nil if parts.length < 2

  parts[-2]
end

def build_album_index(categories)
  index = {}
  categories.each do |cat|
    id = cat['id']
    name = cat['name'].to_s
    parent = category_parent_id(cat)
    next if id.nil? || name.empty?
    index[[parent.to_s, name]] = id
  end
  index
end

def file_sha256(path)
  Digest::SHA256.file(path).hexdigest
end

def split_name(filename)
  ext = File.extname(filename)
  base = File.basename(filename, ext)
  [base, ext]
end

def unique_name_for_album(desired_name, used_names, existing_infos_by_name, local_hash)
  return desired_name unless used_names.include?(desired_name) || existing_infos_by_name.key?(desired_name)

  existing = existing_infos_by_name[desired_name]
  if existing && existing[:sha256] == local_hash
    return nil # identisch vorhanden -> überspringen
  end

  base, ext = split_name(desired_name)
  n = 2
  loop do
    candidate = "#{base}__dup#{n}#{ext}"
    return candidate unless used_names.include?(candidate) || existing_infos_by_name.key?(candidate)
    existing2 = existing_infos_by_name[candidate]
    return nil if existing2 && existing2[:sha256] == local_hash
    n += 1
  end
end

class PiwigoClient
  def initialize(base_url, verify_ssl: true)
    @base_url = base_url.end_with?('/') ? base_url : "#{base_url}/"
    @verify_ssl = verify_ssl
    @cookie = nil
  end

  def login(username, password)
    res = post_form(
      'ws.php?format=json&method=pwg.session.login',
      { 'username' => username, 'password' => password }
    )
    store_cookie(res)
    json = parse_json(res.body)
    raise "Login fehlgeschlagen: #{res.body}" unless json['stat'] == 'ok' && json['result'] == true
  end

  def categories_list
    res = get('ws.php?format=json&method=pwg.categories.getList')
    json = parse_json(res.body)
    raise "Fehler pwg.categories.getList: #{res.body}" unless json['stat'] == 'ok'
    r = json['result']
    return r['categories'] if r.is_a?(Hash) && r['categories']
    return r if r.is_a?(Array)
    raise "Unerwartete Kategorien-Antwort: #{res.body}"
  end

  def add_category(name, parent_id)
    res = post_form(
      'ws.php?format=json&method=pwg.categories.add',
      { 'name' => name.to_s, 'parent' => parent_id.to_s }
    )
    json = parse_json(res.body)
    raise "Fehler pwg.categories.add(#{name}): #{res.body}" unless json['stat'] == 'ok'
    json
  end

  def upload_image(file_path, category_id, image_name)
    res = post_multipart(
      'ws.php?format=json&method=pwg.images.addSimple',
      { 'category' => category_id.to_s, 'name' => image_name.to_s },
      'image',
      file_path
    )
    json = parse_json(res.body)
    raise "Fehler beim Upload #{file_path}: #{res.body}" unless json['stat'] == 'ok'
    json
  end

  # Versucht vorhandene Bilder eines Albums zu lesen.
  # Je nach Piwigo-Version kann die API leicht variieren.
  def images_in_category(category_id)
    methods = [
      "ws.php?format=json&method=pwg.categories.getImages&cat_id=#{category_id}&per_page=100000",
      "ws.php?format=json&method=pwg.categories.getImages&category_id=#{category_id}&per_page=100000"
    ]

    methods.each do |path|
      begin
        res = get(path)
        json = parse_json(res.body)
        next unless json['stat'] == 'ok'
        r = json['result']
        if r.is_a?(Hash) && r['images']
          return r['images']
        elsif r.is_a?(Array)
          return r
        end
      rescue StandardError
        next
      end
    end

    []
  end

  private

  def parse_json(body)
    JSON.parse(body)
  rescue JSON::ParserError => e
    raise "Ungültige JSON-Antwort: #{e.message}\n#{body}"
  end

  def build_http(uri)
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = (uri.scheme == 'https')
    http.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
    http.read_timeout = 300
    http.open_timeout = 60
    http
  end

  def get(path)
    uri = URI.join(@base_url, path)
    req = Net::HTTP::Get.new(uri)
    req['Cookie'] = @cookie if @cookie
    build_http(uri).request(req)
  end

  def post_form(path, params)
    uri = URI.join(@base_url, path)
    req = Net::HTTP::Post.new(uri)
    req['Cookie'] = @cookie if @cookie
    req.set_form_data(params)
    build_http(uri).request(req)
  end

  def post_multipart(path, fields, file_field_name, file_path)
    uri = URI.join(@base_url, path)
    boundary = "----RubyMultipart#{SecureRandom.hex(16)}"

    body = +""
    fields.each do |k, v|
      body << "--#{boundary}\r\n"
      body << %(Content-Disposition: form-data; name="#{k}"\r\n\r\n)
      body << "#{v}\r\n"
    end

    filename = File.basename(file_path)
    mime = case File.extname(filename).downcase
           when '.jpg', '.jpeg' then 'image/jpeg'
           when '.png' then 'image/png'
           when '.gif' then 'image/gif'
           when '.webp' then 'image/webp'
           else 'application/octet-stream'
           end

    body << "--#{boundary}\r\n"
    body << %(Content-Disposition: form-data; name="#{file_field_name}"; filename="#{filename}"\r\n)
    body << "Content-Type: #{mime}\r\n\r\n"
    body << File.binread(file_path)
    body << "\r\n--#{boundary}--\r\n"

    req = Net::HTTP::Post.new(uri)
    req['Cookie'] = @cookie if @cookie
    req['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
    req.body = body

    build_http(uri).request(req)
  end

  def store_cookie(response)
    set_cookie = response.get_fields('set-cookie')
    return if set_cookie.nil? || set_cookie.empty?
    @cookie = set_cookie.map { |c| c.split(';').first }.join('; ')
  end
end

# ---------- Sammeln lokaler Struktur ----------
root = Pathname.new(BILDER_WURZEL)

dirs = Dir.glob(File.join(BILDER_WURZEL, '**', '*'), File::FNM_DOTMATCH)
          .select { |p| File.directory?(p) }
          .reject do |p|
            Pathname.new(p).each_filename.any? { |part| ignored_dir_name?(part) }
          end
          .sort

files = Dir.glob(File.join(BILDER_WURZEL, '**', '*'), File::FNM_DOTMATCH)
           .select { |p| File.file?(p) }
           .reject do |p|
             parts = Pathname.new(p).each_filename.to_a
             parts[0..-2].any? { |part| ignored_dir_name?(part) } || ignored_file_name?(File.basename(p))
           end
           .select { |p| image_file?(p) }
           .sort

log "Verzeichnisse: #{dirs.size}, Bilder: #{files.size}"

client = PiwigoClient.new(PIWIGO_URL, verify_ssl: VERIFY_SSL)
log 'Login bei Piwigo...'
client.login(PIWIGO_USER, PIWIGO_PASSWORD)
log 'Login erfolgreich.'

categories = client.categories_list
album_index = build_album_index(categories)
debug "Vorhandene Kategorien: #{categories.size}"

album_map = { '.' => ROOT_ALBUM_ID }
albums_to_create = []

# ---------- Phase 1: Plan für Alben bauen ----------
albums_new = 0
albums_reused = 0
pg = ProgressTracker.new(dirs.size, 'Alben planen', every: 10)
dirs.each do |dir|
  rel = Pathname.new(dir).relative_path_from(root).to_s
  parent_rel = File.dirname(rel)
  parent_rel = '.' if parent_rel == '.' || parent_rel == rel || parent_rel.empty?

  parent_id = album_map[parent_rel]
  if parent_id.nil?
    warn "Parent-Album unbekannt, überspringe Ordner: #{dir} (rel=#{rel}, parent_rel=#{parent_rel})"
    warn "Bekannte album_map-Keys: #{album_map.keys.sort.take(50).join(' | ')}"
    pg.tick
    next
  end

  raw_name = File.basename(dir)
  album_name = sanitize_album_name(raw_name)
  debug "Albumname bereinigt: #{raw_name.inspect} -> #{album_name.inspect}" if raw_name != album_name

  key = [parent_id.to_s, album_name]
  existing_id = album_index[key]

  if existing_id
    album_map[rel] = existing_id
    albums_reused += 1
  else
    temp_key = "NEW:#{parent_id}:#{album_name}"
    album_map[rel] = temp_key
    albums_to_create << { rel:, parent_rel:, parent_id:, album_name: }
    album_index[key] = temp_key
    albums_new += 1
  end
  pg.tick
end
pg.done("#{albums_new} neu, #{albums_reused} vorhanden (von #{dirs.size} Ordnern)")

# ---------- Prefetch: alle existierenden Remote-Alben laden ----------
remote_album_images = {}
planned_uploads = []
planned_skips = []
def load_remote_album_state(client, album_id, cache)
  # Verwende nil als Marker für "noch nicht abgefragt", leeres Hash für "abgefragt, aber leer"
  return cache[album_id] if cache.key?(album_id)

  debug "Lade Remote-Bilder für Album #{album_id}..."
  images = client.images_in_category(album_id)
  infos = {}

  images.each do |img|
    name = (img['name'] || img['file'] || img['filename'] || img['path'] || '').to_s
    next if name.empty?

    sha = img['sha256'] || img['md5sum'] || img['md5']
    infos[name] = {
      sha256: sha,
      raw: img
    }
  end

  debug "Album #{album_id}: #{infos.size} Remote-Bilder gefunden"
  cache[album_id] = infos
end

existing_ids = album_map.values.select { |v| v.is_a?(Integer) }.uniq
unless existing_ids.empty?
  p_pre = ProgressTracker.new(existing_ids.size, 'Alben prefetch', every: 1)
  existing_ids.each do |album_id|
    load_remote_album_state(client, album_id, remote_album_images)
    p_pre.tick
  end
  p_pre.done("#{existing_ids.size} Alben geladen")
end

# ---------- Phase 2: Zielbildnamen planen ----------
planned_names_per_album = Hash.new { |h, k| h[k] = Set.new }
pg2 = ProgressTracker.new(files.size, 'Bilder planen', every: 50)

files.each do |path|
  dir = File.dirname(path)
  rel_dir = Pathname.new(dir).relative_path_from(root).to_s
  rel_dir = '.' if rel_dir.nil? || rel_dir.empty? || rel_dir == '.'

  album_ref = album_map[rel_dir] || ROOT_ALBUM_ID
  raw_filename = File.basename(path)
  desired_name = sanitize_image_name(raw_filename)
  debug "Bildname bereinigt: #{raw_filename.inspect} -> #{desired_name.inspect}" if raw_filename != desired_name

  local_hash = file_sha256(path)

  # Daten sind nach Prefetch bereits gecached, load_remote_album_state gibt sofort zurück
  existing_infos =
    if album_ref.is_a?(Integer)
      load_remote_album_state(client, album_ref, remote_album_images)
    else
      {}
    end

  final_name = unique_name_for_album(
    desired_name,
    planned_names_per_album[album_ref],
    existing_infos,
    local_hash
  )

  if final_name.nil?
    planned_skips << {
      path:,
      album_ref:,
      reason: 'identischer Name und identischer Inhalt bereits vorhanden/geplant'
    }
    pg2.tick
    next
  end

  planned_names_per_album[album_ref] << final_name
  planned_uploads << {
    path:,
    rel_dir:,
    album_ref:,
    desired_name:,
    final_name:,
    sha256: local_hash
  }
  pg2.tick
end
pg2.done("#{planned_uploads.size} Uploads, #{planned_skips.size} Übersprungen (von #{files.size})")

# ---------- Plan speichern ----------
plan_doc = {
  root: BILDER_WURZEL,
  root_album_id: ROOT_ALBUM_ID,
  albums_to_create: albums_to_create,
  album_map: album_map.transform_keys(&:to_s),
  uploads: planned_uploads,
  skips: planned_skips
}

File.write(PLAN_JSON, JSON.pretty_generate(plan_doc))
log "Plan geschrieben: #{PLAN_JSON}"
log "Geplante Alben: #{albums_to_create.size}, Uploads: #{planned_uploads.size}, Skips: #{planned_skips.size}"

if PLAN_ONLY
  log 'PLAN_ONLY=true, keine Änderungen an Piwigo.'
  exit 0
end

# ---------- Phase 3: Alben anlegen ----------
albums_created = 0
pg3 = ProgressTracker.new(albums_to_create.size, 'Alben anlegen', every: 5)

albums_to_create.each do |entry|
  rel = entry[:rel]
  parent_rel = entry[:parent_rel]
  album_name = entry[:album_name]

  real_parent_id = album_map[parent_rel]
  if !real_parent_id.is_a?(Integer)
    warn "Parent-ID für #{rel} noch nicht real, überspringe Album-Anlage"
    pg3.tick
    next
  end

  key = [real_parent_id.to_s, album_name]
  existing_id = album_index[key]

  if existing_id.is_a?(Integer)
    album_map[rel] = existing_id
    pg3.tick
    next
  end

  begin
    response = client.add_category(album_name, real_parent_id)
    album_id = nil

    if response['result'].is_a?(Hash)
      album_id = response['result']['id'] || response['result']['category_id']
    end

    if album_id.nil?
      categories = client.categories_list
      album_index = build_album_index(categories)
      album_id = album_index[[real_parent_id.to_s, album_name]]
    end

    unless album_id
      warn "Album konnte nach Anlegen nicht ermittelt werden: #{rel}"
      pg3.tick
      next
    end

    album_map[rel] = album_id
    album_index[[real_parent_id.to_s, album_name]] = album_id
    albums_created += 1
  rescue => e
    warn "Fehler beim Anlegen #{rel}: #{e.message}"
    categories = client.categories_list
    album_index = build_album_index(categories)
    fallback_id = album_index[[real_parent_id.to_s, album_name]]
    if fallback_id
      album_map[rel] = fallback_id
    end
  end
  pg3.tick
end
pg3.done("#{albums_created} neu angelegt (von #{albums_to_create.size} geplant)") if albums_to_create.size > 0

# ---------- Phase 4: Uploads ausführen ----------
uploads_done = 0
uploads_skipped = 0
uploads_failed = 0
pg4 = ProgressTracker.new(planned_uploads.size, 'Upload', every: 1)

planned_uploads.each do |up|
  path = up[:path]
  rel_dir = up[:rel_dir]
  final_name = up[:final_name]
  sha256 = up[:sha256]

  album_id = album_map[rel_dir] || album_map[up[:album_ref]] || ROOT_ALBUM_ID
  unless album_id.is_a?(Integer)
    warn "Kein echtes Zielalbum für #{path}, überspringe"
    uploads_skipped += 1
    pg4.tick
    next
  end

  existing_infos = load_remote_album_state(client, album_id, remote_album_images)
  if existing_infos[final_name] && existing_infos[final_name][:sha256] && existing_infos[final_name][:sha256] == sha256
    uploads_skipped += 1
    pg4.tick
    next
  elsif existing_infos[final_name] && existing_infos[final_name][:sha256] && existing_infos[final_name][:sha256] != sha256
    # Name inzwischen belegt mit anderem Inhalt, noch einmal umbenennen
    used_names = Set.new(existing_infos.keys)
    final_name2 = unique_name_for_album(final_name, used_names, existing_infos, sha256)
    if final_name2.nil?
      uploads_skipped += 1
      pg4.tick
      next
    end
    final_name = final_name2
  end

  begin
    client.upload_image(path, album_id, final_name)
    remote_album_images[album_id][final_name] = { sha256:, raw: nil }
    uploads_done += 1
  rescue => e
    warn "FEHLER bei #{path}: #{e.message}"
    uploads_failed += 1
  end
  pg4.tick
end
pg4.done("#{uploads_done} OK, #{uploads_skipped} übersprungen, #{uploads_failed} Fehler (von #{planned_uploads.size})")
log 'Fertig.'
