// Phase 4 deliverable: full navigational/functional TUI parity with the
// original Ink app -- a sidebar with all 7 sections (categories +
// Downloads + Seeding), interactive Downloads/Seeding lists, the folder and
// trackers prompts, a help overlay, and a splash screen. Visual fidelity is
// deliberately simplified relative to the original's exact Ink box-model
// layout math (dynamic row budgeting, per-cell logo sheen, animated
// progress-bar sheen) -- see the Phase 4 commit message for what's
// approximated vs. what's dropped to Phase 6.

#include <algorithm>
#include <array>
#include <cstdlib>
#include <csignal>
#include <filesystem>
#include <iostream>
#include <string>

#include <ftxui/component/component.hpp>
#include <ftxui/component/component_options.hpp>
#include <ftxui/component/screen_interactive.hpp>
#include <ftxui/dom/elements.hpp>

#include "torlinkc/config/config.hpp"
#include "torlinkc/config/folder.hpp"
#include "torlinkc/config/trackers.hpp"
#include "torlinkc/engine/queue.hpp"
#include "torlinkc/sources/registry.hpp"
#include "torlinkc/sources/types.hpp"
#include "torlinkc/ui/app_state.hpp"
#include "torlinkc/ui/clock.hpp"
#include "torlinkc/ui/colored_window.hpp"
#include "torlinkc/ui/engine_thread.hpp"
#include "torlinkc/ui/filter.hpp"
#include "torlinkc/ui/keymap.hpp"
#include "torlinkc/ui/logo.hpp"
#include "torlinkc/ui/move.hpp"
#include "torlinkc/ui/progress_bar.hpp"
#include "torlinkc/ui/search_aggregator.hpp"
#include "torlinkc/ui/sort.hpp"
#include "torlinkc/ui/spinner.hpp"
#include "torlinkc/ui/theme.hpp"
#include "torlinkc/util/format.hpp"
#include "torlinkc/util/open_folder.hpp"

using namespace ftxui;
using namespace torlinkc;
using namespace torlinkc::ui;

namespace {

// Container::Vertical child indices (main.cpp owns this ordering). Modals
// are children of the same container (not a separate Container::Tab) and
// routed to via the same focusedIndex selector -- see syncFocus() and the
// class-less comment above mainContainer's construction for why.
constexpr int kFocusSidebar = 0;
constexpr int kFocusSearch = 1;
constexpr int kFocusResults = 2;
constexpr int kFocusDownloads = 3;
constexpr int kFocusSeeding = 4;
constexpr int kFocusSpinner = 5;
constexpr int kFocusFolderPrompt = 6;
constexpr int kFocusTrackersPrompt = 7;
constexpr int kFocusHelp = 8;

const std::array<Section, 7> kSidebarSections = {
    Section::All, Section::Games, Section::Movies, Section::TV, Section::Anime, Section::Downloads, Section::Seeding,
};

std::string homeDir() {
  const char* h = std::getenv("HOME");
  return h ? h : "/tmp";
}


std::string sourcesStatusLine(const AppState& state) {
  int failed = 0;
  std::string firstError;
  for (const auto& [id, st] : state.perSource) {
    if (st.error) {
      failed++;
      if (firstError.empty()) firstError = id + ": " + st.code.value_or("error");
    }
  }
  std::string line = std::to_string(state.doneSources) + "/" + std::to_string(state.totalSources) + " sources";
  if (failed > 0) {
    line += "  (" + std::to_string(failed) + " down";
    if (!firstError.empty()) line += ", e.g. " + firstError;
    line += ")";
  }
  return line;
}

// FTXUI's default InputOption::transform inverts the *entire* field while
// focused (white-on-default becomes a solid white bar) -- TextField.tsx only
// ever inverted the single cursor character. Input's own Render() already
// draws a native blinking bar cursor at the right position regardless of
// transform, so this just needs to stop painting the rest of the field.
Element plainInputTransform(InputState state) {
  Element e = state.element | color(palette::text);
  if (state.is_placeholder) e = e | dim;
  return e;
}

std::string filterBarText(const AppState& state) {
  std::string s = "[" + sectionLabel(state.section) + "]";
  s += state.hideDead ? "  hide-dead:on" : "  hide-dead:off";
  s += "  sort:" + sortLabel(state.sort);
  return s;
}

Element renderFooter(const AppState& state) {
  const auto hints = footerHints(state.region, state.section, state.downloadFocus, state.seedFocus);
  Elements parts;
  for (std::size_t i = 0; i < hints.size(); ++i) {
    if (i > 0) parts.push_back(text("   ") | dim);
    parts.push_back(text(hints[i].keys) | color(palette::alt));
    parts.push_back(text(" " + hints[i].label) | dim);
  }
  return hbox(std::move(parts));
}

Element renderHelpOverlay() {
  Elements groups;
  for (const auto& g : helpGroups()) {
    Elements lines;
    lines.push_back(text(g.title) | bold | color(palette::accent));
    for (const auto& h : g.hints) {
      lines.push_back(hbox({
          text(h.keys) | color(palette::alt) | size(WIDTH, EQUAL, 22),
          text(h.label) | dim,
      }));
    }
    groups.push_back(vbox(std::move(lines)));
  }
  Elements spaced;
  for (std::size_t i = 0; i < groups.size(); ++i) {
    if (i > 0) spaced.push_back(text(""));
    spaced.push_back(groups[i]);
  }
  spaced.push_back(text(""));
  spaced.push_back(text("Your downloaded files always stay on disk.") | dim);
  spaced.push_back(text("Press any key to close") | dim);
  return coloredWindow(text("Keyboard"), vbox(std::move(spaced)), palette::accent);
}

}  // namespace

int main() {
  std::signal(SIGPIPE, SIG_IGN);

  Config config = loadConfig();
  const auto sources = sourceById();  // stateless; safe to capture by value into lambdas below

  ScreenInteractive screen = ScreenInteractive::Fullscreen();
  AppState state;
  EngineThread engine(screen, state);
  SearchAggregator aggregator(screen, state);

  auto syncFocus = [&] {
    if (state.showHelp) {
      state.focusedIndex = kFocusHelp;
      return;
    }
    if (state.editingFolder) {
      state.focusedIndex = kFocusFolderPrompt;
      return;
    }
    if (state.editingTrackers) {
      state.focusedIndex = kFocusTrackersPrompt;
      return;
    }
    if (state.view == View::Splash) {
      state.focusedIndex = kFocusSearch;
      return;
    }
    if (state.region == Region::Sidebar) {
      state.focusedIndex = kFocusSidebar;
      return;
    }
    switch (state.section) {
      case Section::Downloads:
        state.focusedIndex = kFocusDownloads;
        break;
      case Section::Seeding:
        state.focusedIndex = kFocusSeeding;
        break;
      default:
        state.focusedIndex = kFocusResults;
        break;
    }
  };

  // The sidebar Menu's own highlighted row is driven by sidebarCursor, not
  // section -- anything that changes section from outside the sidebar itself
  // (e.g. downloadSelected jumping to Downloads) must go through here too, or
  // the sidebar visibly disagrees with what's on screen.
  auto setSection = [&](Section s) {
    state.section = s;
    const auto it = std::find(kSidebarSections.begin(), kSidebarSections.end(), s);
    if (it != kSidebarSections.end()) state.sidebarCursor = static_cast<int>(it - kSidebarSections.begin());
  };

  auto refreshVisible = [&] {
    const Category cat = sectionToCategory(state.section).value_or(Category::All);
    std::vector<TorrentResult> byCategory;
    for (const auto& r : state.results) {
      const auto it = sources.find(r.source);
      const bool inCategory = it == sources.end() || sourceInCategory(it->second, cat);
      if (inCategory) byCategory.push_back(r);
    }
    auto filtered = filterResults(byCategory, state.hideDead, sources);
    state.visibleResults = sortResults(filtered, state.sort);
    if (state.selectedResult >= static_cast<int>(state.visibleResults.size())) {
      state.selectedResult = std::max(0, static_cast<int>(state.visibleResults.size()) - 1);
    }
  };

  aggregator.onResultsChanged = [&](bool hadNoResultsBefore) {
    refreshVisible();
    if (hadNoResultsBefore && !state.results.empty() && state.view == View::Browser &&
        state.region == Region::Content) {
      state.focusedIndex = kFocusResults;
    }
  };

  auto totalDownloadsRows = [&] { return static_cast<int>(state.items.size() + state.history.size()); };
  auto seedFor = [&](const std::string& id) -> std::optional<SeedItem> {
    for (const auto& s : state.seeds) {
      if (s.id == id) return s;
    }
    return std::nullopt;
  };

  auto downloadSelected = [&] {
    if (state.selectedResult < 0 || state.selectedResult >= static_cast<int>(state.visibleResults.size())) return;
    const TorrentResult r = state.visibleResults[static_cast<std::size_t>(state.selectedResult)];
    AddInput input;
    input.id = r.infoHash;
    input.name = r.name;
    input.magnet = r.magnet;
    input.source = r.source;
    input.sizeBytes = r.sizeBytes;
    const std::string dir = config.downloadDir;
    engine.post([input, dir](DownloadQueue& q) { q.add(input, dir); });
    state.notice = "queued: " + stripControl(r.name);
    setSection(Section::Downloads);
    syncFocus();
  };

  // --- Search box + results (shared by Splash and the browsing sections) --

  InputOption searchOptions;
  searchOptions.multiline = false;
  searchOptions.transform = plainInputTransform;
  searchOptions.on_enter = [&] {
    aggregator.search(state.query);
    state.view = View::Browser;
    syncFocus();
    // Kick the shared UI clock -- OnAnimation only fires in response to a
    // pending animation-frame request, not on an ordinary redraw.
    screen.RequestAnimationFrame();
  };
  Component searchInput = Input(&state.query, "search torrents (all sources)...", searchOptions);

  // A hand-built table (ported from Results.tsx's column layout) rather than
  // a stock Menu bound to one flat string per row: independent per-column
  // alignment/coloring needs one Element per cell, and a fixed column width
  // means a long name can never change the list's row height/width (what
  // was making it visibly jump as the cursor moved onto a long entry) --
  // FTXUI just clips a too-narrow box, no truncate()/ellipsis needed.
  constexpr int kResultsGutterWidth = 2;
  constexpr int kResultsSizeWidth = 10;
  constexpr int kResultsSeedWidth = 9;
  constexpr int kResultsSrcWidth = 4;

  Component resultsBase = Renderer([&]() -> Element {
    const bool focused = state.focusedIndex == kFocusResults;
    const int numW = std::max(2, static_cast<int>(std::to_string(state.visibleResults.size()).size()));

    auto headerCell = [](const std::string& s, int w) { return text(s) | bold | dim | size(WIDTH, EQUAL, w) | align_right; };
    Elements rows;
    rows.push_back(hbox({
        text("") | size(WIDTH, EQUAL, kResultsGutterWidth),
        headerCell("#", numW),
        text(" "),
        text("Name") | bold | dim | flex,
        text(" "),
        headerCell("Size", kResultsSizeWidth),
        text(" "),
        headerCell("Seed:Lch", kResultsSeedWidth),
        text(" "),
        headerCell("Src", kResultsSrcWidth),
    }));

    for (std::size_t i = 0; i < state.visibleResults.size(); ++i) {
      const auto& r = state.visibleResults[i];
      const bool here = focused && static_cast<int>(i) == state.selectedResult;

      Element pointer = text(here ? icon::pointer : "") | color(palette::accent) | size(WIDTH, EQUAL, kResultsGutterWidth);
      Element num = text(std::to_string(i + 1)) | dim | size(WIDTH, EQUAL, numW) | align_right;

      Element name = text(stripControl(r.name)) | flex;
      name = here ? (name | bold | color(palette::accent)) : (name | dim);

      Element sizeEl = text(r.sizeBytes > 0 ? formatBytes(static_cast<double>(r.sizeBytes)) : "-") |
                       size(WIDTH, EQUAL, kResultsSizeWidth) | align_right;
      sizeEl = here ? (sizeEl | bold) : (sizeEl | dim);

      const std::string seedLch =
          r.seeders > 0 || r.leechers > 0 ? formatCount(r.seeders) + ":" + formatCount(r.leechers) : "-";
      Element seedEl = text(seedLch) | size(WIDTH, EQUAL, kResultsSeedWidth) | align_right;
      if (r.seeders > 0) seedEl = seedEl | color(palette::good);
      seedEl = here ? (seedEl | bold) : (seedEl | dim);

      const auto ss = sourceStyle(r.source);
      Element srcEl =
          text(ss.tag) | color(ss.color) | size(WIDTH, EQUAL, kResultsSrcWidth) | align_right | (here ? bold : dim);

      Element row = hbox({pointer, num, text(" "), name, text(" "), sizeEl, text(" "), seedEl, text(" "), srcEl});
      rows.push_back(here ? select(row) : row);
    }
    return vbox(std::move(rows)) | yframe;
  });

  Component resultsComponent = CatchEvent(resultsBase, [&](Event event) -> bool {
    if (state.visibleResults.empty()) return false;
    const int total = static_cast<int>(state.visibleResults.size());
    if (event == Event::ArrowUp || event == Event::Character('k')) {
      state.selectedResult = wrapStep(state.selectedResult, -1, total);
      return true;
    }
    if (event == Event::ArrowDown || event == Event::Character('j')) {
      state.selectedResult = wrapStep(state.selectedResult, 1, total);
      return true;
    }
    if (event == Event::Return || event == Event::Character('d')) {
      downloadSelected();
      return true;
    }
    if (event == Event::Character('s')) {
      state.sort = nextSort(state.sort);
      refreshVisible();
      return true;
    }
    if (event == Event::Character('z')) {
      state.hideDead = !state.hideDead;
      refreshVisible();
      return true;
    }
    return false;
  });

  Component spinner = MakeSpinner([&] { return state.searching; });
  Component animatedLogo = MakeAnimatedLogo([&] { return state.view == View::Splash; });
  Component uiClock = MakeUiClock(&state.uiClock, [&] { return state.view == View::Browser; });

  // --- Downloads section -----------------------------------------------

  Component downloadsBase = Renderer([&]() -> Element {
    const bool focused = state.region == Region::Content && state.section == Section::Downloads;
    const int total = totalDownloadsRows();
    if (total == 0) {
      return text("No downloads yet. Find something and press d to grab it.") | dim;
    }
    Elements rows;
    for (std::size_t i = 0; i < state.items.size(); ++i) {
      const auto& it = state.items[i];
      const bool here = focused && static_cast<int>(i) == state.downloadsCursor;
      Color statusColor = palette::accent;
      std::string statusIcon = icon::down;
      if (it.status == DownloadStatus::Failed) {
        statusColor = palette::bad;
        statusIcon = icon::error;
      } else if (it.status == DownloadStatus::Paused || it.status == DownloadStatus::Queued) {
        statusColor = palette::paused;
        statusIcon = it.status == DownloadStatus::Paused ? icon::pause : icon::pending;
      }
      std::string right;
      if (it.status == DownloadStatus::Downloading) {
        right = std::to_string(it.progress) + "%  " + formatBytes(it.speed) + "/s  peers=" + std::to_string(it.peers);
      } else if (it.status == DownloadStatus::Paused) {
        right = "paused  " + std::to_string(it.progress) + "%";
      } else if (it.status == DownloadStatus::Queued) {
        right = "queued";
      } else {
        right = it.error.value_or("failed");
      }
      Element row1 = hbox({
          text(here ? icon::pointer : " ") | color(palette::accent),
          text(" "),
          text(statusIcon) | color(statusColor),
          text(" "),
          text(truncate(stripControl(it.name), 40)) | (here ? bold : dim) | flex,
          text("  "),
          text(right) | (it.status == DownloadStatus::Failed ? color(palette::bad) : dim),
      });
      const std::optional<float> sweep = it.status == DownloadStatus::Downloading
                                              ? std::optional(progressSheenCenter(state.uiClock, kProgressBarWidth))
                                              : std::nullopt;
      Element row2 = hbox({text("    "), renderProgressBar(it.progress, kProgressBarWidth, statusColor, sweep)});
      rows.push_back(vbox({row1, row2}));
    }
    if (!state.history.empty()) {
      rows.push_back(text("Recently downloaded (" + std::to_string(state.history.size()) + ")") | dim);
      for (std::size_t i = 0; i < state.history.size(); ++i) {
        const auto& h = state.history[i];
        const bool here = focused && static_cast<int>(state.items.size() + i) == state.downloadsCursor;
        rows.push_back(hbox({
            text(here ? icon::pointer : " ") | color(palette::accent),
            text(" "),
            text(icon::done) | color(palette::good),
            text(" "),
            text(truncate(stripControl(h.name), 40)) | (here ? bold : dim) | flex,
            text("  "),
            text(h.sizeBytes > 0 ? formatBytes(h.sizeBytes) : "-") | dim,
        }));
      }
    }
    return vbox(std::move(rows));
  });

  Component downloadsComponent = CatchEvent(downloadsBase, [&](Event event) -> bool {
    if (!(state.region == Region::Content && state.section == Section::Downloads)) return false;
    const int total = totalDownloadsRows();
    if (total == 0) return false;
    const bool inActive = state.downloadsCursor < static_cast<int>(state.items.size());

    if (event == Event::ArrowUp || event == Event::Character('k')) {
      state.downloadsCursor = wrapStep(state.downloadsCursor, -1, total);
      return true;
    }
    if (event == Event::ArrowDown || event == Event::Character('j')) {
      state.downloadsCursor = wrapStep(state.downloadsCursor, 1, total);
      return true;
    }
    if (event == Event::Character('f')) {
      engine.post([](DownloadQueue& q) { q.retryFailed(); });
      return true;
    }
    if (event == Event::Character('e')) {
      const std::string dir = inActive ? state.items[static_cast<std::size_t>(state.downloadsCursor)].dir
                                        : state.history[static_cast<std::size_t>(state.downloadsCursor) -
                                                         state.items.size()]
                                              .dir;
      if (!openFolder(dir)) state.notice = "Couldn't open folder: " + dir;
      return true;
    }
    if (inActive) {
      const std::string id = state.items[static_cast<std::size_t>(state.downloadsCursor)].id;
      if (event == Event::Character('c')) {
        engine.post([id](DownloadQueue& q) { q.cancel(id); });
        return true;
      }
      if (event == Event::Character('p')) {
        engine.post([id](DownloadQueue& q) { q.togglePause(id); });
        return true;
      }
    } else {
      const HistoryItem h = state.history[static_cast<std::size_t>(state.downloadsCursor) - state.items.size()];
      if (event == Event::Character('d') || event == Event::Return) {
        AddInput input;
        input.id = h.id;
        input.name = h.name;
        input.magnet = h.magnet;
        input.source = h.source;
        input.sizeBytes = h.sizeBytes;
        const std::string dir = config.downloadDir;
        engine.post([input, dir](DownloadQueue& q) { q.add(input, dir); });
        state.notice = "Added: " + stripControl(h.name);
        return true;
      }
      if (event == Event::Character('c')) {
        const std::string id = h.id;
        engine.post([id](DownloadQueue& q) { q.removeHistory(id); });
        return true;
      }
    }
    return false;
  });

  // --- Seeding section ----------------------------------------------------

  Component seedingBase = Renderer([&]() -> Element {
    const bool focused = state.region == Region::Content && state.section == Section::Seeding;
    if (state.history.empty()) {
      return text("Nothing here yet. Downloads start seeding automatically when they finish.") | dim;
    }
    Elements rows;
    for (std::size_t i = 0; i < state.history.size(); ++i) {
      const auto& h = state.history[i];
      const bool here = focused && static_cast<int>(i) == state.seedingCursor;
      const auto seed = seedFor(h.id);
      std::string statusText = "ready";
      Color statusColor = palette::alt;
      bool dimIt = true;
      if (seed) {
        if (seed->status == SeedStatus::Seeding) {
          statusText = std::string(icon::up) + formatBytes(seed->uploadSpeed) + "/s peers=" +
                       std::to_string(seed->peers);
          statusColor = palette::good;
          dimIt = false;
        } else if (seed->status == SeedStatus::Paused) {
          statusText = "paused";
        } else {
          statusText = "file gone";
          statusColor = palette::warn;
          dimIt = false;
        }
      }
      Element statusEl = text(statusText) | color(statusColor);
      if (dimIt) statusEl = statusEl | dim;
      rows.push_back(hbox({
          text(here ? icon::pointer : " ") | color(palette::accent),
          text(" "),
          text(truncate(stripControl(h.name), 40)) | (here ? bold : dim) | flex,
          text("  "),
          text(h.sizeBytes > 0 ? formatBytes(h.sizeBytes) : "-") | dim,
          text("  "),
          statusEl,
      }));
    }
    return vbox(std::move(rows));
  });

  Component seedingComponent = CatchEvent(seedingBase, [&](Event event) -> bool {
    if (!(state.region == Region::Content && state.section == Section::Seeding)) return false;
    const int total = static_cast<int>(state.history.size());
    if (total == 0) return false;

    if (event == Event::ArrowUp || event == Event::Character('k')) {
      state.seedingCursor = wrapStep(state.seedingCursor, -1, total);
      return true;
    }
    if (event == Event::ArrowDown || event == Event::Character('j')) {
      state.seedingCursor = wrapStep(state.seedingCursor, 1, total);
      return true;
    }
    const HistoryItem h = state.history[static_cast<std::size_t>(state.seedingCursor)];
    if (event == Event::Character('p')) {
      engine.post([h](DownloadQueue& q) { q.toggleSeeding(h); });
      return true;
    }
    if (event == Event::Character('c')) {
      const std::string id = h.id;
      engine.post([id](DownloadQueue& q) { q.removeHistory(id); });
      return true;
    }
    if (event == Event::Character('e')) {
      if (!openFolder(h.dir)) state.notice = "Couldn't open folder: " + h.dir;
      return true;
    }
    return false;
  });

  // --- Modal overlays: folder prompt, trackers prompt, help --------------
  //
  // These become ordinary children of mainContainer (added after it's
  // constructed, below), routed to via the same focusedIndex selector as
  // everything else -- not a separate Container::Tab. An earlier version
  // used Tab for this and the close/cancel keys silently did nothing: Tab's
  // event routing did not reliably reach a child with no focusable
  // descendant of its own (a bare Renderer+CatchEvent, as help's dismiss
  // handler is). Container::Vertical with an explicit selector is the
  // mechanism already proven to route events correctly by index throughout
  // this file, so the modals reuse it instead of a second, less-well-
  // understood primitive. Declared here (before mainRenderer) since
  // mainRenderer's Element needs to call their ->Render() directly when a
  // modal is showing.

  InputOption folderOptions;
  folderOptions.multiline = false;
  folderOptions.transform = plainInputTransform;
  folderOptions.on_enter = [&] {
    state.editingFolder = false;
    syncFocus();
    const std::string dir = normalizeDownloadDir(state.folderPromptText, homeDir());
    if (dir.empty() || dir == config.downloadDir) return;
    std::error_code ec;
    std::filesystem::create_directories(dir, ec);
    if (ec) {
      state.notice = "Couldn't use folder: " + dir;
      return;
    }
    config.downloadDir = dir;
    saveConfig(config);
    state.notice = "Download folder: " + dir;
  };
  Component folderInput = Input(&state.folderPromptText, "~/Downloads/torlink", folderOptions);
  folderInput = CatchEvent(folderInput, [&](Event event) {
    if (event == Event::Escape) {
      state.editingFolder = false;
      syncFocus();
      return true;
    }
    return false;
  });

  InputOption trackersOptions;
  trackersOptions.multiline = false;
  trackersOptions.transform = plainInputTransform;
  trackersOptions.on_enter = [&] {
    state.editingTrackers = false;
    syncFocus();
    const auto list = parseTrackers(state.trackersPromptText);
    config.trackers = list;
    saveConfig(config);
    engine.post([list](DownloadQueue& q) { q.setTrackers(list); });
    state.notice = list.empty() ? "Cleared extra trackers."
                                 : "Saved " + std::to_string(list.size()) + " tracker(s).";
  };
  Component trackersInput =
      Input(&state.trackersPromptText, "udp://tracker.example:1337/announce, https://...", trackersOptions);
  trackersInput = CatchEvent(trackersInput, [&](Event event) {
    if (event == Event::Escape) {
      state.editingTrackers = false;
      syncFocus();
      return true;
    }
    return false;
  });

  // No visible content of its own -- the help overlay is rendered inline by
  // mainRenderer when state.showHelp is set. This just needs to be a focus
  // target that swallows the next keypress to close it.
  Component helpDismiss = Renderer([] { return text(""); });
  helpDismiss = CatchEvent(helpDismiss, [&](Event event) {
    // Event::Custom is EngineThread's periodic redraw signal (posted every
    // engine tick, ~500ms), not a real keypress -- without excluding it,
    // help closes itself within one tick of opening, since "any event"
    // otherwise includes synthetic ones the app posts to itself.
    if (event == Event::Custom) return false;
    state.showHelp = false;
    syncFocus();
    return true;
  });

  // --- Sidebar --------------------------------------------------------

  MenuOption sidebarOptions = MenuOption::Vertical();
  sidebarOptions.on_change = [&] {
    if (state.sidebarCursor < 0 || state.sidebarCursor >= static_cast<int>(kSidebarSections.size())) return;
    state.section = kSidebarSections[static_cast<std::size_t>(state.sidebarCursor)];
    refreshVisible();
  };
  sidebarOptions.on_enter = [&] {
    state.region = Region::Content;
    syncFocus();
  };
  Component sidebarMenu = Menu(&state.sidebarLabels, &state.sidebarCursor, sidebarOptions);

  // --- Top-level composition -------------------------------------------

  Component mainContainer = Container::Vertical(
      {sidebarMenu, searchInput, resultsComponent, downloadsComponent, seedingComponent, spinner},
      &state.focusedIndex);

  Component mainRenderer = Renderer(mainContainer, [&] {
    int activeCount = 0;
    for (const auto& it : state.items) {
      if (it.status == DownloadStatus::Downloading) activeCount++;
    }
    int seedingCount = 0;
    for (const auto& s : state.seeds) {
      if (s.status == SeedStatus::Seeding) seedingCount++;
    }
    state.sidebarLabels = {
        "All",
        "Games",
        "Movies",
        "TV",
        "Anime",
        activeCount > 0 ? "Downloads (" + std::to_string(activeCount) + ")" : "Downloads",
        seedingCount > 0 ? "Seeding (" + std::to_string(seedingCount) + ")" : "Seeding",
    };

    // Sets the terminal tab title, mirroring TabTitle.tsx; a side-effect
    // write interleaved with FTXUI's own frame output, same as the original
    // did inside Ink's render cycle.
    std::cout << "\x1b]0;torlinkc" << (activeCount > 0 ? " (" + std::to_string(activeCount) + ")" : "") << "\x07";

    if (state.showHelp) return renderHelpOverlay();
    if (state.editingFolder) {
      return vbox({
          renderLogo(),
          separator() | color(palette::rule),
          coloredWindow(text("default download folder"), folderInput->Render(), palette::accent),
          text("enter: save   esc: cancel") | dim,
      });
    }
    if (state.editingTrackers) {
      return vbox({
          renderLogo(),
          separator() | color(palette::rule),
          coloredWindow(text("extra trackers"),
                        vbox({
                            text(trackersStatus(config.trackers, state.trackersPromptText)) | dim,
                            trackersInput->Render(),
                        }),
                        palette::accent),
          text("enter: save   esc: cancel") | dim,
      });
    }

    if (state.view == View::Splash) {
      const std::string categories = "games  " + std::string(icon::dot) + "  movies  " + icon::dot + "  tv  " +
                                      icon::dot + "  anime";
      return vbox({
                 filler(),
                 animatedLogo->Render() | center,
                 text("") | center,
                 text("A curated, terminal-native torrent downloader.") | color(palette::text) | center,
                 text(categories) | dim | center,
                 text("") | center,
                 coloredWindow(text("Search"), searchInput->Render(), palette::accent) | size(WIDTH, EQUAL, 56) |
                     center,
                 text("") | center,
                 hbox({text("enter") | color(palette::alt), text(" search   ") | dim, text("tab") | color(palette::alt),
                       text(" browse   ") | dim, text("esc") | color(palette::alt), text(" quit") | dim}) |
                     center,
                 filler(),
             }) |
             flex;
    }

    Element content;
    if (state.section == Section::Downloads) {
      const Color c = state.region == Region::Content ? palette::accent : palette::rule;
      content = coloredWindow(text("Downloads"), downloadsComponent->Render(), c);
    } else if (state.section == Section::Seeding) {
      const Color c = state.region == Region::Content ? palette::accent : palette::rule;
      content = coloredWindow(text("Seeding"), seedingComponent->Render(), c);
    } else {
      Elements resultsSection;
      resultsSection.push_back(text(filterBarText(state)) | dim);
      if (state.searching) {
        resultsSection.push_back(hbox({spinner->Render(), text(" " + sourcesStatusLine(state))}));
      } else {
        resultsSection.push_back(text(sourcesStatusLine(state)) | dim);
      }
      if (state.visibleResults.empty()) {
        std::string hint = "no results yet -- type a query and press Enter";
        if (state.searching) hint = "searching...";
        else if (!state.results.empty()) hint = "no results in this category/filter";
        resultsSection.push_back(text(hint) | dim);
      } else {
        resultsSection.push_back(resultsComponent->Render() | flex);
      }
      content = vbox({
          coloredWindow(text("Search"), searchInput->Render(),
                        state.focusedIndex == kFocusSearch ? palette::accent : palette::rule),
          coloredWindow(text(sectionLabel(state.section) + " results"), vbox(std::move(resultsSection)) | flex,
                        state.focusedIndex == kFocusResults ? palette::accent : palette::rule) |
              flex,
      });
    }

    Elements top = {renderLogo()};
    if (!state.notice.empty()) top.push_back(filler());
    if (!state.notice.empty()) top.push_back(text(state.notice) | color(palette::good));

    // Only one divider, below the logo/notice bar -- matches the original
    // (App.tsx's single `showTopRule` Rule); nothing separates the sidebar
    // from the content or sits above the footer there, just spacing.
    return vbox({
               hbox(std::move(top)),
               separator() | color(palette::rule),
               hbox({
                   sidebarMenu->Render() | size(WIDTH, EQUAL, 16),
                   text(" "),
                   content | flex,
               }) | flex,
               renderFooter(state),
           }) |
           flex;
  });

  Component mainWithGlobalKeys = CatchEvent(mainRenderer, [&](Event event) -> bool {
    // The prompt/help component at focusedIndex owns all input while shown
    // (matching the original's `if (editingFolder || editingTrackers ||
    // pendingDownload) return;` at the very top of its global handler) --
    // otherwise, e.g., this handler's own Escape case would fire before the
    // folder prompt's Input ever saw the key press meant to cancel it.
    if (state.showHelp || state.editingFolder || state.editingTrackers) return false;

    const bool textEditing = state.focusedIndex == kFocusSearch;

    if (!textEditing) {
      if (event == Event::Character('q')) {
        screen.Exit();
        return true;
      }
      if (event == Event::Character('?')) {
        state.showHelp = true;
        syncFocus();
        return true;
      }
      if (event == Event::Character('o')) {
        state.folderPromptText = config.downloadDir;
        state.editingFolder = true;
        syncFocus();
        return true;
      }
      if (event == Event::Character('t')) {
        state.trackersPromptText = formatTrackers(config.trackers);
        state.editingTrackers = true;
        syncFocus();
        return true;
      }
    }

    if (event == Event::Tab) {
      if (state.view == View::Splash) {
        // Matches the original's Splash "tab browse" hint: give up on typing
        // a query and browse everything, the same as pressing Enter on an
        // empty search box would. Without this, Tab would leave focus on an
        // empty, unpopulated results list, and further keystrokes would be
        // silently swallowed as global shortcuts instead of reaching a text
        // field or a populated list.
        aggregator.search(state.query);
        state.view = View::Browser;
        screen.RequestAnimationFrame();
      } else {
        state.region = state.region == Region::Sidebar ? Region::Content : Region::Sidebar;
      }
      syncFocus();
      return true;
    }
    if ((event == Event::ArrowRight || (!textEditing && event == Event::Character('l'))) &&
        state.view == View::Browser && state.region == Region::Sidebar) {
      state.region = Region::Content;
      syncFocus();
      return true;
    }
    if ((event == Event::ArrowLeft || (!textEditing && event == Event::Character('h'))) &&
        state.view == View::Browser && state.region == Region::Content) {
      state.region = Region::Sidebar;
      syncFocus();
      return true;
    }
    if (event == Event::Escape) {
      if (state.view == View::Splash) {
        screen.Exit();
        return true;
      }
      if (state.region == Region::Content) {
        state.region = Region::Sidebar;
        syncFocus();
        return true;
      }
      state.view = View::Splash;
      syncFocus();
      // Kick off the logo sweep -- OnAnimation only fires in response to a
      // pending animation-frame request, not on an ordinary redraw.
      screen.RequestAnimationFrame();
      return true;
    }
    return false;
  });

  // Modal overlays (folder prompt, trackers prompt, help) are ordinary
  // children of mainContainer, added below -- see the comment where they're
  // defined for why not a separate Container::Tab.
  mainContainer->Add(folderInput);
  mainContainer->Add(trackersInput);
  mainContainer->Add(helpDismiss);
  // Never a focus target (like `spinner` above) -- just needs to be in the
  // tree so it receives OnAnimation ticks while the splash screen is up.
  mainContainer->Add(animatedLogo);
  mainContainer->Add(uiClock);

  // AppState::focusedIndex defaults to 0 (the sidebar), not the search box
  // Splash needs -- without this, the first keystrokes on launch go nowhere
  // useful (the sidebar Menu ignores character input).
  syncFocus();
  // Kick off the splash logo's sweep -- OnAnimation only fires in response
  // to a pending animation-frame request, not on an ordinary redraw.
  screen.RequestAnimationFrame();

  engine.start(config);
  screen.Loop(mainWithGlobalKeys);
  engine.stop();

  return 0;
}
