class GuiController:
    """Manages GUI event handling and interactions."""
    def __init__(self, state: AppState):
        self.state = state
        self.target_smiles_input = target_smiles_input
        self.fragmenter_choice = fragmenter_choice
        self.file_path_input = file_path_input
        self.parallelize_checkbox = parallelize_checkbox
        self.num_cores_input = num_cores_input
        self.core_factor_input = core_factor_input
        self.run_button = run_button
        self.output_area = output_area
        self.filter_checkbox = filter_checkbox
        self.fragment_count_input = fragment_count_input
        self.display_button = display_button
        self.solution_output_area = solution_output_area
        self.solution_dropdown = solution_dropdown
        self.image_display_area = image_display_area
        self.fragment_comb_dropdown = fragment_comb_dropdown
        self.smiles_display_area = smiles_display_area
        self.prev_smiles_button = prev_smiles_button
        self.smiles_pagination_label = smiles_pagination_label
        self.next_smiles_button = next_smiles_button
        self.sort_smiles_button = sort_smiles_button
    def register_event_handlers(self) -> None:
        """Connects widget events to controller methods."""
        self.parallelize_checkbox.observe(self.handle_parallelize_change, names="value")
        self.filter_checkbox.observe(self.handle_filter_change, names="value")
        self.run_button.on_click(self.run_retrosynthesis_on_click)
        self.fragment_comb_dropdown.observe(self.on_fragment_comb_select, names="value")
        self.prev_smiles_button.on_click(self.on_prev_smiles_click)
        self.next_smiles_button.on_click(self.on_next_smiles_click)
        self.sort_smiles_button.on_click(self.on_sort_smiles_click)
        self.display_button.on_click(self.display_solutions_on_click)
        self.solution_dropdown.observe(self.on_solution_select, names="value")
    def handle_parallelize_change(self, change: dict[str, bool]) -> None:
        """Callback to enable/disable core inputs based on parallelize checkbox."""
        is_parallel = change.get("new", False)
        self.num_cores_input.disabled = not is_parallel
        self.core_factor_input.disabled = not is_parallel
    def handle_filter_change(self, change: dict[str, bool]) -> None:
        """Callback to enable/disable fragment count input based on filter checkbox."""
        is_filtered = change.get("new", False)
        self.fragment_count_input.disabled = not is_filtered
        if not is_filtered:
            self.fragment_count_input.value = None  # type: ignore
    def reset_ui_outputs(self) -> None:
        """Resets output areas, dropdowns, and buttons to their initial states."""
        self.solution_dropdown.options = []
        self.solution_dropdown.value = None
        self.solution_dropdown.disabled = True
        self.image_display_area.clear_output(wait=False)
        self.solution_output_area.clear_output(wait=False)
        self.fragment_comb_dropdown.options = []
        self.fragment_comb_dropdown.value = None
        self.fragment_comb_dropdown.disabled = True
        self.prev_smiles_button.disabled = True
        self.next_smiles_button.disabled = True
        self.smiles_pagination_label.value = "0 of 0"
        self.sort_smiles_button.disabled = True
        self.smiles_display_area.clear_output(wait=False)
        with self.smiles_display_area:
            logger.info("[GUI] Perform an action (Run/Display) to populate this area.")
    def run_retrosynthesis_on_click(self, b: widgets.Button) -> None:
        """Handles the click event for the 'Run Retrosynthesis' button."""
        self.state.reset_run_state()
        self.reset_ui_outputs()  # Reset UI elements
        self.output_area.clear_output(wait=True)  # Clear main output specifically for run
        with self.output_area:
            logger.info("[GUI] Starting retrosynthesis...")
        target: str = self.target_smiles_input.value
        fragmenter_name: str = self.fragmenter_choice.value
        json_path_str: str = self.file_path_input.value
        parallelize: bool = self.parallelize_checkbox.value
        num_cores: int | None = self.num_cores_input.value  # type: ignore
        core_factor: int = self.core_factor_input.value  # type: ignore
        if not target:
            with self.output_area:
                logger.error("[GUI] ERROR: Target SMILES cannot be empty.")
            return
        if json_path_str:
            mol_properties_path = Path(json_path_str)
            if not mol_properties_path.is_file():
                with self.output_area:
                    logger.error(f"[GUI] ERROR: Properties file not found at {mol_properties_path}")
                return
        try:
            logger.info("[GUI] Running fragmentation...")
            fragmenter: Fragmenter | None = None
            if fragmenter_name == "BRICSFragmenter":
                fragmenter = BRICSFragmenter(target)
            elif fragmenter_name == "rBRICSFragmenter":
                fragmenter = rBRICSFragmenter(target)
            else:
                with self.output_area:
                    logger.error(f"[GUI] ERROR: Unknown fragmenter type '{fragmenter_name}'")
                return
            with self.output_area:
                logger.info(f"[GUI] Using Fragmenter: {fragmenter_name}")
                logger.info(f"[GUI] Using Properties: {mol_properties_path}")
                logger.info(f"[GUI] Parallelize: {parallelize}, Num Cores: {num_cores}, Core Factor: {core_factor}")
            retro_tool = Retrosynthesis(
                fragmenter,
                mol_properties_path=mol_properties_path,
                parallelize=parallelize,
                num_cores=num_cores,
                core_factor=core_factor,
            )
            with self.output_area:
                logger.info("[GUI] Running retrosynthesis...")
                retro_tool.fragment_retrosynthesis()
                retro_solution = RetrosynthesisSolution(retro_tool)
                retro_solution.fill_solutions()
                logger.info(f"[GUI] Found {len(retro_solution.solutions)} solution(s).")
            self.state.retro_solution = retro_solution
            self.state.retro_tool = retro_tool
            with self.output_area:
                logger.info("[GUI] Retrosynthesis complete. Ready to display solutions and browse fragment SMILES.")
        except Exception as e:
            with self.output_area:
                logger.error(f"[GUI] ERROR during retrosynthesis: {e}", exc_info=True)
    def update_fragment_comb_dropdown(self, solution: SolutionType | None) -> None:
        """Populates the fragment comb dropdown based on a single solution."""
        with contextlib.suppress(ValueError):
            self.fragment_comb_dropdown.unobserve(self.on_fragment_comb_select, names="value")
        if solution:
            self.fragment_comb_dropdown.options = [(str(comb), comb) for comb in solution]
            self.fragment_comb_dropdown.disabled = False
            self.smiles_display_area.clear_output(wait=False)
            logger.debug("[GUI] Select a fragment combination to view SMILES.")
        else:
            self.fragment_comb_dropdown.options = []
            self.fragment_comb_dropdown.disabled = True
            self.smiles_display_area.clear_output(wait=False)
            logger.debug("[GUI] No fragment combinations in the selected solution.")
        self.fragment_comb_dropdown.value = None
        self.prev_smiles_button.disabled = True
        self.next_smiles_button.disabled = True
        self.smiles_pagination_label.value = "0 of 0"
        self.state.selected_fragment_comb = None
        self.state.current_smiles_list = []
        self.state.current_smiles_index = 0
        self.fragment_comb_dropdown.observe(self.on_fragment_comb_select, names="value")
    def update_smiles_display(self) -> None:
        """Updates the SMILES display area and pagination controls."""
        smiles_list: list[str] = self.state.current_smiles_list
        index: int = self.state.current_smiles_index
        total_smiles: int = len(smiles_list)
        with self.smiles_display_area:
            self.smiles_display_area.clear_output(wait=True)
            if not smiles_list:
                logger.info("[GUI] No SMILES found for this combination.")
                self.smiles_pagination_label.value = "0 of 0"
                self.prev_smiles_button.disabled = True
                self.next_smiles_button.disabled = True
                return
            current_smiles: str = smiles_list[index]
            try:
                mol = Chem.MolFromSmiles(current_smiles)
                if mol:
                    img: PILImage = Draw.MolToImage(mol, size=(300, 300))
                    bio = io.BytesIO()
                    img.save(bio, format="PNG")
                    image_widget = widgets.Image(value=bio.getvalue(), format="png", width=300, height=300)
                    display(image_widget)  # type: ignore
                else:
                    logger.warning(f"[GUI] Invalid SMILES: {current_smiles}")
            except Exception as e:
                logger.error(f"[GUI] Error generating image for SMILES {current_smiles}: {e}")
        self.smiles_pagination_label.value = f"{index + 1} of {total_smiles}"
        self.prev_smiles_button.disabled = index == 0
        self.next_smiles_button.disabled = index >= total_smiles - 1
    def on_fragment_comb_select(self, change: dict[str, str | CombType]) -> None:
        """Handles selection changes in the fragment combination dropdown."""
        if change.get("type") == "change" and change.get("name") == "value":
            selected_comb: CombType = cast(CombType, change.get("new"))
            retro_tool: Retrosynthesis | None = self.state.retro_tool
            if selected_comb is None or retro_tool is None or not hasattr(retro_tool, "comb_bbs_dict"):
                self.state.reset_smiles_viewer_state()
                self.sort_smiles_button.disabled = True
            else:
                smiles_data: set[str] = retro_tool.comb_bbs_dict.get(selected_comb, set())
                smiles_list: list[str] = list(smiles_data)
                self.state.selected_fragment_comb = selected_comb
                self.state.current_smiles_list = smiles_list
                self.state.current_smiles_index = 0
                self.state.is_smiles_sorted = False
                self.sort_smiles_button.disabled = not smiles_list
            self.update_smiles_display()
    def on_prev_smiles_click(self, b: widgets.Button) -> None:
        """Handles clicks on the 'Previous' SMILES button."""
        if self.state.current_smiles_index > 0:
            self.state.current_smiles_index -= 1
            self.update_smiles_display()
    def on_next_smiles_click(self, b: widgets.Button) -> None:
        """Handles clicks on the 'Next' SMILES button."""
        smiles_list: list[str] = self.state.current_smiles_list
        if self.state.current_smiles_index < len(smiles_list) - 1:
            self.state.current_smiles_index += 1
            self.update_smiles_display()
    def on_sort_smiles_click(self, b: widgets.Button) -> None:
        """Handles clicks on the 'Sort by Heavy Atoms' button."""
        if not self.state.is_smiles_sorted and self.state.current_smiles_list:
            self.state.current_smiles_list = sort_by_heavy_atoms(self.state.current_smiles_list)
            self.state.is_smiles_sorted = True
            self.state.current_smiles_index = 0
            self.update_smiles_display()
            self.sort_smiles_button.disabled = True
    def display_solutions_on_click(self, b: widgets.Button) -> None:
        """Handles the click event for the 'Display Solutions' button."""
        self.reset_ui_outputs()  # Reset UI elements
        self.state.reset_display_state()  # Reset backend display state
        with self.solution_output_area:  # Log specifically to solution output area after clear
            logger.debug("[GUI] Attempting to display solutions...")
        retro_solution: RetrosynthesisSolution | None = self.state.retro_solution
        use_filter: bool = self.filter_checkbox.value
        fragment_count: int | None = self.fragment_count_input.value if use_filter else None  # type: ignore
        if retro_solution is None:
            msg = "No retrosynthesis results available. Please run retrosynthesis first."
            with self.solution_output_area:
                logger.warning(f"[GUI] {msg}")
            return
        filtered_solutions: list[SolutionType] = []
        if use_filter and (fragment_count is None or not isinstance(fragment_count, int) or fragment_count <= 0):
            msg = "Filter checkbox is checked, but fragment count is invalid. Displaying all solutions."
            with self.solution_output_area:
                logger.warning(f"[GUI] {msg}")
            filtered_solutions = retro_solution.solutions
        elif use_filter:
            filtered_solutions = [sol for sol in retro_solution.solutions if len(sol) == fragment_count]
            msg = f"Filtering for solutions with exactly {fragment_count} fragments."
            with self.solution_output_area:
                logger.info(f"[GUI] {msg}")
        else:
            msg = "Filter checkbox is unchecked. Displaying all available solutions."
            with self.solution_output_area:
                logger.info(f"[GUI] {msg}")
            filtered_solutions = retro_solution.solutions
        if not filtered_solutions:
            msg = f"No solutions found matching the criteria (count: {fragment_count})."
            with self.solution_output_area:
                logger.info(f"[GUI] {msg}")
            return
        with self.solution_output_area:
            logger.info(f"[GUI] Visualizing {len(filtered_solutions)} solution(s)...")
        try:
            solution_images: list[PILImage] = retro_solution.visualize_solutions(filtered_solutions)
            if not solution_images:
                msg = "Visualization did not produce any images."
                with self.solution_output_area:
                    logger.info(f"[GUI] {msg}")
                return
            valid_images: list[PILImage] = []
            displayable_solutions: list[SolutionType] = []
            original_indices: list[int] = []
            for i, img in enumerate(solution_images):
                if img:
                    valid_images.append(img)
                    current_filtered_solution = filtered_solutions[i]
                    displayable_solutions.append(current_filtered_solution)
                    try:
                        original_solution_index = retro_solution.solutions.index(current_filtered_solution)
                        original_indices.append(original_solution_index)
                    except ValueError:
                        with self.solution_output_area:
                            logger.error(f"[GUI] Could not find filtered solution {i} in original solutions list.")
                        original_indices.append(-1)
                else:
                    with self.solution_output_area:
                        logger.warning(
                            f"[GUI] Null image returned by visualize_solutions for filtered solution index {i}"
                        )
            num_valid_images = len(valid_images)
            self.state.valid_images_cache = valid_images
            self.state.displayable_solutions = displayable_solutions
            if num_valid_images == 0:
                msg = "Visualization did not produce any valid images."
                with self.solution_output_area:
                    logger.info(f"[GUI] {msg}")
                return
            with self.solution_output_area:
                logger.info(f"[GUI] Generated {num_valid_images} image(s). Use dropdown to view.")
            dropdown_options: list[tuple[str, int]] = [
                (f"Solution {original_indices[i] + 1}", i) for i in range(num_valid_images) if original_indices[i] != -1
            ]
            self.solution_dropdown.options = dropdown_options
            self.solution_dropdown.value = 0 if dropdown_options else None
            self.solution_dropdown.disabled = not dropdown_options
            self.solution_dropdown.observe(self.on_solution_select, names="value")
            if num_valid_images > 0 and self.state.displayable_solutions:
                initial_solution: SolutionType = self.state.displayable_solutions[0]
                self.update_fragment_comb_dropdown(initial_solution)
            else:
                self.update_fragment_comb_dropdown(None)
            if valid_images:
                self.image_display_area.clear_output(wait=True)
                with self.image_display_area:
                    display(valid_images[0])  # type: ignore
            with self.solution_output_area:
                logger.info("[GUI] Solutions displayed.")
        except Exception as e:
            with self.solution_output_area:
                logger.error(f"[GUI] An error occurred during solution visualization: {e}", exc_info=True)
    def on_solution_select(self, change: dict[str, str | int]) -> None:
        """Callback function for solution dropdown selection changes."""
        if change.get("type") == "change" and change.get("name") == "value":
            selected_index: int = cast(int, change.get("new"))
            valid_images_cache: list[PILImage] = self.state.valid_images_cache
            displayable_solutions: list[SolutionType] = self.state.displayable_solutions
            if selected_index is not None and 0 <= selected_index < len(valid_images_cache):
                self.image_display_area.clear_output(wait=True)
                with self.image_display_area:
                    display(valid_images_cache[selected_index])  # type: ignore
                if selected_index < len(displayable_solutions):
                    selected_solution: SolutionType = displayable_solutions[selected_index]
                    self.update_fragment_comb_dropdown(selected_solution)
                    self.state.reset_smiles_viewer_state()
                else:
                    with self.solution_output_area:
                        logger.warning(
                            f"[GUI] Selected index {selected_index} out of bounds for displayable_solutions."
                        )
                    self.update_fragment_comb_dropdown(None)
                    self.state.reset_smiles_viewer_state()
            elif selected_index is None:
                self.image_display_area.clear_output(wait=True)
                with self.solution_output_area:
                    logger.info("[GUI] No solution selected or available.")
                self.update_fragment_comb_dropdown(None)
                self.state.reset_smiles_viewer_state()
                self.sort_smiles_button.disabled = True