from pathlib import Path
from copy import deepcopy
import logging
import json
import random
import threading
import tkinter as tk
from tkinter import filedialog
import numpy as np
from ttkbootstrap.dialogs import Messagebox
import sounddevice as sd
import docx2txt
from .geometry import geometry
from .save_audio import save_audio
from .tts_engine import tts_engine
from .utils import format_file_size, format_duration, play_with_system_player
from .defaults import DEFAULT_SETTINGS, WINDOW_SIZE_MAP, LANGUAGES
from . import data_path
from .license_info import license_info
from .stats import stats


SPINNER = ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"]
CHECK = "✔"

logger = logging.getLogger(__name__)


class Controller:

    def __init__(self, app):
        self.app = app

        self.load_settings()
        self.init_geometry()
        self.init_vars()

        self.source_folder: Path | None = None
        self.text_file_names: set[str] = set()

        self.grid_empty = True
        self.conversion_performed = False

        self.is_converting = False
        self.current_index = 0

        self._spinner_job = None
        self._spinner_index = 0
        self._spinner_item = None

    def init_vars(self):

        speech = self.settings["speech"]
        appearance = self.settings["appearance"]
        attribution = self.settings["attribution"]


        self.lang_var = tk.StringVar(master=self.app.root, value=speech["lang"])
        self.voice_var = tk.StringVar(master=self.app.root, value=speech["voice"])
        self.quality_var = tk.IntVar(master=self.app.root, value=speech["quality"])
        self.speed_var = tk.DoubleVar(master=self.app.root, value=speech["speed"])
        self.output_format_var = tk.StringVar(master=self.app.root, value=speech["output_format"])

        self.theme_var = tk.StringVar(master=self.app.root, value=appearance["theme"])
        self.font_size_var = tk.StringVar(master=self.app.root, value=appearance["font_size"])
        self.window_size_var = tk.StringVar(master=self.app.root, value=appearance["window_size"])
        self.include_attribution_var = tk.BooleanVar(
            master=self.app.root,
            value=attribution["include_attribution"],
        )



    def _start_spinner(self, item_id: str) -> None:
        self._stop_spinner()

        self._spinner_item = item_id
        self._spinner_index = 0

        def spin():
            char = SPINNER[self._spinner_index % len(SPINNER)]
            self.app.convert_tab.tree.set(item_id, "status", char)
            self._spinner_index += 1
            self._spinner_job = self.app.root.after(120, spin)

        spin()

    def _stop_spinner(self, done: bool = False) -> None:
        if self._spinner_job:
            self.app.root.after_cancel(self._spinner_job)
            self._spinner_job = None

        if self._spinner_item:
            self.app.convert_tab.tree.set(
                self._spinner_item,
                "status",
                CHECK if done else ""
            )

        self._spinner_item = None

    def open_text_files(self) -> None:
        file_paths = filedialog.askopenfilenames(
            parent=self.app.root,
            title="Select text files",
            filetypes=[
                ("All supported files", "*.txt *.docx"),
                ("Text files", "*.txt"),
                ("Word documents", "*.docx")
            ]
        )

        if not file_paths:
            return

        self.add_files(list(file_paths))

    def on_files_dropped(self, event) -> None:
        file_paths = self.app.root.splitlist(event.data)
        self.add_files(list(file_paths))

    def add_files(self, file_paths: list[str]) -> None:
        for path in file_paths:
            p = Path(path)
            if p.is_file() and p.suffix.lower() in (".txt", ".docx"):
                if not self.add_dropped_file(path):
                    return

    def add_dropped_file(self, path: str) -> bool:
        tab = self.app.convert_tab
        p = Path(path)

        if not p.exists() or not p.is_file():
            return False

        if self.conversion_performed:
            self.new_batch()

        if self.source_folder is None:
            self.source_folder = p.parent
        elif p.parent != self.source_folder:
            Messagebox.show_info(
                "Please convert selected files first.",
                "Convert first"
            )
            return False

        if p.name in self.text_file_names:
            return False

        tab.tree.insert(
            "",
            "end",
            values=(
                "",
                p.name,
                format_file_size(p.stat().st_size) + "\u2002",
                "",
                "",
                "",
            ),
        )

        self.text_file_names.add(p.name)
        self.grid_empty = False
        return True

    def start_conversion(self) -> None:
        if self.is_converting:
            return

        if self.grid_empty:
            Messagebox.show_info(
                "Please add some files to convert",
                "Add Files"
            )
            return

        if self.conversion_performed:
            self.reset_conversion_results()

        self.is_converting = True
        self.current_index = 0
        self._convert_next()

    def _handle_conversion_error(self, item_id, err):
        self._on_file_converted(item_id, failed=True)
        Messagebox.show_error(
            title="Conversion failed",
            message=str(err)
        )

    def _convert_next(self) -> None:
        tab = self.app.convert_tab
        items = tab.tree.get_children()

        if self.current_index >= len(items):
            self.is_converting = False
            self.conversion_performed = True
            return

        item_id = items[self.current_index]
        tab.select_row(item_id)
        self._start_spinner(item_id)

        filename = tab.tree.item(item_id, "values")[1]
        input_path = self.source_folder / filename
        output_format = self.output_format_var.get()
        output_path = input_path.with_suffix(output_format)


        def worker():

            try:

                duration_sec = self._convert_file(input_path, output_path)

                self.app.root.after(
                    0,
                    lambda: self._on_file_converted(
                        item_id,
                        output_path.name,
                        format_file_size(output_path.stat().st_size),
                        format_duration(duration_sec),
                    )
                )

            except Exception as e:
                logger.exception("Conversion failed: %s", input_path)
                self.app.root.after(
                    0,
                    lambda e=e: self._handle_conversion_error(item_id, e)
                )

        threading.Thread(target=worker, daemon=True).start()

    def _on_file_converted(
        self,
        item_id: str | None = None,
        speech_file: str | None = None,
        speech_size: str | None = None,
        duration: str | None = None,
        failed: bool = False,
    ) -> None:
        tab = self.app.convert_tab

        if item_id:
            self._stop_spinner(done=not failed)
            if failed:
                tab.tree.set(item_id, "status", "✖")
            else:
                tab.update_row(
                    item_id,
                    speech_file=speech_file,
                    speech_size=speech_size,
                    duration=duration + "\u2002",
                )

        self.current_index += 1
        self._convert_next()

    def play_selected_converted_file(self) -> None:
        tab = self.app.convert_tab
        selected_items = tab.tree.selection()

        if not selected_items:
            Messagebox.show_info(
                "Please select a row containing a speech file",
                "Select a file",
            )
            return

        if self.source_folder is None:
            Messagebox.show_info(
                "Please add and convert some files first",
                "Nothing to play",
            )
            return

        item_id = selected_items[0]
        values = tab.tree.item(item_id, "values")
        speech_file = values[3].strip() if len(values) > 3 else ""

        if not speech_file:
            Messagebox.show_info(
                "Please convert the selected file first",
                "Nothing to play",
            )
            return

        speech_path = self.source_folder / speech_file

        if not speech_path.is_file():
            Messagebox.show_error(
                title="File not found",
                message=f"Could not find speech file:\n{speech_path}",
            )
            return

        try:
            play_with_system_player(str(speech_path))
        except Exception as exc:
            logger.exception("Failed to play speech file: %s", speech_path)
            Messagebox.show_error(
                title="Playback failed",
                message=str(exc),
            )

    def reset_conversion_results(self) -> None:
        tab = self.app.convert_tab

        for item_id in tab.tree.get_children():
            tab.update_row(
                item_id,
                speech_file="",
                speech_size="",
                duration="",
            )
            tab.tree.set(item_id, "status", "")

        self.current_index = 0
        self.conversion_performed = False

    def _get_params(self) -> dict:

        params = {
            "total_steps": self.quality_var.get(),
            "speed": self.speed_var.get(),
            "lang": LANGUAGES[self.lang_var.get()],
            "voice_name": self.voice_var.get()
        }

        return params


    def _convert_file(self, input_path: Path, output_path: Path) -> float:

        if input_path.suffix.lower() == ".txt":
            text = input_path.read_text(encoding="utf-8", errors="replace")

        elif input_path.suffix.lower() == ".docx":
            text = docx2txt.process(input_path)

        else:
            raise ValueError("Unsupported file type")


        wav, duration, sample_rate = self.generate_speech(text)

        save_audio(wav, sample_rate, str(output_path))

        return duration


    def generate_speech(self, text: str) -> tuple[np.ndarray, float, int]:
        "Returns wav, duration in seconds"

        params = self._get_params()
        segments = tts_engine.prepare_segments(
            text,
            insert_ident=self.should_insert_ident(),
        )
        wav, duration = tts_engine.text2speech(segments, params)

        return wav, duration, tts_engine.sample_rate


    def should_insert_ident(self) -> bool:


        if license_info.is_regular():
            attr_var = self.include_attribution_var.get()
            return attr_var


        num_runs = stats.num_runs
        if num_runs <= 20:
            probability = 10
        elif num_runs <= 100:
            probability = num_runs / 2.
        else:
            probability = 50

        result = random.random() < probability / 100.0
        return result

    def new_batch(self):
        self._stop_spinner()

        tab = self.app.convert_tab
        for item in tab.tree.get_children():
            tab.tree.delete(item)

        self.source_folder = None
        self.text_file_names.clear()

        self.grid_empty = True
        self.conversion_performed = False
        self.is_converting = False
        self.current_index = 0

    def apply_settings(self, section: str) -> None:

        if section == "speech":
            speech = self.settings["speech"]
            self.lang_var.set(speech["lang"])
            self.voice_var.set(speech["voice"])
            self.quality_var.set(speech["quality"])
            self.speed_var.set(speech["speed"])
            self.output_format_var.set(speech["output_format"])
            return

        if section == "appearance":
            appearance = self.settings["appearance"]
            self.theme_var.set(appearance["theme"])
            self.font_size_var.set(appearance["font_size"])
            self.window_size_var.set(appearance["window_size"])
            return

        if section == "attribution":
            attribution = self.settings["attribution"]
            self.include_attribution_var.set(attribution["include_attribution"])
            return

        raise ValueError("Section can be either 'speech', 'appearance', or 'attribution'")


    def init_geometry(self):


        try:
            window_size = self.settings["appearance"]["window_size"]
            geometry.height_ratio = WINDOW_SIZE_MAP[window_size]
        except:
            geometry.height_ratio = 0.6

    def get_settings(self, section: str) -> dict:

        settings = deepcopy(self.settings)

        if section == "speech":
            settings["speech"] = {
                "lang": self.lang_var.get(),
                "voice": self.voice_var.get(),
                "quality": self.quality_var.get(),
                "speed": self.speed_var.get(),
                "output_format": self.output_format_var.get()
            }
            return settings

        if section == "appearance":
            settings["appearance"] = {
                "theme": self.theme_var.get(),
                "font_size": self.font_size_var.get(),
                "window_size": self.window_size_var.get()
            }
            return settings

        if section == "attribution":
            settings["attribution"] = {
                "include_attribution": self.include_attribution_var.get()
            }
            return settings

        raise ValueError("Section name can be either 'speech', 'appearance', or 'attribution'")


    def save_speech_settings(self) -> None:
        settings_path = data_path / "settings.json"

        try:
            with settings_path.open("w") as f:
                new_settings = self.get_settings("speech")
                json.dump(new_settings, f, indent=4)
                self.settings = new_settings
        except Exception as exc:
            logger.exception("Failed to save speech settings: %s", exc)
            Messagebox.show_error(
                title="Error",
                message="Failed to save speech settings."
            )


    def save_appearance_settings(self) -> None:
        settings_path = data_path / "settings.json"

        try:
            new_settings = self.get_settings("appearance")

            if new_settings["appearance"] == self.settings["appearance"]:
                return

            with settings_path.open("w") as f:
                json.dump(new_settings, f, indent=4)

            self.settings = new_settings

            Messagebox.show_info(
                title="Restart required",
                message="Appearance changes will take effect after restarting Jimlet."
            )

        except Exception as exc:
            logger.exception("Failed to save appearance settings: %s", exc)
            Messagebox.show_error(
                title="Error",
                message="Failed to save appearance settings."
            )


    def save_attribution_settings(self) -> None:
        settings_path = data_path / "settings.json"

        try:
            new_settings = self.get_settings("attribution")

            if new_settings["attribution"] == self.settings["attribution"]:
                return

            with settings_path.open("w") as f:
                json.dump(new_settings, f, indent=4)

            self.settings = new_settings

        except Exception as exc:
            logger.exception("Failed to save attribution settings: %s", exc)
            Messagebox.show_error(
                title="Error",
                message="Failed to save attribution settings."
            )


    def valid_settings(self, settings)-> bool:

        if not isinstance(settings, dict):
            return False

        for section_name, default_section in DEFAULT_SETTINGS.items():
            section = settings.get(section_name)
            if not isinstance(section, dict):
                return False
            if section.keys() != default_section.keys():
                return False

        return True


    def normalize_settings(self, settings: dict) -> dict:
        normalized = deepcopy(DEFAULT_SETTINGS)

        for section_name, default_section in DEFAULT_SETTINGS.items():
            section = settings.get(section_name)
            if not isinstance(section, dict):
                continue

            for key in default_section:
                if key in section:
                    normalized[section_name][key] = section[key]

        return normalized

    def load_settings(self) -> dict:

        self.settings = deepcopy(DEFAULT_SETTINGS)

        settings_path = data_path / "settings.json"

        if not settings_path.exists():
            return

        try:
            with settings_path.open("r") as f:


                settings = self.normalize_settings(json.load(f))
                if self.valid_settings(settings):
                    self.settings = settings
                    return

        except Exception as exc:
            logger.exception("Failed to load settings, using defaults: %s", exc)

    def play_text(self, text:str)->None:
        """Play text from textbox on textbox_tab"""

        wav, _, sample_rate = self.generate_speech(text)
        wav = np.asarray(wav)

        if wav.ndim == 2:
            wav = wav.T   

        sd.stop()
        sd.play(wav, sample_rate)

    def convert_and_save(self, text:str)->None:

        fmt = self.output_format_var.get()
        fmt_desc = f"{fmt[1:].upper()} audio"

        path = filedialog.asksaveasfilename(
            title="Save speech file",
            initialfile=f"speech{fmt}",
            defaultextension=fmt,
            filetypes=[
                (fmt_desc, f"*{fmt}")
            ],
        )

        if not path:
            return


        wav, _, sample_rate = self.generate_speech(text)
        save_audio(wav, sample_rate, str(path))
