defmodule BDS.Desktop.ShellLive do
  @moduledoc false

  use Phoenix.LiveView

  require Logger

  import Phoenix.HTML

  alias BDS.{AI, BoundedAtoms, ImportDefinitions, Media, Metadata, Posts, Scripts}
  alias BDS.CliSync.Watcher
  alias BDS.Desktop.{FolderPicker, Overlay, ShellData, UILocale}

  alias BDS.Desktop.ShellLive.{
    ChatEditor,
    ImportEditor,
    MediaEditor,
    MenuEditor,
    MiscEditor,
    ScriptEditor,
    SettingsEditor,
    TagsEditor,
    TemplateEditor
  }

  alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents
  alias BDS.Desktop.ShellLive.PostEditor
  alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents
  alias BDS.Desktop.ShellLive.SidebarEvents
  alias BDS.Desktop.ShellLive.SidebarState, as: ShellSidebarState

  alias BDS.Desktop.ShellLive.{
    ChatSurface,
    CliSync,
    Layout,
    SessionUtil,
    ShellCommandRunner,
    SidebarCreate,
    TabHelpers,
    TaskLocalization,
    TitlebarMenu
  }

  import TaskLocalization,
    only: [
      localize_task_status: 2,
      translate_for_socket: 2
    ]

  import TabHelpers,
    only: [
      tab_title: 2,
      tab_subtitle: 2,
      tab_id_for_route: 2,
      tab_intent: 2,
      sidebar_route_atom: 1,
      parse_integer: 1
    ]

  alias BDS.Projects
  alias BDS.Templates
  alias BDS.UI.{Commands, MenuBar, Session, Workbench}
  alias Desktop.OS
  alias BDS.Desktop.Shutdown

  @refresh_interval 1_500
  @output_entry_limit 20
  @sidebar_filter_events [
    "toggle_sidebar_filters",
    "toggle_sidebar_archive",
    "toggle_sidebar_tags",
    "toggle_sidebar_categories",
    "update_sidebar_search",
    "clear_sidebar_search",
    "clear_sidebar_tags",
    "clear_sidebar_categories",
    "toggle_sidebar_tag",
    "toggle_sidebar_category",
    "select_sidebar_year",
    "select_sidebar_month",
    "clear_sidebar_month",
    "clear_sidebar_filters",
    "load_more_sidebar"
  ]

  @local_menu_actions MapSet.new([
                        :toggle_sidebar,
                        :toggle_panel,
                        :toggle_assistant_sidebar,
                        :view_posts,
                        :view_media,
                        :edit_preferences,
                        :edit_menu,
                        :documentation,
                        :api_documentation,
                        :close_tab
                      ])
  @socket_menu_actions MapSet.new([
                         :new_post,
                         :import_media,
                         :save,
                         :publish_selected,
                         :quit,
                         :view_on_github,
                         :report_issue,
                         :about
                       ])
  @runtime_menu_actions MapSet.new([
                          :undo,
                          :redo,
                          :cut,
                          :copy,
                          :paste,
                          :delete,
                          :select_all,
                          :find,
                          :replace,
                          :reload,
                          :force_reload,
                          :reset_zoom,
                          :zoom_in,
                          :zoom_out,
                          :toggle_full_screen
                        ])

  def supported_menu_actions do
    @local_menu_actions
    |> MapSet.union(@socket_menu_actions)
    |> MapSet.union(@runtime_menu_actions)
    |> MapSet.union(MapSet.new([:open_in_browser, :open_data_folder]))
    |> MapSet.union(MapSet.new([:preview_post, :rebuild_database, :reindex_text]))
    |> MapSet.union(MapSet.new([:rebuild_embedding_index, :metadata_diff, :regenerate_calendar]))
    |> MapSet.union(MapSet.new([:validate_translations, :fill_missing_translations, :find_duplicates]))
    |> MapSet.union(MapSet.new([:generate_sitemap, :validate_site, :upload_site]))
  end

  embed_templates("shell_live/*")

  @impl true
  def mount(_params, _session, socket) do
    connected = connected?(socket)

    if connected do
      Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic())
      :timer.send_interval(@refresh_interval, :refresh_task_status)
    end

    workbench = Workbench.new()

    {:ok,
     socket
     |> assign(:page_title, ShellData.title())
     |> assign(:page_language, ShellData.ui_language())
     |> assign(:client_shortcuts, Commands.client_shortcuts())
     |> assign(:offline_mode, if(connected, do: AI.airplane_mode?(true), else: true))
     |> assign(:handled_task_results, SessionUtil.initial_handled_task_results())
     |> assign(:assistant_prompt, "")
     |> assign(:assistant_messages, [])
     |> assign(:is_mac_ui, mac_ui?())
     |> assign(:menu_groups, TitlebarMenu.groups())
     |> assign(:titlebar_menu_group, nil)
     |> assign(:titlebar_menu_item_index, nil)
     |> assign(:tab_meta, %{})
     |> assign(:project_menu_open, false)
      |> assign(:sidebar_filters_by_view, %{})
       |> assign(:sidebar_filter_panels, %{})
     |> assign(:chat_editor_inputs, %{})
     |> assign(:chat_model_selectors_open, %{})
     |> assign(:chat_editor_requests, %{})
     |> assign(:chat_editor_request_refs, %{})
     |> assign(:chat_editor_surface_data, %{})
     |> assign(:chat_editor_surface_tabs, %{})
    |> assign(:chat_editor_dismissed_surfaces, MapSet.new())
     |> assign(:chat_editor_action_errors, %{})
     |> assign(:import_editor_analysis_states, %{})
     |> assign(:import_editor_analysis_task_refs, %{})
     |> assign(:import_editor_execution_states, %{})
     |> assign(:import_editor_execution_task_refs, %{})
     |> assign(:import_editor_sections, %{})
     |> assign(:import_editor_taxonomy_edits, %{})
     |> assign(:import_editor_model_selectors_open, %{})
     |> assign(:import_editor_selected_models, %{})
     |> assign(:misc_editor_selected_pairs, %{})
     |> assign(:misc_editor_git_selected_files, %{})
     |> assign(:metadata_diff_active_tabs, %{})
     |> assign(:metadata_diff_field_filters, %{})
     |> assign(:shell_overlay, nil)
     |> assign(:output_entries, [])
     |> reload_shell(workbench)}
  end

  @impl true
  def handle_event("toggle_sidebar", _params, socket) do
    {:noreply, reload_shell(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
  end

  def handle_event("toggle_panel", _params, socket) do
    {:noreply, reload_shell(socket, Workbench.toggle_panel(socket.assigns.workbench))}
  end

  def handle_event("toggle_assistant_sidebar", _params, socket) do
    {:noreply, reload_shell(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))}
  end

  def handle_event("select_view", %{"view" => view_id}, socket) do
    workbench =
      Workbench.click_activity(
        socket.assigns.workbench,
        BoundedAtoms.sidebar_view(view_id, :posts)
      )

    {:noreply, reload_shell(socket, workbench)}
  end

  def handle_event("select_panel_tab", %{"tab" => tab}, socket) do
    workbench =
      socket.assigns.workbench
      |> Workbench.set_panel_visible(true)
      |> Workbench.set_panel_tab(BoundedAtoms.panel_tab(tab, :tasks))

    {:noreply, reload_shell(socket, workbench)}
  end

  def handle_event("open_sidebar_item", %{"route" => _route, "id" => _id} = params, socket) do
    {:noreply, open_sidebar_item(socket, params, :preview)}
  end

  def handle_event("pin_sidebar_item", %{"route" => _route, "id" => _id} = params, socket) do
    {:noreply, open_sidebar_item(socket, params, :pin)}
  end

  def handle_event("sync_layout", params, socket) do
    {:noreply, reload_shell(socket, Layout.sync(socket.assigns.workbench, params))}
  end

  def handle_event("resize_panel", %{"target" => target, "width" => width}, socket) do
    {:noreply, reload_shell(socket, Layout.resize(socket.assigns.workbench, target, width))}
  end

  def handle_event(event, params, socket) when event in @sidebar_filter_events do
    SidebarEvents.handle(socket, event, params, &reload_shell/2)
  end

  def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do
    {:noreply, create_sidebar_item(socket, kind)}
  end

  def handle_event("shortcut", params, socket) do
    if Layout.ignore_shortcut?(params) do
      {:noreply, socket}
    else
      case Commands.command_for_shortcut(params) do
        nil -> {:noreply, socket}
        action -> {:noreply, handle_menu_action(socket, action)}
      end
    end
  end

  def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do
    workbench =
      Workbench.open_tab(
        socket.assigns.workbench,
        BoundedAtoms.editor_route(type, :post),
        id,
        :preview
      )

    {:noreply, reload_shell(socket, workbench)}
  end

  def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do
    type_atom = BoundedAtoms.editor_route(type, :post)
    workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id)
    tab_meta = Map.delete(socket.assigns.tab_meta, {type_atom, id})

    {:noreply,
     socket
     |> assign(:tab_meta, tab_meta)
     |> reload_shell(workbench)}
  end

  def handle_event(
        "confirm_sidebar_delete",
        %{"route" => route, "id" => id} = params,
        socket
      ) do
    {:noreply, request_sidebar_delete(socket, route, id, Map.get(params, "title"))}
  end

  def handle_event("toggle_offline_mode", _params, socket) do
    next_mode = not socket.assigns.offline_mode

    :ok = AI.set_airplane_mode(next_mode)
    socket = assign(socket, :offline_mode, next_mode)

    {:noreply, reload_shell(socket, socket.assigns.workbench)}
  end

  def handle_event("update_assistant_prompt", %{"assistant" => %{"prompt" => prompt}}, socket) do
    {:noreply, assign(socket, :assistant_prompt, prompt)}
  end

  def handle_event("submit_assistant_prompt", %{"assistant" => %{"prompt" => prompt}}, socket) do
    prompt = prompt |> to_string() |> String.trim()

    socket =
      if prompt == "" do
        assign(socket, :assistant_prompt, "")
      else
        socket
        |> assign(:assistant_prompt, "")
        |> assign(
          :assistant_messages,
          socket.assigns.assistant_messages ++ ChatSurface.assistant_turn(prompt, socket)
        )
      end

    {:noreply, socket}
  end

  def handle_event("open_tasks_panel", _params, socket) do
    workbench =
      socket.assigns.workbench
      |> Workbench.set_panel_visible(true)
      |> Workbench.set_panel_tab(:tasks)

    {:noreply, reload_shell(socket, workbench)}
  end

  def handle_event("settings_shell_command", %{"action" => action}, socket) do
    {:noreply, apply_shell_command(socket, action)}
  end

  def handle_event("change_chat_editor_input", %{"message" => message}, socket) do
    {:noreply, ChatEditor.update_input(socket, message, &reload_shell/2)}
  end

  def handle_event("toggle_chat_model_selector", _params, socket) do
    {:noreply, ChatEditor.toggle_model_selector(socket, &reload_shell/2)}
  end

  def handle_event("select_chat_model", %{"model" => model_id}, socket) do
    {:noreply, ChatEditor.set_model(socket, model_id, &reload_shell/2, &append_output_entry/5)}
  end

  def handle_event("send_chat_editor_message", _params, socket) do
    {:noreply, ChatEditor.send_message(socket, &reload_shell/2, &append_output_entry/5)}
  end

  def handle_event("abort_chat_editor_message", _params, socket) do
    {:noreply, ChatEditor.abort_message(socket, &reload_shell/2)}
  end

  def handle_event("open_chat_settings", _params, socket) do
    {:noreply,
     socket
     |> ChatSurface.clear_action_error()
     |> open_sidebar_item(
       %{"route" => "settings", "id" => "settings-ai", "title" => "Settings", "subtitle" => "AI"},
       :pin
     )}
  end

  def handle_event(
        "change_chat_surface_form",
        %{"surface" => %{"id" => surface_id, "fields" => fields}},
        socket
      ) do
    {:noreply, ChatEditor.update_surface_form(socket, surface_id, fields, &reload_shell/2)}
  end

  def handle_event(
        "select_chat_surface_tab",
        %{"surface-id" => surface_id, "index" => index},
        socket
      ) do
    {:noreply,
     ChatEditor.select_surface_tab(socket, surface_id, parse_integer(index), &reload_shell/2)}
  end

  def handle_event("dismiss_chat_surface", %{"surface-id" => surface_id}, socket) do
    {:noreply, ChatEditor.dismiss_surface(socket, surface_id, &reload_shell/2)}
  end

  def handle_event("chat_surface_action", params, socket) do
    {:noreply,
     ChatSurface.handle_action(socket, params, %{
       reload: &reload_shell/2,
       open_sidebar: &open_sidebar_item/3
     })}
  end

  def handle_event("change_import_editor_definition", %{"import_definition" => params}, socket) do
    {:noreply, ImportEditor.change_definition(socket, params, &reload_shell/2)}
  end

  def handle_event("select_import_uploads_folder", _params, socket) do
    {:noreply,
     ImportEditor.select_uploads_folder(socket, &reload_shell/2, &append_output_entry/5)}
  end

  def handle_event("select_import_wxr_file", _params, socket) do
    {:noreply, ImportEditor.select_and_analyze(socket, &reload_shell/2, &append_output_entry/5)}
  end

  def handle_event("execute_import_editor", _params, socket) do
    {:noreply, ImportEditor.execute_import(socket, &reload_shell/2, &append_output_entry/5)}
  end

  def handle_event("change_import_conflict_resolution", params, socket) do
    {:noreply, ImportEditor.change_conflict_resolution(socket, params, &reload_shell/2)}
  end

  def handle_event("start_import_taxonomy_edit", params, socket) do
    {:noreply, ImportEditor.start_taxonomy_edit(socket, params, &reload_shell/2)}
  end

  def handle_event("save_import_taxonomy_edit", params, socket) do
    {:noreply, ImportEditor.save_taxonomy_edit(socket, params, &reload_shell/2)}
  end

  def handle_event("cancel_import_taxonomy_edit", _params, socket) do
    {:noreply, ImportEditor.cancel_taxonomy_edit(socket, &reload_shell/2)}
  end

  def handle_event("clear_import_taxonomy_mapping", params, socket) do
    {:noreply, ImportEditor.clear_taxonomy_mapping(socket, params, &reload_shell/2)}
  end

  def handle_event("toggle_import_section", %{"section" => section}, socket) do
    {:noreply, ImportEditor.toggle_section(socket, section, &reload_shell/2)}
  end

  def handle_event("toggle_import_ai_model_selector", _params, socket) do
    {:noreply, ImportEditor.toggle_model_selector(socket, &reload_shell/2)}
  end

  def handle_event("select_import_ai_model", %{"model" => model_id}, socket) do
    {:noreply, ImportEditor.select_ai_model(socket, model_id, &reload_shell/2)}
  end

  def handle_event("analyze_import_taxonomy_ai", _params, socket) do
    {:noreply, ImportEditor.analyze_taxonomy_ai(socket, &reload_shell/2, &append_output_entry/5)}
  end

  def handle_event("rerun_misc_editor", _params, socket) do
    case MiscEditor.rerun(socket) do
      {:command, action} -> {:noreply, apply_shell_command(socket, action)}
      {:noop, next_socket} -> {:noreply, next_socket}
    end
  end

  def handle_event("apply_site_validation", _params, socket) do
    case MiscEditor.apply_site_validation(socket, &append_output_entry/5) do
      {:rerun, next_socket} -> {:noreply, apply_shell_command(next_socket, "validate_site")}
      {:socket, next_socket} -> {:noreply, next_socket}
    end
  end

  def handle_event("fix_translation_validation", _params, socket) do
    case MiscEditor.fix_translation_validation(socket, &append_output_entry/5) do
      {:rerun, next_socket} ->
        {:noreply, apply_shell_command(next_socket, "validate_translations")}

      {:socket, next_socket} ->
        {:noreply, next_socket}
    end
  end

  def handle_event("select_git_diff_file", %{"path" => path}, socket) do
    {:noreply, socket |> MiscEditor.select_git_diff_file(path) |> assign_misc_editor()}
  end

  def handle_event("toggle_duplicate_pair", %{"pair-id" => pair_id}, socket) do
    {:noreply, MiscEditor.toggle_duplicate(socket, pair_id, &reload_shell/2)}
  end

  def handle_event(
        "dismiss_duplicate_pair",
        %{"post-id-a" => post_id_a, "post-id-b" => post_id_b},
        socket
      ) do
    {:noreply,
     MiscEditor.dismiss_duplicate(
       socket,
       post_id_a,
       post_id_b,
       &reload_shell/2,
       &append_output_entry/5
     )}
  end

  def handle_event("dismiss_selected_duplicates", _params, socket) do
    {:noreply, MiscEditor.dismiss_selected(socket, &reload_shell/2, &append_output_entry/5)}
  end

  def handle_event("repair_metadata_diff", %{"field" => field, "direction" => direction}, socket) do
    case MiscEditor.metadata_diff_repair_request(socket, field, direction) do
      {:ok, params} ->
        {:noreply, apply_shell_command(socket, "repair_metadata_diff", params)}

      {:error, message} ->
        {:noreply,
         append_output_entry(
           socket,
           translate_for_socket(socket, "Metadata Diff"),
           message,
           nil,
           "error"
         )}
    end
  end

  def handle_event("import_metadata_diff_orphans", _params, socket) do
    case MiscEditor.metadata_diff_orphan_import_request(socket) do
      {:ok, params} ->
        {:noreply, apply_shell_command(socket, "import_metadata_diff_orphans", params)}

      {:error, message} ->
        {:noreply,
         append_output_entry(
           socket,
           translate_for_socket(socket, "Metadata Diff"),
           message,
           nil,
           "error"
         )}
    end
  end

  def handle_event("select_metadata_diff_tab", %{"tab" => tab}, socket) do
    tab_id = socket.assigns.current_tab.id

    socket =
      socket
      |> assign(
        :metadata_diff_active_tabs,
        Map.put(socket.assigns.metadata_diff_active_tabs, tab_id, tab)
      )
      |> assign(
        :metadata_diff_field_filters,
        Map.delete(socket.assigns.metadata_diff_field_filters, tab_id)
      )
      |> assign_misc_editor()

    {:noreply, socket}
  end

  def handle_event("toggle_metadata_diff_field", %{"field" => field}, socket) do
    tab_id = socket.assigns.current_tab.id
    current = Map.get(socket.assigns.metadata_diff_field_filters, tab_id)

    next_filters =
      if current == field do
        Map.delete(socket.assigns.metadata_diff_field_filters, tab_id)
      else
        Map.put(socket.assigns.metadata_diff_field_filters, tab_id, field)
      end

    {:noreply,
     socket |> assign(:metadata_diff_field_filters, next_filters) |> assign_misc_editor()}
  end

  def handle_event("open_duplicate_post", %{"id" => id, "title" => title}, socket) do
    {:noreply,
     open_sidebar_item(
       socket,
       %{"route" => "post", "id" => id, "title" => title, "subtitle" => "draft"},
       :preview
     )}
  end

  def handle_event("open_overlay", %{"kind" => kind}, socket) do
    socket =
      case socket.assigns[:current_tab] do
        %{type: :post, id: post_id} when kind in ["ai_suggestions", "language_picker"] ->
          send_update(__MODULE__.PostEditor, id: "post-editor-#{post_id}", action: :close_quick_actions)
          socket

        %{type: :media, id: media_id}
        when kind in ["ai_suggestions", "language_picker", "confirm_delete"] ->
          send_update(__MODULE__.MediaEditor, id: "media-editor-#{media_id}", action: :close_quick_actions)
          socket

        _other ->
          socket
      end

    overlay =
      with overlay_kind when not is_nil(overlay_kind) <- ShellOverlayComponents.kind(kind),
           %{type: route} <- socket.assigns[:current_tab] do
        tab = socket.assigns.current_tab
        title = tab_title(tab, socket.assigns.tab_meta)
        subtitle = tab_subtitle(tab, socket.assigns.tab_meta)

        Overlay.open(
          route,
          overlay_kind,
          ShellOverlayComponents.context(socket.assigns, title, subtitle)
        )
      end

    socket = assign(socket, :shell_overlay, overlay)

    socket =
      if kind == "ai_suggestions" and not is_nil(overlay) do
        if socket.assigns.offline_mode do
          socket
          |> assign(:shell_overlay, nil)
          |> append_output_entry(
            translated("AI Suggestions"),
            translated("Automatic AI actions stay gated by airplane mode."),
            nil,
            "info"
          )
        else
          spawn_ai_suggestions_task(socket)
        end
      else
        socket
      end

    {:noreply, socket}
  end

  def handle_event("close_overlay", _params, socket) do
    {:noreply, assign(socket, :shell_overlay, nil)}
  end

  def handle_event("overlay_keydown", %{"key" => key}, socket) do
    socket =
      case {socket.assigns[:shell_overlay], key} do
        {nil, _other} ->
          socket

        {_overlay, "Escape"} ->
          assign(socket, :shell_overlay, nil)

        {%{kind: :gallery} = overlay, "ArrowLeft"} ->
          assign(socket, :shell_overlay, Overlay.lightbox_previous(overlay))

        {%{kind: :gallery} = overlay, "ArrowRight"} ->
          assign(socket, :shell_overlay, Overlay.lightbox_next(overlay))

        _other ->
          socket
      end

    {:noreply, socket}
  end

  def handle_event("overlay_toggle_ai_field", %{"key" => key}, socket) do
    {:noreply, update_shell_overlay(socket, &Overlay.toggle_ai_field(&1, key))}
  end

  def handle_event("overlay_set_search", %{"overlay" => %{"query" => query}}, socket) do
    {:noreply, update_shell_overlay(socket, &Overlay.set_search_query(&1, query))}
  end

  def handle_event("overlay_set_tab", %{"tab" => tab}, socket) do
    {:noreply,
     update_shell_overlay(socket, &Overlay.set_active_tab(&1, ShellOverlayComponents.tab(tab)))}
  end

  def handle_event("overlay_update_form", %{"overlay" => params}, socket) do
    socket =
      socket
      |> update_shell_overlay(
        &Overlay.update_form_value(&1, :external_url, Map.get(params, "url", ""))
      )
      |> update_shell_overlay(
        &Overlay.update_form_value(&1, :external_text, Map.get(params, "text", ""))
      )

    {:noreply, socket}
  end

  def handle_event("overlay_select_result", %{"id" => id}, socket) do
    overlay = socket.assigns[:shell_overlay]
    current_tab = socket.assigns[:current_tab]

    socket =
      case {overlay, current_tab} do
        {%{kind: :insert_link}, %{type: :post, id: post_id}} ->
          case Overlay.insert_link_result(overlay, id) do
            nil ->
              socket

            result ->
              send(self(), {:post_editor_insert_content, post_id, ShellOverlayComponents.markdown_link(result.title, result.canonical_url)})
              socket
          end

        {%{kind: :insert_media}, %{type: :post, id: post_id}} ->
          case Overlay.insert_media_result(overlay, id) do
            nil ->
              socket

            result ->
              syntax =
                if result.is_image do
                  "![#{result.title}](bds-media://#{result.media_id})"
                else
                  "[#{result.original_name}](bds-media://#{result.media_id})"
                end

              send(self(), {:post_editor_insert_content, post_id, syntax})
              socket
          end

        _other ->
          socket
      end

    {:noreply, socket}
  end

  def handle_event("overlay_insert_external", _params, socket) do
    current_tab = socket.assigns[:current_tab]

    socket =
      case {socket.assigns[:shell_overlay], current_tab} do
        {%{kind: :insert_link} = overlay, %{type: :post, id: post_id}} ->
          details =
            case {overlay.external_url, String.trim(overlay.external_text || "")} do
              {"", _text} -> nil
              {url, ""} -> url
              {url, text} -> ShellOverlayComponents.markdown_link(text, url)
            end

          if details do
            send(self(), {:post_editor_insert_content, post_id, details})
          end

          socket

        _other ->
          socket
      end

    {:noreply, socket}
  end

  def handle_event("overlay_select_language", %{"code" => code}, socket) do
    current_tab = socket.assigns[:current_tab]

    socket =
      case {socket.assigns[:shell_overlay], current_tab} do
        {%{kind: :language_picker}, %{type: :post, id: post_id}} ->
          send(self(), {:post_editor_translate, post_id, code})
          socket

        {%{kind: :language_picker}, %{type: :media, id: media_id}} ->
          send_update(MediaEditor, id: "media-editor-#{media_id}", action: :translate, language: code)
          socket

        _other ->
          socket
      end

    {:noreply, socket}
  end

  def handle_event("overlay_confirm", _params, socket) do
    current_tab = socket.assigns[:current_tab]

    socket =
      case {socket.assigns[:shell_overlay], current_tab} do
        {%{kind: :confirm_delete, delete_action: %{source: :sidebar, route: route, id: id}}, _tab} ->
          execute_sidebar_delete(socket, route, id)

        {%{kind: :ai_suggestions} = overlay, %{type: :post, id: post_id}} ->
          send(self(), {:post_editor_apply_ai_suggestions, post_id, Overlay.selected_ai_fields(overlay)})
          socket

        {%{kind: :ai_suggestions} = overlay, %{type: :media, id: media_id}} ->
          send_update(MediaEditor, id: "media-editor-#{media_id}",
            action: :apply_ai_suggestions,
            fields: Overlay.selected_ai_fields(overlay)
          )

          socket

        {%{kind: :confirm_delete}, %{type: :media, id: media_id}} ->
          case Media.delete_media(media_id) do
            {:ok, :deleted} ->
              workbench = Workbench.close_tab(socket.assigns.workbench, :media, media_id)

              socket
              |> assign(:shell_overlay, nil)
              |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id}))
              |> reload_shell(workbench)

            {:error, reason} ->
              socket
              |> assign(:shell_overlay, nil)
              |> append_output_entry(translated("Delete Media"), inspect(reason), nil, "error")
              |> reload_shell(socket.assigns.workbench)
          end

        {%{kind: :confirm_delete, title: title, entity_name: entity_name}, _tab} ->
          close_overlay_with_output(socket, title, entity_name)

        {%{kind: :confirm_dialog, title: title, message: message}, _tab} ->
          close_overlay_with_output(socket, title, message)

        _other ->
          socket
      end

    {:noreply, socket}
  end

  def handle_event("overlay_select_gallery_image", %{"id" => id}, socket) do
    {:noreply, update_shell_overlay(socket, &Overlay.select_gallery_image(&1, id))}
  end

  def handle_event("overlay_close_lightbox", _params, socket) do
    {:noreply, update_shell_overlay(socket, &Overlay.close_lightbox/1)}
  end

  def handle_event("overlay_lightbox_previous", _params, socket) do
    {:noreply, update_shell_overlay(socket, &Overlay.lightbox_previous/1)}
  end

  def handle_event("overlay_lightbox_next", _params, socket) do
    {:noreply, update_shell_overlay(socket, &Overlay.lightbox_next/1)}
  end

  def handle_event("toggle_project_menu", _params, socket) do
    {:noreply, assign(socket, :project_menu_open, not socket.assigns.project_menu_open)}
  end

  def handle_event("close_project_menu", _params, socket) do
    {:noreply, assign(socket, :project_menu_open, false)}
  end

  def handle_event("select_project", %{"project_id" => project_id}, socket) do
    {:noreply,
     activate_project(socket, project_id, "Select Project", fn project ->
       "Activated #{project.name}"
     end)}
  end

  def handle_event("create_project", _params, socket) do
    attrs = %{name: SessionUtil.next_project_name(socket.assigns.projects.projects)}

    socket =
      case Projects.create_project(attrs) do
        {:ok, project} ->
          activate_project(socket, project.id, "New Project", fn created ->
            "Activated #{created.name}"
          end)

        {:error, reason} ->
          append_output_entry(socket, "New Project", inspect(reason), nil, "error")
      end

    {:noreply, socket}
  end

  def handle_event("import_project", _params, socket) do
    socket =
      case FolderPicker.choose_directory("Open Existing Blog") do
        {:ok, path} ->
          name =
            path
            |> Path.basename()
            |> String.trim()
            |> case do
              "" -> "Imported Blog"
              value -> value
            end

          case Projects.create_project(%{name: name, data_path: path}) do
            {:ok, project} ->
              activate_project(socket, project.id, "Open Existing Blog", fn imported ->
                "Activated #{imported.name}"
              end)

            {:error, reason} ->
              append_output_entry(socket, "Open Existing Blog", inspect(reason), nil, "error")
          end

        :cancel ->
          assign(socket, :project_menu_open, false)

        {:error, %{message: message}} ->
          append_output_entry(socket, "Open Existing Blog", message, nil, "error")
      end

    {:noreply, socket}
  end

  def handle_event("change_ui_language", %{"ui_language" => language}, socket) do
    {:noreply, set_page_language(socket, language)}
  end

  def handle_event("sync_ui_language", %{"language" => language}, socket) do
    {:noreply, set_page_language(socket, language)}
  end

  def handle_event("restore_workbench_session", %{"session" => session_payload}, socket)
      when is_map(session_payload) do
    {:noreply, reload_shell(socket, SessionUtil.restore_workbench_session(session_payload))}
  end

  def handle_event("native_menu_action", %{"action" => action}, socket) do
    {:noreply, handle_native_menu_action(socket, action)}
  end

  def handle_event("titlebar_menu_keydown", %{"key" => key}, socket) do
    {:noreply, TitlebarMenu.handle_keydown(socket, key, &handle_native_menu_action/2)}
  end

  def handle_event("toggle_titlebar_menu", %{"group" => group}, socket) do
    {:noreply, TitlebarMenu.toggle(socket, group)}
  end

  def handle_event("hover_titlebar_menu", %{"group" => group}, socket) do
    {:noreply, TitlebarMenu.hover(socket, group)}
  end

  def handle_event("close_titlebar_menu", _params, socket) do
    {:noreply, TitlebarMenu.close(socket)}
  end

  def handle_event("titlebar_menu_action", %{"action" => action}, socket) do
    {:noreply,
     socket
     |> TitlebarMenu.close()
     |> handle_native_menu_action(action)}
  end

  @impl true
  def handle_info({ref, result}, socket) when is_reference(ref) do
    Process.demonitor(ref, [:flush])

    cond do
      Map.has_key?(socket.assigns.import_editor_analysis_task_refs, ref) ->
        {:noreply,
         ImportEditor.finish_analysis(
           socket,
           ref,
           result,
           &reload_shell/2,
           &append_output_entry/5
         )}

      Map.has_key?(socket.assigns.import_editor_execution_task_refs, ref) ->
        {:noreply,
         ImportEditor.finish_execution(
           socket,
           ref,
           result,
           &reload_shell/2,
           &append_output_entry/5
         )}

      true ->
        {:noreply,
         ChatEditor.finish_request(socket, ref, result, &reload_shell/2, &append_output_entry/5)}
    end
  end

  def handle_info({:DOWN, ref, :process, _pid, reason}, socket) when is_reference(ref) do
    next_socket =
      cond do
        Map.has_key?(socket.assigns.import_editor_analysis_task_refs, ref) ->
          ImportEditor.handle_task_down(
            socket,
            :analysis,
            ref,
            reason,
            &reload_shell/2,
            &append_output_entry/5
          )

        Map.has_key?(socket.assigns.import_editor_execution_task_refs, ref) ->
          ImportEditor.handle_task_down(
            socket,
            :execution,
            ref,
            reason,
            &reload_shell/2,
            &append_output_entry/5
          )

        true ->
          case reason do
            :normal ->
              socket

            _other ->
              ChatEditor.finish_request(
                socket,
                ref,
                {:error, :cancelled},
                &reload_shell/2,
                &append_output_entry/5
              )
          end
      end

    {:noreply, next_socket}
  end

  def handle_info({:import_analysis_progress, definition_id, step, detail}, socket) do
    {:noreply,
     ImportEditor.note_analysis_progress(socket, definition_id, step, detail, &reload_shell/2)}
  end

  def handle_info(
        {:import_execution_progress, definition_id, phase, current, total, detail},
        socket
      ) do
    {:noreply,
     ImportEditor.note_execution_progress(
       socket,
       definition_id,
       phase,
       current,
       total,
       detail,
       &reload_shell/2
     )}
  end

  def handle_info({:chat_tool_call, conversation_id, tool_call}, socket) do
    {:noreply, ChatEditor.note_tool_call(socket, conversation_id, tool_call, &reload_shell/2)}
  end

  def handle_info({:chat_tool_result, conversation_id, name}, socket) do
    {:noreply, ChatEditor.note_tool_result(socket, conversation_id, name, &reload_shell/2)}
  end

  def handle_info({:chat_streaming_content, conversation_id, content}, socket) do
    {:noreply,
     ChatEditor.note_streaming_content(socket, conversation_id, content, &reload_shell/2)}
  end

  def handle_info({:entity_changed, payload}, socket) when is_map(payload) do
    {:noreply, CliSync.apply_entity_change(socket, payload, &reload_shell/2)}
  end

  def handle_info(:refresh_task_status, socket) do
    raw_task_status = BDS.Tasks.status_snapshot()

    case SessionUtil.next_completed_task_result(socket, raw_task_status) do
      nil ->
        task_status = localize_task_status(raw_task_status, socket.assigns.page_language)

        {:noreply,
         socket
         |> assign(:task_status, task_status)
         |> assign(:editor_meta, ShellData.editor_meta(task_status))
         |> assign(
           :status,
           ShellData.status_bar(socket.assigns.workbench, task_status, socket.assigns.dashboard,
             ui_language: socket.assigns.page_language,
             offline_mode: socket.assigns.offline_mode
           )
         )}

      task ->
        {:noreply,
         socket
         |> SessionUtil.mark_task_result_handled(task.id)
         |> apply_shell_command_result(task.result)}
    end
  end

  def handle_info({:tags_editor_output, title, message, level}, socket) do
    {:noreply, append_output_entry(socket, title, message, nil, level)}
  end

  def handle_info(:tags_changed, socket) do
    {:noreply, reload_shell(socket, socket.assigns.workbench)}
  end

  def handle_info({:settings_output, title, message, level}, socket) do
    {:noreply, append_output_entry(socket, title, message, nil, level)}
  end

  def handle_info(:settings_changed, socket) do
    {:noreply, reload_shell(socket, socket.assigns.workbench)}
  end

  def handle_info({:menu_editor_output, title, message, level}, socket) do
    {:noreply, append_output_entry(socket, title, message, nil, level)}
  end

  def handle_info({:script_editor_output, title, message, level}, socket) do
    {:noreply, append_output_entry(socket, title, message, nil, level)}
  end

  def handle_info({:template_editor_output, title, message, level}, socket) do
    {:noreply, append_output_entry(socket, title, message, nil, level)}
  end

  def handle_info({:post_editor_output, title, message, level}, socket) do
    {:noreply, append_output_entry(socket, title, message, nil, level)}
  end

  def handle_info({:post_editor_dirty, post_id, dirty?}, socket) do
    workbench =
      if dirty? do
        BDS.UI.Workbench.mark_dirty(socket.assigns.workbench, :post, post_id)
      else
        BDS.UI.Workbench.clear_dirty(socket.assigns.workbench, :post, post_id)
      end

    {:noreply, assign(socket, :workbench, workbench)}
  end

  def handle_info({:post_editor_tab_meta, post_id, title, subtitle}, socket) do
    tab_meta =
      Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: title, subtitle: subtitle})

    {:noreply, assign(socket, :tab_meta, tab_meta)}
  end

  def handle_info({:post_editor_insert_content, post_id, content}, socket) do
    send_update(PostEditor, id: "post-editor-#{post_id}", action: :insert_content, content: content)
    {:noreply, socket}
  end

  def handle_info({:post_editor_translate, post_id, language}, socket) do
    send_update(PostEditor, id: "post-editor-#{post_id}", action: :translate, language: language)
    {:noreply, socket}
  end

  def handle_info({:post_editor_apply_ai_suggestions, post_id, fields}, socket) do
    send_update(PostEditor, id: "post-editor-#{post_id}",
      action: :apply_ai_suggestions,
      fields: fields
    )

    {:noreply, socket}
  end

  def handle_info({:ai_suggestions_result, type, id, result}, socket) do
    socket =
      case socket.assigns[:shell_overlay] do
        %{kind: :ai_suggestions} = overlay ->
          current_tab = socket.assigns.current_tab

          if current_tab && current_tab.type == type && current_tab.id == id do
            suggestions =
              case type do
                :post ->
                  %{
                    "title" => result.title,
                    "excerpt" => result.excerpt,
                    "slug" => result.slug
                  }

                :media ->
                  %{
                    "title" => result.title,
                    "alt" => result.alt,
                    "caption" => result.caption
                  }
              end

            assign(socket, :shell_overlay, Overlay.set_ai_suggestions(overlay, suggestions))
          else
            socket
          end

        _other ->
          socket
      end

    {:noreply, socket}
  end

  def handle_info({:ai_suggestions_error, type, id, reason}, socket) do
    Logger.error("AI suggestions error type=#{type} id=#{id} reason=#{inspect(reason)}")

    socket =
      case socket.assigns[:shell_overlay] do
        %{kind: :ai_suggestions} = overlay ->
          current_tab = socket.assigns.current_tab

          if current_tab && current_tab.type == type && current_tab.id == id do
            message =
              if is_map(reason) and Map.has_key?(reason, :kind) do
                "#{reason.kind}: #{inspect(Map.drop(reason, [:kind]))}"
              else
                inspect(reason)
              end

            assign(socket, :shell_overlay, Overlay.set_ai_suggestions_error(overlay, message))
          else
            socket
          end

        _other ->
          socket
      end

    {:noreply, socket}
  end

  def handle_info({:media_editor_output, title, message, level}, socket) do
    {:noreply, append_output_entry(socket, title, message, nil, level)}
  end

  def handle_info({:media_editor_dirty, media_id, dirty?}, socket) do
    workbench =
      if dirty? do
        Workbench.mark_dirty(socket.assigns.workbench, :media, media_id)
      else
        Workbench.clear_dirty(socket.assigns.workbench, :media, media_id)
      end

    {:noreply, assign(socket, :workbench, workbench)}
  end

  def handle_info({:media_editor_tab_meta, media_id, title, subtitle}, socket) do
    tab_meta =
      Map.put(socket.assigns.tab_meta, {:media, media_id}, %{title: title, subtitle: subtitle})

    {:noreply, assign(socket, :tab_meta, tab_meta)}
  end

  def handle_info(:reload_shell, socket) do
    {:noreply, reload_shell(socket, socket.assigns.workbench)}
  end

  def handle_info({:close_tab, type, id}, socket) do
    {:noreply, reload_shell(socket, BDS.UI.Workbench.close_tab(socket.assigns.workbench, type, id))}
  end

  @impl true
  def render(assigns) do
    UILocale.put(assigns.page_language)
    index(assigns)
  end

  defp reload_shell(socket, workbench) do
    projects = ShellData.project_snapshot()
    dashboard = ShellData.dashboard(projects.active_project_id)
    git_badge_count = ShellData.git_badge_count(projects.active_project_id)
    active_view_id = Atom.to_string(workbench.active_view)
    tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})

    sidebar_data =
      ShellData.sidebar_view(
        projects.active_project_id,
        active_view_id,
        ShellSidebarState.current_filters(socket, active_view_id)
      )

    sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
    raw_task_status = BDS.Tasks.status_snapshot()
    activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
    page_language = socket.assigns[:page_language] || ShellData.ui_language()

    offline_mode =
      if connected?(socket) do
        Map.get(socket.assigns, :offline_mode, AI.airplane_mode?(true))
      else
        Map.get(socket.assigns, :offline_mode, true)
      end

    task_status = localize_task_status(raw_task_status, page_language)

    socket
  |> assign(:tab_meta, tab_meta)
    |> assign(:workbench, workbench)
    |> assign(:projects, projects)
    |> assign(:current_project, ShellData.current_project(projects))
    |> assign(:dashboard, dashboard)
    |> assign(:dashboard_timeline_entries, Map.get(dashboard, :timeline_entries, []))
    |> assign(:dashboard_category_counts, Map.get(dashboard, :category_counts, []))
    |> assign(:dashboard_recent_posts, Map.get(dashboard, :recent_posts, []))
    |> assign(
      :dashboard_tag_cloud_items,
      ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, []))
    )
    |> assign(:sidebar_data, sidebar_data)
    |> assign(
      :sidebar_header,
      active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data)
    )
    |> assign(:assistant_cards, ShellData.assistant_cards())
    |> assign(:editor_meta, ShellData.editor_meta(task_status))
    |> assign(:task_status, task_status)
    |> assign(
      :status,
      ShellData.status_bar(workbench, task_status, dashboard,
        ui_language: page_language,
        offline_mode: offline_mode
      )
    )
    |> assign(:activity_buttons, activity_buttons)
    |> assign(:panel_tabs, ShellData.panel_tabs(workbench))
    |> assign(:supported_ui_languages, ShellData.supported_ui_languages())
    |> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
    |> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
    |> assign(:current_tab, current_tab(workbench))
    |> assign_chat_editor()
    |> assign_import_editor()
    |> assign_misc_editor()
  end

  defp translated(text, bindings \\ %{}),
    do: ShellData.translate(text, bindings, UILocale.current())

  defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts)

  defp encoded_workbench_session(workbench), do: Jason.encode!(Session.serialize(workbench))

  defp panel_tab_label(:tasks), do: translated("Tasks")
  defp panel_tab_label(:output), do: translated("Output")
  defp panel_tab_label(:git_log), do: translated("Git Log")
  defp panel_tab_label(tab), do: ShellData.route_label(tab)

  defp activity_label("AI Assistant"), do: "Chat"
  defp activity_label("Source Control"), do: "Git"
  defp activity_label(label), do: translated(label)

  defp active_sidebar_label(activity_buttons, active_view, sidebar_data) do
    Enum.find_value(activity_buttons, translated(Map.get(sidebar_data, :title, "")), fn button ->
      if button.id == active_view, do: activity_label(button.label), else: nil
    end)
  end

  defp sidebar_header_label(label), do: translated(label)

  defp timeline_height(entry, entries) do
    max_count =
      entries
      |> Enum.map(&(&1.count || 0))
      |> Enum.max(fn -> 1 end)

    max(4, (entry.count || 0) / max_count * 100)
  end

  defp current_tab(%{active_tab: nil}), do: nil

  defp current_tab(%{tabs: tabs, active_tab: {type, id}}) do
    Enum.find(tabs, &(&1.type == type and &1.id == id))
  end

  defp assign_chat_editor(socket) do
    ChatEditor.assign_socket(socket)
  end

  defp assign_import_editor(socket) do
    ImportEditor.assign_socket(socket)
  end

  defp assign_misc_editor(socket) do
    MiscEditor.assign_socket(socket)
  end

  defp create_sidebar_item(socket, kind),
    do: SidebarCreate.create(socket, kind, sidebar_create_callbacks())

  defp sidebar_create_callbacks do
    %{
      reload: &reload_shell/2,
      open_sidebar: &open_sidebar_item/3,
      append_output: &append_output_entry/5
    }
  end

  defp open_sidebar_item(socket, params, intent) do
    route_atom = sidebar_route_atom(Map.fetch!(params, "route"))
    tab_id = tab_id_for_route(route_atom, Map.fetch!(params, "id"))

    workbench =
      Workbench.open_tab(
        socket.assigns.workbench,
        route_atom,
        tab_id,
        tab_intent(route_atom, intent)
      )

    tab_meta =
      Map.put(socket.assigns.tab_meta, {route_atom, tab_id}, %{
        sidebar_item_id: Map.get(params, "id"),
        title: Map.get(params, "title", ""),
        subtitle: Map.get(params, "subtitle", "")
      })

    socket
    |> assign(:tab_meta, tab_meta)
    |> reload_shell(workbench)
  end

  defp sidebar_create_action(view), do: SidebarCreate.action(view)

  defp set_page_language(socket, language) do
    codes =
      Enum.map(
        socket.assigns[:supported_ui_languages] || ShellData.supported_ui_languages(),
        & &1.code
      )

    normalized =
      language
      |> to_string()
      |> String.trim()
      |> case do
        value -> if(value in codes, do: value, else: socket.assigns.page_language)
      end

    if normalized == socket.assigns.page_language do
      socket
    else
      socket
      |> assign(:page_language, normalized)
      |> reload_shell(socket.assigns.workbench)
    end
  end

  defp activate_project(socket, project_id, title, message_fun) do
    cond do
      project_id == socket.assigns.projects.active_project_id ->
        assign(socket, :project_menu_open, false)

      true ->
        case Projects.set_active_project(project_id) do
          {:ok, project} ->
            socket
            |> assign(:project_menu_open, false)
            |> assign(:sidebar_filters_by_view, %{})
            |> append_output_entry(title, message_fun.(project))
            |> reload_shell(Workbench.new())

          {:error, reason} ->
            socket
            |> assign(:project_menu_open, false)
            |> append_output_entry(title, inspect(reason), nil, "error")
        end
    end
  end

  defp append_output_entry(socket, title, message, details \\ nil, level \\ "info") do
    entry = %{title: title, message: message, details: details, level: level}
    entries = [entry | socket.assigns.output_entries] |> Enum.take(@output_entry_limit)
    assign(socket, :output_entries, entries)
  end

  defp handle_native_menu_action(socket, action) do
    case BoundedAtoms.menu_action(action) do
      nil -> append_output_entry(socket, "Menu", "Unsupported shell command", action, "error")
      action_atom -> handle_menu_action(socket, action_atom)
    end
  end

  defp handle_menu_action(socket, action) when is_atom(action) do
    cond do
      MapSet.member?(@local_menu_actions, action) ->
        reload_shell(socket, MenuBar.execute(socket.assigns.workbench, action))

      MapSet.member?(@socket_menu_actions, action) ->
        handle_socket_menu_action(socket, action)

      MapSet.member?(@runtime_menu_actions, action) ->
        push_event(socket, "menu-runtime-command", %{action: Atom.to_string(action)})

      shell_command?(action) ->
        apply_shell_command(socket, Atom.to_string(action))

      true ->
        append_output_entry(socket, "Menu", "Unsupported shell command", Atom.to_string(action), "error")
    end
  end

  defp handle_socket_menu_action(socket, :new_post), do: create_sidebar_item(socket, "post")
  defp handle_socket_menu_action(socket, :import_media), do: create_sidebar_item(socket, "media")
  defp handle_socket_menu_action(socket, :save), do: save_current_tab(socket)
  defp handle_socket_menu_action(socket, :publish_selected), do: publish_current_tab(socket)

  defp handle_socket_menu_action(socket, :quit) do
    Shutdown.request_quit()
    socket
  end

  defp handle_socket_menu_action(socket, :view_on_github) do
    OS.launch_default_browser("https://github.com/rfc1437/bDS")
    socket
  end

  defp handle_socket_menu_action(socket, :report_issue) do
    OS.launch_default_browser("https://github.com/rfc1437/bDS/issues")
    socket
  end

  defp handle_socket_menu_action(socket, :about) do
    append_output_entry(
      socket,
      "About",
      "Blogging Desktop Server",
      "Version #{Application.spec(:bds, :vsn) |> to_string()}",
      "info"
    )
  end

  defp shell_command?(action), do: not is_nil(shell_command_atom(action))

  defp save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
    send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
    socket
  end

  defp save_current_tab(%{assigns: %{current_tab: %{type: :media, id: media_id}}} = socket) do
    send_update(MediaEditor, id: "media-editor-#{media_id}", action: :save)
    socket
  end

  defp save_current_tab(%{assigns: %{current_tab: %{type: :settings}}} = socket) do
    send_update(SettingsEditor, id: "settings-editor", action: :save_project)
    socket
  end

  defp save_current_tab(%{assigns: %{current_tab: %{type: :menu_editor}}} = socket) do
    send_update(MenuEditor, id: "menu-editor", action: :save)
    socket
  end

  defp save_current_tab(%{assigns: %{current_tab: %{type: :tags}}} = socket) do
    send_update(TagsEditor, id: "tags-editor", action: :save)
    socket
  end

  defp save_current_tab(%{assigns: %{current_tab: %{type: :scripts, id: script_id}}} = socket) do
    send_update(ScriptEditor, id: "script-editor-#{script_id}", action: :save)
    socket
  end

  defp save_current_tab(%{assigns: %{current_tab: %{type: :templates, id: template_id}}} = socket) do
    send_update(TemplateEditor, id: "template-editor-#{template_id}", action: :save)
    socket
  end

  defp save_current_tab(socket), do: reload_shell(socket, socket.assigns.workbench)

  defp publish_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
    send_update(PostEditor, id: "post-editor-#{post_id}", action: :publish)
    socket
  end

  defp publish_current_tab(socket), do: reload_shell(socket, socket.assigns.workbench)

  defp apply_shell_command(socket, action, params \\ %{}),
    do: ShellCommandRunner.execute(socket, action, params, shell_command_callbacks())

  defp apply_shell_command_result(socket, result),
    do: ShellCommandRunner.apply_result(socket, result, shell_command_callbacks())

  defp shell_command_callbacks do
    %{
      reload: &reload_shell/2,
      append_output: &append_output_entry/5
    }
  end

  defp shell_command_atom(action), do: ShellCommandRunner.shell_command_atom(action)

  defp spawn_ai_suggestions_task(socket) do
    current_tab = socket.assigns.current_tab
    language = ai_suggestions_language(socket)

    case current_tab do
      %{type: :post, id: post_id} ->
        parent = self()

        Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
          case AI.analyze_post(post_id, language: language) do
            {:ok, result} ->
              send(parent, {:ai_suggestions_result, :post, post_id, result})

            {:error, reason} ->
              send(parent, {:ai_suggestions_error, :post, post_id, reason})
          end
        end)

      %{type: :media, id: media_id} ->
        parent = self()

        Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
          case AI.analyze_image(media_id, language: language) do
            {:ok, result} ->
              send(parent, {:ai_suggestions_result, :media, media_id, result})

            {:error, reason} ->
              send(parent, {:ai_suggestions_error, :media, media_id, reason})
          end
        end)

      _other ->
        :ok
    end

    socket
  end

  defp ai_suggestions_language(socket) do
    active_project_id = socket.assigns.projects.active_project_id
    {:ok, metadata} = Metadata.get_project_metadata(active_project_id)
    metadata.main_language || "en"
  rescue
    _error -> "en"
  end

  defp mac_ui? do
    case Application.get_env(:bds, :shell_platform) do
      nil -> match?({:unix, :darwin}, :os.type())
      platform -> match?({:unix, :darwin}, platform)
    end
  end

  defp update_shell_overlay(socket, updater),
    do: ChatSurface.update_shell_overlay(socket, updater)

  defp close_overlay_with_output(socket, title, details) do
    socket
    |> append_output_entry(title, translated("Command completed"), details)
    |> assign(:shell_overlay, nil)
  end

  defp request_sidebar_delete(socket, route, id, fallback_title) do
    case sidebar_delete_target(socket, route, id, fallback_title) do
      {:ok, entity_name} ->
        assign(socket, :shell_overlay, %{
          kind: :confirm_delete,
          title: sidebar_delete_title(route),
          entity_name: entity_name,
          entity_type: route,
          reference_count: 0,
          reference_list: [],
          delete_action: %{source: :sidebar, route: route, id: id}
        })

      {:error, reason} ->
        socket
        |> assign(:shell_overlay, nil)
        |> append_output_entry(sidebar_delete_title(route), inspect(reason), nil, "error")
        |> reload_shell(socket.assigns.workbench)
    end
  end

  defp execute_sidebar_delete(socket, route, id) do
    case route do
      "post" ->
        case Posts.delete_post(id) do
          {:ok, :deleted} ->
            workbench = BDS.UI.Workbench.close_tab(socket.assigns.workbench, :post, id)

            socket
            |> assign(:shell_overlay, nil)
            |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, id}))
            |> reload_shell(workbench)

          {:error, reason} ->
            socket
            |> assign(:shell_overlay, nil)
            |> append_output_entry(sidebar_delete_title("post"), inspect(reason), nil, "error")
            |> reload_shell(socket.assigns.workbench)
        end

      "media" ->
        case Media.delete_media(id) do
          {:ok, :deleted} ->
            workbench = Workbench.close_tab(socket.assigns.workbench, :media, id)

            socket
            |> assign(:shell_overlay, nil)
            |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, id}))
            |> reload_shell(workbench)

          {:error, reason} ->
            socket
            |> assign(:shell_overlay, nil)
            |> append_output_entry(translated("Delete Media"), inspect(reason), nil, "error")
            |> reload_shell(socket.assigns.workbench)
        end

      "scripts" ->
        delete_sidebar_script(socket, id)

      "templates" ->
        delete_sidebar_template(socket, id)

      "chat" ->
        delete_sidebar_chat(socket, id)

      "import" ->
        delete_sidebar_import(socket, id)

      _other ->
        socket
        |> assign(:shell_overlay, nil)
        |> append_output_entry(translated("Delete"), inspect(:unsupported_route), nil, "error")
        |> reload_shell(socket.assigns.workbench)
    end
  end

  defp delete_sidebar_script(socket, script_id) do
    case Scripts.delete_script(script_id) do
      {:ok, :deleted} ->
        workbench = Workbench.close_tab(socket.assigns.workbench, :scripts, script_id)

        socket
        |> assign(:shell_overlay, nil)
        |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:scripts, script_id}))
        |> reload_shell(workbench)

      {:error, reason} ->
        socket
        |> assign(:shell_overlay, nil)
        |> append_output_entry(sidebar_delete_title("scripts"), inspect(reason), nil, "error")
        |> reload_shell(socket.assigns.workbench)
    end
  end

  defp delete_sidebar_template(socket, template_id) do
    case Templates.delete_template(template_id, force: true) do
      {:ok, :deleted} ->
        workbench = Workbench.close_tab(socket.assigns.workbench, :templates, template_id)

        socket
        |> assign(:shell_overlay, nil)
        |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:templates, template_id}))
        |> reload_shell(workbench)

      {:error, reason} ->
        socket
        |> assign(:shell_overlay, nil)
        |> append_output_entry(sidebar_delete_title("templates"), inspect(reason), nil, "error")
        |> reload_shell(socket.assigns.workbench)
    end
  end

  defp delete_sidebar_chat(socket, conversation_id) do
    case AI.delete_chat_conversation(conversation_id) do
      {:ok, :deleted} ->
        workbench = Workbench.close_tab(socket.assigns.workbench, :chat, conversation_id)

        socket
        |> assign(:shell_overlay, nil)
        |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:chat, conversation_id}))
        |> reload_shell(workbench)

      {:error, reason} ->
        socket
        |> assign(:shell_overlay, nil)
        |> append_output_entry(sidebar_delete_title("chat"), inspect(reason), nil, "error")
        |> reload_shell(socket.assigns.workbench)
    end
  end

  defp delete_sidebar_import(socket, definition_id) do
    case ImportDefinitions.delete_definition(definition_id) do
      {:ok, :deleted} ->
        workbench = Workbench.close_tab(socket.assigns.workbench, :import, definition_id)

        socket
        |> assign(:shell_overlay, nil)
        |> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:import, definition_id}))
        |> clear_import_editor_state(definition_id)
        |> reload_shell(workbench)

      {:error, reason} ->
        socket
        |> assign(:shell_overlay, nil)
        |> append_output_entry(sidebar_delete_title("import"), inspect(reason), nil, "error")
        |> reload_shell(socket.assigns.workbench)
    end
  end

  defp clear_import_editor_state(socket, definition_id) do
    socket
    |> assign(
      :import_editor_analysis_states,
      Map.delete(socket.assigns.import_editor_analysis_states, definition_id)
    )
    |> assign(
      :import_editor_analysis_task_refs,
      Map.delete(socket.assigns.import_editor_analysis_task_refs, definition_id)
    )
    |> assign(
      :import_editor_execution_states,
      Map.delete(socket.assigns.import_editor_execution_states, definition_id)
    )
    |> assign(
      :import_editor_execution_task_refs,
      Map.delete(socket.assigns.import_editor_execution_task_refs, definition_id)
    )
    |> assign(:import_editor_sections, Map.delete(socket.assigns.import_editor_sections, definition_id))
    |> assign(
      :import_editor_taxonomy_edits,
      Map.delete(socket.assigns.import_editor_taxonomy_edits, definition_id)
    )
    |> assign(
      :import_editor_model_selectors_open,
      Map.delete(socket.assigns.import_editor_model_selectors_open, definition_id)
    )
    |> assign(
      :import_editor_selected_models,
      Map.delete(socket.assigns.import_editor_selected_models, definition_id)
    )
  end

  defp sidebar_delete_target(socket, route, id, fallback_title) do
    active_project_id = socket.assigns.projects.active_project_id

    case route do
      "post" ->
        case Posts.get_post(id) do
          %{project_id: ^active_project_id} = post ->
            {:ok, present_title(fallback_title) || present_title(post.title) || present_title(post.slug) || id}

          _other ->
            {:error, :not_found}
        end

      "media" ->
        case Media.get_media(id) do
          %{project_id: ^active_project_id} = media ->
            {:ok,
             present_title(fallback_title) || present_title(media.title) ||
               present_title(media.original_name) || id}

          _other ->
            {:error, :not_found}
        end

      "scripts" ->
        case Scripts.get_script(id) do
          %{project_id: ^active_project_id} = script ->
            {:ok, present_title(fallback_title) || present_title(script.title) || id}

          _other ->
            {:error, :not_found}
        end

      "templates" ->
        case Templates.get_template(id) do
          %{project_id: ^active_project_id} = template ->
            {:ok, present_title(fallback_title) || present_title(template.title) || id}

          _other ->
            {:error, :not_found}
        end

      "chat" ->
        case AI.get_chat_conversation(id) do
          %{title: title} -> {:ok, present_title(fallback_title) || present_title(title) || id}
          _other -> {:error, :not_found}
        end

      "import" ->
        case ImportDefinitions.get_definition(id) do
          %{project_id: ^active_project_id} = definition ->
            {:ok, present_title(fallback_title) || present_title(definition.name) || id}

          _other ->
            {:error, :not_found}
        end

      _other ->
        {:error, :unsupported_route}
    end
  end

  defp sidebar_delete_title("chat"), do: translated("sidebar.chat.deleteConversation")
  defp sidebar_delete_title("post"), do: translated("Delete") <> " " <> translated("Post")
  defp sidebar_delete_title("media"), do: translated("Delete") <> " " <> translated("Media")
  defp sidebar_delete_title("scripts"), do: translated("Delete") <> " " <> translated("Script")
  defp sidebar_delete_title("templates"), do: translated("Delete") <> " " <> translated("Template")
  defp sidebar_delete_title("import"), do: translated("Delete") <> " " <> translated("Import")
  defp sidebar_delete_title(_route), do: translated("Delete")

  defp present_title(value) when is_binary(value) do
    case String.trim(value) do
      "" -> nil
      trimmed -> trimmed
    end
  end

  defp present_title(_value), do: nil
end
