• Panel for reviewing, filtering, sorting, and managing manga match results. Supports manual search, acceptance, rejection, alternative selection, and batch operations.

    Parameters

    Returns Element

    Rendered matching panel with filtering, sorting, and management controls.

    export function MangaMatchingPanel({
    matches,
    onManualSearch,
    onAcceptMatch,
    onRejectMatch,
    onSelectAlternative,
    onResetToPending,
    searchQuery,
    onSetMatchedToPending,
    isSetMatchedToPendingDisabled,
    isLoadingInitial = false,
    selectedMatchIds,
    onToggleSelection,
    onSelectAll,
    onClearSelection,
    }: Readonly<MangaMatchingPanelProps>) {
    const [currentPage, setCurrentPage] = useState(1);
    const [statusFilters, setStatusFilters] = useState<StatusFiltersState>({
    isMatchedVisible: true,
    isPendingVisible: true,
    isManualVisible: true,
    isSkippedVisible: true,
    });
    const [searchTerm, setSearchTerm] = useState("");
    const [advancedFilters, setAdvancedFilters] = useState<AdvancedMatchFilters>(
    defaultAdvancedFilters,
    );
    const [userPresets, setUserPresets] = useState<FilterPreset[]>([]);
    const [isFuzzySearchEnabled, setIsFuzzySearchEnabled] = useState(true);
    const searchInputRef = useRef<HTMLInputElement>(null);
    const containerRef = useRef<HTMLDivElement>(null);
    const lastExternalSearchQuery = useRef<string | undefined>(undefined);

    type SortField = "title" | "status" | "confidence" | "chaptersRead";
    const [sortOption, setSortOption] = useState<{
    field: SortField;
    direction: "asc" | "desc";
    }>({ field: "title", direction: "asc" });

    const itemsPerPage = 10;
    const [isSkippingEmptyMatches, setIsSkippingEmptyMatches] = useState(false);
    const [isAcceptingAllMatches, setIsAcceptingAllMatches] = useState(false);
    const [isReSearchingNoMatches, setIsReSearchingNoMatches] = useState(false);
    const [isResettingSkippedToPending, setIsResettingSkippedToPending] =
    useState(false);
    const [isResettingMatchedToPending] = useState(false);

    // Track which match IDs are currently being updated for visual feedback
    const [updatingMatchIds, setUpdatingMatchIds] = useState<Set<number>>(
    new Set(),
    );

    // Helper to check if a specific match is updating
    const isMatchUpdating = useCallback(
    (matchId: number): boolean => {
    return updatingMatchIds.has(matchId);
    },
    [updatingMatchIds],
    );

    /**
    * Clears the loading state for specified match IDs after a visual delay.
    * @param ids - Array of match IDs to clear from updating state
    */
    const clearLoadingStateAfterDelay = (ids: number[]): void => {
    setTimeout(() => {
    setUpdatingMatchIds((prev) => {
    const newSet = new Set(prev);
    for (const id of ids) {
    newSet.delete(id);
    }
    return newSet;
    });
    }, 500);
    };

    /**
    * Adds match IDs to the updating state set.
    * @param ids - Array of match IDs to mark as updating
    */
    const addToUpdatingSet = (ids: number[]): void => {
    setUpdatingMatchIds((prev) => {
    const newSet = new Set(prev);
    for (const id of ids) {
    newSet.add(id);
    }
    return newSet;
    });
    };

    /**
    * Wraps a handler callback to show/hide loading state for a specific match.
    * Provides visual feedback during optimistic updates and background persistence.
    *
    * @param handler - Original callback handler
    * @param getMatchId - Function to extract match ID from the parameter
    * @returns Wrapped handler that tracks updating state
    * @internal
    */
    const wrapHandlerWithLoadingState = useCallback(
    <T,>(
    handler: ((arg: T) => void | Promise<void>) | undefined,
    getMatchId: (arg: T) => number | number[],
    ): ((arg: T) => void | Promise<void>) | undefined => {
    if (!handler) return undefined;

    return (arg: T) => {
    const matchIds = getMatchId(arg);
    const ids = Array.isArray(matchIds) ? matchIds : [matchIds];

    // Add to updating set
    addToUpdatingSet(ids);

    // Call the original handler
    const result = handler(arg);

    // If it's a promise, wait for it; otherwise, clear loading state after a brief delay
    if (result instanceof Promise) {
    result.finally(() => {
    clearLoadingStateAfterDelay(ids);
    });
    } else {
    clearLoadingStateAfterDelay(ids);
    }
    };
    },
    [],
    );

    // Wrapped handlers that provide loading state feedback
    const wrappedOnAcceptMatch = wrapHandlerWithLoadingState(
    onAcceptMatch,
    (match) => {
    if ("isBatchOperation" in match && match.isBatchOperation) {
    return match.matches.map((m) => m.kenmeiManga.id);
    }
    return (match as MangaMatchResult).kenmeiManga.id;
    },
    );

    const wrappedOnRejectMatch = wrapHandlerWithLoadingState(
    onRejectMatch,
    (match) => {
    if ("isBatchOperation" in match && match.isBatchOperation) {
    return match.matches.map((m) => m.kenmeiManga.id);
    }
    return (match as MangaMatchResult).kenmeiManga.id;
    },
    );

    const wrappedOnResetToPending = wrapHandlerWithLoadingState(
    onResetToPending,
    (match) => {
    if ("isBatchOperation" in match && match.isBatchOperation) {
    return match.matches.map((m) => m.kenmeiManga.id);
    }
    return (match as MangaMatchResult).kenmeiManga.id;
    },
    );

    // State for collapsible sections
    const [isAdvancedFiltersOpen, setIsAdvancedFiltersOpen] = useState(false);

    // State for adult content settings and blur management
    const [shouldBlurAdultContent, setShouldBlurAdultContent] = useState(true);
    const [unblurredImages, setUnblurredImages] = useState<Set<string>>(
    new Set(),
    );

    // State for Comick search setting (disabled - Comick temporarily unavailable)
    // eslint-disable-next-line
    const [isComickSearchEnabled, setIsComickSearchEnabled] = useState(false);
    // State for MangaDex search setting
    const [isMangaDexSearchEnabled, setIsMangaDexSearchEnabled] = useState(true);

    // Load blur settings from match config
    useEffect(() => {
    const loadBlurSettings = async () => {
    const matchConfig = getMatchConfig();
    setShouldBlurAdultContent(matchConfig.blurAdultContent);
    // Comick is temporarily disabled, keep it false
    setIsComickSearchEnabled(false);
    setIsMangaDexSearchEnabled(matchConfig.enableMangaDexSearch);
    };
    loadBlurSettings();
    }, []);

    // Load advanced filters from storage
    useEffect(() => {
    const loadAdvancedFilters = () => {
    const savedFilters = getMatchFilters();
    setAdvancedFilters(savedFilters);
    };
    loadAdvancedFilters();
    }, []);

    // Load user presets from storage
    useEffect(() => {
    const loadUserPresets = () => {
    const presets = getFilterPresets();
    setUserPresets(presets);
    };
    loadUserPresets();
    }, []);

    // Save advanced filters to storage (debounced)
    useEffect(() => {
    const timeoutId = setTimeout(() => {
    saveMatchFilters(advancedFilters);
    }, 500); // 500ms debounce

    return () => clearTimeout(timeoutId);
    }, [advancedFilters]);

    // Helper functions for adult content handling
    const isAdultContent = (manga: AniListManga | undefined | null) => {
    return manga?.isAdult === true;
    };

    const shouldBlurImage = (mangaId: string) => {
    return shouldBlurAdultContent && !unblurredImages.has(mangaId);
    };

    const toggleImageBlur = (mangaId: string) => {
    setUnblurredImages((prev) => {
    const newSet = new Set(prev);
    if (newSet.has(mangaId)) {
    newSet.delete(mangaId);
    } else {
    newSet.add(mangaId);
    }
    return newSet;
    });
    };

    // Handler for toggling Comick search setting
    const handleComickSearchToggle = async (enabled: boolean) => {
    setIsComickSearchEnabled(enabled);
    try {
    const currentConfig = getMatchConfig();
    const updatedConfig = {
    ...currentConfig,
    enableComickSearch: enabled,
    };
    saveMatchConfig(updatedConfig);
    } catch (error) {
    console.error(
    "[MatchingPanel] Failed to save Comick search setting:",
    error,
    );
    // Revert the state if saving failed
    setIsComickSearchEnabled(!enabled);
    }
    };

    // Handler for toggling MangaDex search setting
    const handleMangaDexSearchToggle = async (enabled: boolean) => {
    setIsMangaDexSearchEnabled(enabled);
    try {
    const currentConfig = getMatchConfig();
    const updatedConfig = {
    ...currentConfig,
    enableMangaDexSearch: enabled,
    };
    saveMatchConfig(updatedConfig);
    } catch (error) {
    console.error(
    "[MatchingPanel] Failed to save MangaDex search setting:",
    error,
    );
    // Revert the state if saving failed
    setIsMangaDexSearchEnabled(!enabled);
    }
    };

    // Selection helper
    const isMatchSelected = useCallback(
    (matchId: number): boolean => {
    return selectedMatchIds?.has(matchId) ?? false;
    },
    [selectedMatchIds],
    );

    // Handler for opening external links in the default browser
    const handleOpenExternal = (url: string) => async (e: React.MouseEvent) => {
    e.preventDefault();
    const result = await openExternalSafe(url);
    if (!result.success) {
    console.error(
    "[MangaMatchingPanel] Failed to open external URL:",
    result.error,
    );
    }
    };

    // Handler for advanced filter changes
    const handleAdvancedFiltersChange = useCallback(
    (newFilters: AdvancedMatchFilters) => {
    setAdvancedFilters(newFilters);
    // Reset to first page when filters change
    setCurrentPage(1);
    },
    [],
    );

    // Handler for removing individual filters
    const handleRemoveFilter = useCallback(
    (
    filterType: "confidence" | "format" | "genre" | "status" | "year" | "tag",
    value?: string,
    ) => {
    setAdvancedFilters((prev) => {
    switch (filterType) {
    case "confidence":
    return { ...prev, confidence: { min: 0, max: 100 } };
    case "format":
    return {
    ...prev,
    formats: prev.formats.filter((f) => f !== value),
    };
    case "genre":
    return { ...prev, genres: prev.genres.filter((g) => g !== value) };
    case "status":
    return {
    ...prev,
    publicationStatuses: prev.publicationStatuses.filter(
    (s) => s !== value,
    ),
    };
    case "year":
    return { ...prev, yearRange: { min: null, max: null } };
    case "tag":
    return {
    ...prev,
    tags: (prev.tags || []).filter((t) => t !== value),
    };
    default:
    return prev;
    }
    });
    },
    [],
    );

    // Handler for clearing all advanced filters
    const handleClearAllFilters = useCallback(() => {
    setAdvancedFilters(defaultAdvancedFilters);
    setCurrentPage(1);
    }, []);

    // Handler for saving a preset
    const handleSavePreset = useCallback(
    (name: string, description?: string) => {
    const newPreset = addFilterPreset({
    name,
    description,
    filters: advancedFilters,
    });
    setUserPresets((prev) => [...prev, newPreset]);
    console.log("[MangaMatchingPanel] Saved filter preset:", name);
    },
    [advancedFilters],
    );

    // Handler for applying a preset
    const handleApplyPreset = useCallback((preset: FilterPreset) => {
    setAdvancedFilters(preset.filters);
    setCurrentPage(1);
    console.log("[MangaMatchingPanel] Applied filter preset:", preset.name);
    }, []);

    // Handler for deleting a preset
    const handleDeletePreset = useCallback((presetId: string) => {
    const success = deleteFilterPreset(presetId);
    if (success) {
    setUserPresets((prev) => prev.filter((p) => p.id !== presetId));
    console.log("[MangaMatchingPanel] Deleted filter preset:", presetId);
    }
    }, []);

    // Process matches to filter out Light Novels from alternatives
    const processedMatches = useMemo(() => {
    return matches.map((match) => {
    // Ensure manga has an ID - if missing, generate one based on title
    if (match.kenmeiManga.id === undefined) {
    // Create a simple hash from the title
    const generatedId = match.kenmeiManga.title
    .split("")
    .reduce(
    (hash, char) => (hash << 5) - hash + (char.codePointAt(0) ?? 0),
    0,
    );
    match = {
    ...match,
    kenmeiManga: {
    ...match.kenmeiManga,
    id: Math.abs(generatedId),
    },
    };
    }

    // Filter out Light Novels from anilistMatches
    const filteredAltMatches = match.anilistMatches
    ? match.anilistMatches.filter(
    (m) =>
    m.manga &&
    m.manga.format !== "NOVEL" &&
    m.manga.format !== "LIGHT_NOVEL",
    )
    : [];

    // If the selected match is a Light Novel, clear it
    const newSelectedMatch =
    match.selectedMatch &&
    (match.selectedMatch.format === "NOVEL" ||
    match.selectedMatch.format === "LIGHT_NOVEL")
    ? undefined
    : match.selectedMatch;

    // Return a new object with filtered matches
    return {
    ...match,
    anilistMatches: filteredAltMatches,
    selectedMatch: newSelectedMatch,
    };
    });
    }, [matches]);

    // Extract unique values for filter options
    const availableGenres = useMemo(
    () => extractUniqueGenres(processedMatches),
    [processedMatches],
    );
    const availableFormats = useMemo(
    () => extractUniqueFormats(processedMatches),
    [processedMatches],
    );
    const availableStatuses = useMemo(
    () => extractUniqueStatuses(processedMatches),
    [processedMatches],
    );
    const availableTags = useMemo(
    () => extractUniqueTags(processedMatches, advancedFilters.tags),
    [processedMatches, advancedFilters.tags],
    );
    const yearRange = useMemo(
    () => extractYearRange(processedMatches),
    [processedMatches],
    );

    // Apply status filters first (fast, main thread)
    const statusFilteredMatches = useMemo(() => {
    return processedMatches.filter((match) => {
    if (match.kenmeiManga.id === undefined) return false;

    const statusMatch =
    (match.status === "matched" && statusFilters.isMatchedVisible) ||
    (match.status === "pending" && statusFilters.isPendingVisible) ||
    (match.status === "manual" && statusFilters.isManualVisible) ||
    (match.status === "skipped" && statusFilters.isSkippedVisible);

    return statusMatch;
    });
    }, [processedMatches, statusFilters]);

    // Apply advanced filters using worker (with debouncing)
    const { filteredMatches: advancedFilteredMatches, isFiltering } =
    useAdvancedFilter(
    statusFilteredMatches,
    advancedFilters,
    300, // 300ms debounce
    );

    // Prepare search query for fuzzy search (extract text tokens only)
    const fuzzySearchQuery = useMemo(() => {
    const trimmedSearchTerm = searchTerm.trim();
    if (!trimmedSearchTerm || !isFuzzySearchEnabled) {
    return "";
    }

    // Check if search contains query syntax
    const tokens = parseQuerySyntax(trimmedSearchTerm);
    const hasFieldTokens = tokens.some((t) => t.type === "field");
    const textTokens = tokens.filter((t) => t.type === "text");

    if (hasFieldTokens && textTokens.length > 0) {
    // If there are both field and text tokens, use only text tokens for fuzzy search
    return textTokens.map((t) => t.value).join(" ");
    } else if (!hasFieldTokens) {
    // No field tokens, use the entire search term
    return searchTerm;
    }

    // Only field tokens, no fuzzy search
    return "";
    }, [searchTerm, isFuzzySearchEnabled]);

    // Use the async fuzzy search hook for large datasets
    const { results: fuzzySearchedMatches } = useFuzzySearchResults(
    fuzzySearchQuery,
    advancedFilteredMatches,
    {
    debounceMs: 150,
    enabled: isFuzzySearchEnabled && fuzzySearchQuery.length > 0,
    },
    );

    // Apply filters in order: status ✓ → advanced ✓ → search (with field tokens)
    const filteredMatches = useMemo(() => {
    let filtered = fuzzySearchedMatches;

    // Apply field-based filters from query syntax if present
    if (searchTerm && isFuzzySearchEnabled) {
    const tokens = parseQuerySyntax(searchTerm);
    const hasFieldTokens = tokens.some((t) => t.type === "field");

    if (hasFieldTokens) {
    // Apply query syntax to filters (temporarily)
    const queryFilters = applyQueryToFilters(tokens, advancedFilters);
    filtered = filterByAdvancedCriteria(filtered, queryFilters);
    }
    } else if (searchTerm.trim() && !isFuzzySearchEnabled) {
    const trimmedTerm = searchTerm.trim().toLowerCase();
    // Fallback to substring search when fuzzy search is disabled
    filtered = filtered.filter(
    (match) =>
    match.kenmeiManga.title.toLowerCase().includes(trimmedTerm) ||
    match.selectedMatch?.title?.english
    ?.toLowerCase()
    .includes(trimmedTerm) ||
    match.selectedMatch?.title?.romaji
    ?.toLowerCase()
    .includes(trimmedTerm),
    );
    }

    return filtered;
    }, [fuzzySearchedMatches, searchTerm, advancedFilters, isFuzzySearchEnabled]);

    // Sort the filtered matches
    const sortedMatches = [...filteredMatches].sort((a, b) => {
    // Declare variables outside switch to avoid linter errors
    let titleA: string, titleB: string;
    let statusA: number, statusB: number;
    let confidenceA: number, confidenceB: number;
    let chaptersA: number, chaptersB: number;

    // Define status priority for sorting (matched > manual > conflict > pending > skipped)
    const statusPriority: Record<string, number> = {
    matched: 1,
    manual: 2,
    conflict: 3,
    pending: 4,
    skipped: 5,
    };

    switch (sortOption.field) {
    case "title":
    titleA = a.kenmeiManga.title.toLowerCase();
    titleB = b.kenmeiManga.title.toLowerCase();
    return sortOption.direction === "asc"
    ? titleA.localeCompare(titleB)
    : titleB.localeCompare(titleA);

    case "status":
    statusA = statusPriority[a.status] || 999;
    statusB = statusPriority[b.status] || 999;
    return sortOption.direction === "asc"
    ? statusA - statusB
    : statusB - statusA;

    case "confidence":
    // Get confidence scores
    // Entries with actual matches but 0 confidence should rank higher than entries with no matches at all
    confidenceA =
    a.anilistMatches?.length && a.anilistMatches.length > 0
    ? (a.anilistMatches[0].confidence ?? 0)
    : -1; // No matches at all should be lowest

    confidenceB =
    b.anilistMatches?.length && b.anilistMatches.length > 0
    ? (b.anilistMatches[0].confidence ?? 0)
    : -1; // No matches at all should be lowest

    return sortOption.direction === "asc"
    ? confidenceA - confidenceB
    : confidenceB - confidenceA;

    case "chaptersRead":
    chaptersA = a.kenmeiManga.chaptersRead || 0;
    chaptersB = b.kenmeiManga.chaptersRead || 0;
    return sortOption.direction === "asc"
    ? chaptersA - chaptersB
    : chaptersB - chaptersA;

    default:
    return 0;
    }
    });

    // Pagination logic
    const totalPages = Math.max(
    1,
    Math.ceil(sortedMatches.length / itemsPerPage),
    );
    const currentMatches = sortedMatches.slice(
    (currentPage - 1) * itemsPerPage,
    currentPage * itemsPerPage,
    );

    // Auto-adjust current page if filters change
    useEffect(() => {
    // If current page is out of bounds, adjust it
    if (currentPage > totalPages) {
    setCurrentPage(Math.max(1, totalPages));
    }
    }, [statusFilters, advancedFilters, searchTerm, totalPages, currentPage]);

    // Focus search input when pressing Ctrl+F
    useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
    if ((e.ctrlKey || e.metaKey) && e.key === "f") {
    e.preventDefault();
    searchInputRef.current?.focus();
    }
    };

    globalThis.addEventListener("keydown", handleKeyDown);
    return () => globalThis.removeEventListener("keydown", handleKeyDown);
    }, []);

    // Handle Ctrl+A to select all visible items on current page
    useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
    // Don't trigger when typing in input fields or text areas
    const target = e.target as HTMLElement;
    if (
    target.tagName === "INPUT" ||
    target.tagName === "TEXTAREA" ||
    target.isContentEditable
    ) {
    return;
    }

    const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
    const modifier = isMac ? e.metaKey : e.ctrlKey;

    // Ctrl/Cmd+A to select all visible items on current page
    if (
    modifier &&
    e.key === "a" &&
    currentMatches.length > 0 &&
    onSelectAll
    ) {
    e.preventDefault();
    const visibleIds = currentMatches.map((match) => match.kenmeiManga.id);
    onSelectAll(visibleIds);
    }
    };

    globalThis.addEventListener("keydown", handleKeyDown);
    return () => globalThis.removeEventListener("keydown", handleKeyDown);
    }, [currentMatches, onSelectAll]);

    // Count statistics
    const matchStats = {
    total: matches.length,
    matched: matches.filter((m) => m.status === "matched").length,
    pending: matches.filter((m) => m.status === "pending").length,
    manual: matches.filter((m) => m.status === "manual").length,
    skipped: matches.filter((m) => m.status === "skipped").length,
    };

    // Handle pagination
    const goToPage = (page: number) => {
    if (page < 1) page = 1;
    if (page > totalPages) page = totalPages;
    setCurrentPage(page);
    };

    // Add keyboard navigation for pagination
    useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
    // Skip if we're in an input field
    if (
    e.target instanceof HTMLInputElement ||
    e.target instanceof HTMLTextAreaElement
    ) {
    return;
    }

    // Handle arrow keys and Home/End keys
    if (e.key === "ArrowLeft" && currentPage > 1) {
    goToPage(currentPage - 1);
    } else if (e.key === "ArrowRight" && currentPage < totalPages) {
    goToPage(currentPage + 1);
    } else if (e.key === "Home" && currentPage > 1) {
    goToPage(1);
    } else if (e.key === "End" && currentPage < totalPages) {
    goToPage(totalPages);
    }
    };

    // Add event listener
    document.addEventListener("keydown", handleKeyDown);

    // Cleanup
    return () => {
    document.removeEventListener("keydown", handleKeyDown);
    };
    }, [currentPage, totalPages]);

    // Sync external searchQuery with local searchTerm
    useEffect(() => {
    if (
    searchQuery !== undefined &&
    searchQuery.trim() !== "" &&
    searchQuery !== lastExternalSearchQuery.current
    ) {
    setSearchTerm(searchQuery);
    lastExternalSearchQuery.current = searchQuery;
    }
    }, [searchQuery]);

    // Confidence badge extracted to separate component

    // Helper function to format status text nicely - moved outside for reuse
    const formatStatusText = (status: string | undefined): string => {
    if (!status) return "Unknown";

    // Handle cases with underscores or spaces
    return status
    .split(/[_\s]+/) // Split by underscores or spaces
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
    .join(" ");
    };

    // Handle keyboard navigation for item selection
    const handleKeyDown = (e: React.KeyboardEvent, callback: () => void) => {
    if (e.key === "Enter" || e.key === " ") {
    e.preventDefault();
    callback();
    }
    };

    // Function to skip all pending matches with no results
    const handleSkipEmptyMatches = () => {
    // Set processing state to disable the button
    setIsSkippingEmptyMatches(true);

    // Find all pending manga with no matches
    const pendingWithNoMatches = matches.filter(
    (match) =>
    match.status === "pending" &&
    (!match.anilistMatches || match.anilistMatches.length === 0),
    );

    console.debug(
    `[MatchingPanel] Skipping ${pendingWithNoMatches.length} pending manga with no matches`,
    );

    // Skip all matches at once if possible
    if (pendingWithNoMatches.length > 0 && onRejectMatch) {
    // Pass only the affected matches to the handler
    onRejectMatch({ isBatchOperation: true, matches: pendingWithNoMatches });

    // Short delay to ensure state updates have time to process
    setTimeout(() => {
    setIsSkippingEmptyMatches(false);
    }, 500);
    } else {
    // Reset processing state if no matching items found
    setIsSkippingEmptyMatches(false);
    }
    };

    // Get count of pending matches with no results
    const emptyMatchesCount = matches.filter(
    (match) =>
    match.status === "pending" &&
    (!match.anilistMatches || match.anilistMatches.length === 0),
    ).length;

    // Function to accept all pending matches with main matches
    const handleAcceptAllPendingMatches = () => {
    // Set processing state to disable the button
    setIsAcceptingAllMatches(true);

    // Find all pending manga with valid main matches
    const pendingWithMatches = matches.filter(
    (match) =>
    match.status === "pending" &&
    match.anilistMatches &&
    match.anilistMatches.length > 0,
    );

    console.debug(
    `[MatchingPanel] Accepting ${pendingWithMatches.length} pending manga with matches`,
    );

    // Accept all matches at once if possible
    if (pendingWithMatches.length > 0 && onAcceptMatch) {
    // Pass only the affected matches to the handler
    onAcceptMatch({ isBatchOperation: true, matches: pendingWithMatches });

    // Short delay to ensure state updates have time to process
    setTimeout(() => {
    setIsAcceptingAllMatches(false);
    }, 500);
    } else {
    // Reset processing state if no matching items found
    setIsAcceptingAllMatches(false);
    }
    };

    // Get count of pending matches with valid matches
    const pendingMatchesCount = matches.filter(
    (match) =>
    match.status === "pending" &&
    match.anilistMatches &&
    match.anilistMatches.length > 0,
    ).length;

    // Function to handle re-searching all manga without matches regardless of status
    const handleReSearchNoMatches = () => {
    // Set processing state to disable the button
    setIsReSearchingNoMatches(true);

    // Find all manga without any matches regardless of status
    const mangaWithoutMatches = matches.filter(
    (match) => !match.anilistMatches || match.anilistMatches.length === 0,
    );

    console.debug(
    `[MatchingPanel] Re-searching ${mangaWithoutMatches.length} manga without any matches`,
    );

    if (mangaWithoutMatches.length > 0) {
    // Extract the Kenmei manga objects from the matches
    const kenmeiMangaToResearch = mangaWithoutMatches.map(
    (match) => match.kenmeiManga,
    );

    // Create a custom event to trigger the re-search process at the page level
    // This allows us to use the same efficient batch processing as the "Fresh Search" button
    const customEvent = new CustomEvent("reSearchEmptyMatches", {
    detail: {
    mangaToResearch: kenmeiMangaToResearch,
    },
    });

    // Dispatch the event to be handled by the MatchingPage component
    globalThis.dispatchEvent(customEvent);

    // Reset processing state after a short delay
    setTimeout(() => {
    setIsReSearchingNoMatches(false);
    }, 1000);
    } else {
    // Reset processing state if no matching items found
    setIsReSearchingNoMatches(false);
    }
    };

    // Get count of manga without any matches
    const noMatchesCount = matches.filter(
    (match) => !match.anilistMatches || match.anilistMatches.length === 0,
    ).length;

    // Function to handle resetting all skipped manga to pending
    const handleResetSkippedToPending = () => {
    // Set processing state to disable the button
    setIsResettingSkippedToPending(true);

    // Find all skipped manga
    const skippedManga = matches.filter((match) => match.status === "skipped");

    console.debug(
    `[MatchingPanel] Resetting ${skippedManga.length} skipped manga to pending status`,
    );

    // Reset all these manga to pending status
    if (skippedManga.length > 0 && onResetToPending) {
    // Pass only the affected matches to the handler
    onResetToPending({ isBatchOperation: true, matches: skippedManga });

    // Short delay to ensure state updates have time to process
    setTimeout(() => {
    setIsResettingSkippedToPending(false);
    }, 500);
    } else {
    // Reset processing state if no matching items found
    setIsResettingSkippedToPending(false);
    }
    };

    // Get count of skipped manga
    const skippedMangaCount = matches.filter(
    (match) => match.status === "skipped",
    ).length;

    // Count matched manga for bulk reset label
    const matchedCount = matches.filter((m) => m.status === "matched").length;

    // Batch selection descriptive text
    let batchSelectionText = "Select multiple matches for batch operations";
    if (selectedMatchIds && selectedMatchIds.size > 0) {
    batchSelectionText = `${selectedMatchIds.size} match${selectedMatchIds.size === 1 ? "" : "es"} selected`;
    }

    return (
    <div
    className="flex flex-col space-y-4"
    ref={containerRef}
    tabIndex={-1} // Make div focusable but not in tab order
    >
    {/* PRIMARY CONTROLS - Always visible, grouped for proximity */}
    <MatchStatisticsCard
    matchStats={matchStats}
    noMatchesCount={noMatchesCount}
    searchTerm={searchTerm}
    onSearchTermChange={(value) => setSearchTerm(value)}
    searchInputRef={searchInputRef}
    isFiltering={isFiltering}
    />

    {/* PRIMARY CONTROLS - Filters, Sort, and Bulk Actions grouped in a centered 3x1 grid */}
    <div className="flex w-full justify-center">
    <div className="grid w-full max-w-7xl items-stretch gap-4 lg:grid-cols-3">
    <div className="h-full w-full">
    <MatchFilterControls
    statusFilters={statusFilters}
    setStatusFilters={setStatusFilters}
    matchStats={matchStats}
    />
    </div>

    <div className="h-full w-full">
    <MatchSortControls
    sortOption={sortOption}
    setSortOption={setSortOption}
    />
    </div>

    <div className="h-full w-full">
    <MatchBulkActions
    emptyMatchesCount={emptyMatchesCount}
    onSkipEmptyMatches={handleSkipEmptyMatches}
    isSkippingEmptyMatches={isSkippingEmptyMatches}
    noMatchesCount={noMatchesCount}
    onReSearchNoMatches={handleReSearchNoMatches}
    isReSearchingNoMatches={isReSearchingNoMatches}
    skippedMangaCount={skippedMangaCount}
    onResetSkippedToPending={handleResetSkippedToPending}
    isResettingSkippedToPending={isResettingSkippedToPending}
    pendingMatchesCount={pendingMatchesCount}
    onAcceptAllPendingMatches={handleAcceptAllPendingMatches}
    isAcceptingAllMatches={isAcceptingAllMatches}
    onSetMatchedToPending={onSetMatchedToPending}
    isResettingMatchedToPending={isResettingMatchedToPending}
    isSetMatchedToPendingDisabled={isSetMatchedToPendingDisabled}
    matchedCount={matchedCount}
    />
    </div>
    </div>
    </div>

    {/* ADVANCED CONTROLS - Collapsible for progressive disclosure */}
    <Collapsible
    open={isAdvancedFiltersOpen}
    onOpenChange={setIsAdvancedFiltersOpen}
    >
    <Card className="relative overflow-hidden rounded-3xl border border-white/40 bg-white/75 shadow-xl shadow-slate-900/5 backdrop-blur dark:border-slate-800/60 dark:bg-slate-900/70">
    <CardHeader className="relative z-10 transition-colors">
    <div className="flex items-center justify-between">
    <div className="flex items-center gap-3">
    <div className="flex h-9 w-9 items-center justify-center rounded-full bg-violet-500/10 text-violet-500">
    <ListFilter className="h-4 w-4" />
    </div>
    <div className="text-left">
    <CardTitle className="flex items-center gap-2 text-base font-semibold text-slate-900 dark:text-white">
    Advanced Filters & Settings
    <Badge
    variant="secondary"
    className="rounded-full bg-violet-500/10 px-2 py-0.5 text-xs text-violet-600 dark:text-violet-300"
    >
    Advanced
    </Badge>
    </CardTitle>
    <p className="text-xs text-slate-500 dark:text-slate-400">
    Fine-tune your results with additional criteria
    </p>
    </div>
    </div>
    <CollapsibleTrigger asChild>
    <Button variant="ghost" size="sm" className="h-8 w-8 p-0">
    <CollapsibleChevron isExpanded={isAdvancedFiltersOpen} />
    </Button>
    </CollapsibleTrigger>
    </div>
    </CardHeader>

    <CollapsibleContent>
    <CardContent className="relative z-10 space-y-4 pb-5">
    {/* Fuzzy Search Toggle */}
    <div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50/50 p-3 dark:border-slate-700 dark:bg-slate-800/30">
    <div className="flex items-center gap-2">
    <Sparkles className="h-4 w-4 text-indigo-500" />
    <label
    htmlFor="fuzzy-search-toggle"
    className="text-sm font-medium text-slate-700 dark:text-slate-300"
    >
    Fuzzy Search
    </label>
    </div>
    <input
    id="fuzzy-search-toggle"
    type="checkbox"
    checked={isFuzzySearchEnabled}
    onChange={(e) => setIsFuzzySearchEnabled(e.target.checked)}
    className="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
    title="Enable fuzzy matching for more flexible search results"
    />
    </div>

    {/* Advanced Filter Panel */}
    <AdvancedFilterPanel
    filters={advancedFilters}
    onFiltersChange={handleAdvancedFiltersChange}
    availableGenres={availableGenres}
    availableFormats={availableFormats}
    availableStatuses={availableStatuses}
    availableTags={availableTags}
    yearRange={yearRange}
    matchCount={filteredMatches.length}
    userPresets={userPresets}
    onSavePreset={handleSavePreset}
    onApplyPreset={handleApplyPreset}
    onDeletePreset={handleDeletePreset}
    />

    {/* Alternative Search Settings */}
    <AlternativeSearchSettingsCard
    isMangaDexSearchEnabled={isMangaDexSearchEnabled}
    onComickSearchToggle={handleComickSearchToggle}
    onMangaDexSearchToggle={handleMangaDexSearchToggle}
    />
    </CardContent>
    </CollapsibleContent>
    </Card>
    </Collapsible>

    {/* Active Filter Chips - Show what's currently filtering */}
    <FilterChips
    filters={advancedFilters}
    onRemoveFilter={handleRemoveFilter}
    onClearAll={handleClearAllFilters}
    />

    {/* Batch Selection Controls - Contextual, only when feature is available */}
    {onSelectAll && (
    <Card className="relative mb-4 overflow-hidden rounded-3xl border border-white/40 bg-white/75 shadow-xl shadow-slate-900/5 backdrop-blur dark:border-slate-800/60 dark:bg-slate-900/70">
    <CardHeader className="relative z-10">
    <div className="flex flex-wrap items-center justify-between gap-3">
    <div>
    <CardTitle className="text-base font-semibold text-slate-900 dark:text-white">
    Batch Selection
    </CardTitle>
    <p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
    {batchSelectionText}
    </p>
    </div>
    <div className="flex gap-2">
    {selectedMatchIds && selectedMatchIds.size > 0 ? (
    <button
    type="button"
    onClick={onClearSelection}
    className="rounded-xl bg-slate-500/10 px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-500/20 dark:text-slate-300 dark:hover:bg-slate-500/30"
    title="Clear Selection (Esc)"
    >
    Clear Selection
    </button>
    ) : (
    <button
    type="button"
    onClick={() => {
    if (onSelectAll) {
    // Only select items currently visible on this page
    const visibleIds = currentMatches.map(
    (match) => match.kenmeiManga.id,
    );
    onSelectAll(visibleIds);
    }
    }}
    className="rounded-xl bg-blue-500/10 px-4 py-2 text-sm font-medium text-blue-700 transition-colors hover:bg-blue-500/20 dark:text-blue-300 dark:hover:bg-blue-500/30"
    title="Select All Visible Matches on This Page (Ctrl+A)"
    >
    Select All Visible
    </button>
    )}
    </div>
    </div>
    </CardHeader>
    </Card>
    )}

    {/* CONTENT AREA */}
    {/* Confidence accuracy notice - moved closer to cards */}
    <div className="relative mb-4 overflow-hidden rounded-3xl border border-amber-400/40 bg-amber-50/80 p-5 shadow-xl shadow-amber-500/10 backdrop-blur dark:border-amber-500/30 dark:bg-amber-900/25">
    <div className="pointer-events-none absolute -top-20 left-10 h-48 w-48 rounded-full bg-amber-400/25 blur-3xl" />
    <div className="pointer-events-none absolute -bottom-16 right-8 h-40 w-40 rounded-full bg-red-400/15 blur-3xl" />
    <div className="relative z-10 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
    <div className="flex items-start gap-3">
    <span className="flex h-10 w-10 items-center justify-center rounded-full border border-amber-500/30 bg-amber-100/70 text-amber-600 shadow-inner dark:border-amber-500/30 dark:bg-amber-900/40 dark:text-amber-200">
    <ShieldAlert className="h-5 w-5" />
    </span>
    <div className="space-y-2">
    <h3 className="text-base font-semibold text-amber-900 dark:text-amber-100">
    Confidence percentages are advisory
    </h3>
    <p className="text-sm text-amber-900/90 dark:text-amber-100/90">
    Treat the score as a hintnot a guarantee. Always glance over
    matches with similar titles, alternate editions, or multiple
    adaptations.
    </p>
    <p className="text-sm text-amber-900/90 dark:text-amber-100/90">
    Found a confidence outlier? Let me know so I can improve the
    matching for everyone.
    </p>
    </div>
    </div>
    <div className="flex flex-col gap-3 md:items-end">
    <Badge className="rounded-full border border-amber-500/20 bg-amber-200/70 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-amber-700 dark:border-amber-500/30 dark:bg-amber-900/40 dark:text-amber-100">
    Manual review recommended
    </Badge>
    <a
    href="https://github.com/RLAlpha49/KenmeiToAnilist/issues/new?template=confidence_mismatch.md"
    target="_blank"
    rel="noopener noreferrer"
    onClick={handleOpenExternal(
    "https://github.com/RLAlpha49/KenmeiToAnilist/issues/new?template=confidence_mismatch.md",
    )}
    className="inline-flex items-center gap-2 rounded-full border border-amber-500/40 bg-transparent px-4 py-2 text-sm font-semibold text-amber-700 transition hover:bg-amber-500/10 hover:text-amber-800 dark:border-amber-500/40 dark:text-amber-200 dark:hover:bg-amber-500/20"
    >
    <AlertTriangle className="h-4 w-4" />
    Report a mismatch
    </a>
    </div>
    </div>
    </div>

    {/* Match list */}
    <div className="space-y-6" aria-live="polite">
    {(() => {
    let matchListContent: React.ReactNode;
    if (isLoadingInitial && matches.length === 0) {
    matchListContent = (
    <motion.div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
    {Array.from({ length: 6 }).map((_, index) => (
    <motion.div
    key={`skeleton-card-${index + 1}`}
    initial={{ opacity: 0, y: 10 }}
    animate={{ opacity: 1, y: 0 }}
    transition={{ duration: 0.2, delay: index * 0.05 }}
    >
    <SkeletonCard />
    </motion.div>
    ))}
    </motion.div>
    );
    } else if (currentMatches.length > 0) {
    matchListContent = (
    <AnimatePresence mode="popLayout">
    {currentMatches.map((match, index) => {
    // Generate a unique key using index as fallback when ID is undefined
    const uniqueKey = match.kenmeiManga.id
    ? `${match.kenmeiManga.id}-${match.status}`
    : `index-${index}-${match.status}-${match.kenmeiManga.title?.replaceAll(" ", "_") || "unknown"}`;

    // Extract styles using the helper function
    const { borderColorClass, statusBgColorClass, glowClass } =
    getMatchCardStyles(match);

    return (
    <MatchCard
    key={uniqueKey}
    match={match}
    uniqueKey={uniqueKey}
    borderColorClass={borderColorClass}
    statusBgColorClass={statusBgColorClass}
    glowClass={glowClass}
    formatStatusText={formatStatusText}
    handleOpenExternal={handleOpenExternal}
    handleKeyDown={handleKeyDown}
    isAdultContent={isAdultContent}
    shouldBlurImage={shouldBlurImage}
    toggleImageBlur={toggleImageBlur}
    onManualSearch={onManualSearch}
    onAcceptMatch={wrappedOnAcceptMatch}
    onRejectMatch={wrappedOnRejectMatch}
    onSelectAlternative={onSelectAlternative}
    onResetToPending={wrappedOnResetToPending}
    isSelected={isMatchSelected(match.kenmeiManga.id)}
    onToggleSelection={
    onToggleSelection
    ? () => onToggleSelection(match.kenmeiManga.id)
    : undefined
    }
    isUpdating={isMatchUpdating(match.kenmeiManga.id)}
    />
    );
    })}
    </AnimatePresence>
    );
    } else {
    matchListContent = (
    <div className="flex flex-col items-center justify-center rounded-lg border border-gray-200 bg-white p-8 text-center dark:border-gray-700 dark:bg-gray-800">
    <p className="text-gray-600 dark:text-gray-400">
    {searchTerm
    ? `No manga matches found for "${searchTerm}" with the current filters.`
    : "No manga matches found with the current filters."}
    </p>
    </div>
    );
    }
    return matchListContent;
    })()}
    </div>

    {/* Pagination */}
    {totalPages > 1 && (
    <div
    className="mt-4 flex flex-col items-center justify-between space-y-3 sm:flex-row sm:space-y-0"
    aria-label="Pagination navigation"
    >
    <div className="text-sm text-gray-700 dark:text-gray-300">
    Showing{" "}
    <span className="font-medium">
    {(currentPage - 1) * itemsPerPage + 1}
    </span>{" "}
    to{" "}
    <span className="font-medium">
    {Math.min(currentPage * itemsPerPage, sortedMatches.length)}
    </span>{" "}
    of <span className="font-medium">{sortedMatches.length}</span>{" "}
    results
    <span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
    (Use ← → arrow keys to navigate, Home/End for first/last page)
    </span>
    </div>
    <div className="inline-flex items-center space-x-1">
    <button
    className="inline-flex items-center rounded-md border border-gray-300 bg-white px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
    onClick={() => goToPage(1)}
    disabled={currentPage === 1}
    aria-label="First page"
    title="Go to first page"
    >
    <span className="text-xs">«</span>
    <span className="sr-only sm:not-sr-only sm:ml-1">First</span>
    </button>

    <button
    className="inline-flex items-center rounded-md border border-gray-300 bg-white px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
    onClick={() => goToPage(currentPage - 1)}
    disabled={currentPage === 1}
    aria-label="Previous page"
    >
    <ChevronLeft className="h-4 w-4" aria-hidden="true" />
    <span className="sr-only sm:not-sr-only sm:ml-1">Previous</span>
    </button>

    <span className="mx-2 inline-flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">
    <span className="font-medium">{currentPage}</span>
    <span className="mx-1">/</span>
    <span className="font-medium">{totalPages}</span>
    </span>

    <button
    className="inline-flex items-center rounded-md border border-gray-300 bg-white px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
    onClick={() => goToPage(currentPage + 1)}
    disabled={currentPage === totalPages}
    aria-label="Next page"
    >
    <span className="sr-only sm:not-sr-only sm:mr-1">Next</span>
    <ChevronRight className="h-4 w-4" aria-hidden="true" />
    </button>

    <button
    className="inline-flex items-center rounded-md border border-gray-300 bg-white px-2 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
    onClick={() => goToPage(totalPages)}
    disabled={currentPage === totalPages}
    aria-label="Last page"
    title="Go to last page"
    >
    <span className="sr-only sm:not-sr-only sm:mr-1">Last</span>
    <span className="text-xs">»</span>
    </button>
    </div>
    </div>
    )}
    </div>
    );
    }