import sys
import os
import traceback
import shutil
import re
import uuid
import glob
import time
from typing import List, Optional, Dict, Tuple, Any

try:
    import chardet
except ImportError:
    print("Error: Missing library: chardet. Please run 'pip install chardet'")
    sys.exit(1)

try:
    import tktts 
    from pydub import AudioSegment 
except ImportError:
     print("Error: Missing libraries: tktts or pydub. Please run 'pip install tktts pydub'")
     sys.exit(1)


from PySide6.QtWidgets import (
    QApplication, QWidget, QVBoxLayout, QHBoxLayout,
    QTextEdit, QLineEdit, QPushButton, QFileDialog,
    QSlider, QDoubleSpinBox, QComboBox, QLabel, QMessageBox,
    QProgressBar, QGridLayout, QFrame, QTabWidget, QSizePolicy
)
from PySide6.QtCore import Qt, QUrl, QThread, Signal, Slot, QRect, QDir
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput, QMediaDevices 


# --- 定数とヘルパー関数（tkttsで利用される引数構造を維持） ---
DEFAULT_ENGINE = "pyttsx3"
DEFAULT_VOICEVOX_ENDPOINT = "http://127.0.0.in21"
DEFAULT_AQUESTALK_PATH = "AquesTalkPlayer.exe"
DEFAULT_TEMP_DIR = "tts_temp_wavs"
TEMP_WAV_PREFIX = "_tktts_tmp_"
TEMP_WAV_EXT = ".wav"
INI_FILE_NAME = os.path.splitext(__file__)[0] + ".ini"


def detect_encoding(file_path):
    """ファイルの文字コードを判定して開く"""
    with open(file_path, 'rb') as f:
        raw_data = f.read()
    result = chardet.detect(raw_data)
    return result['encoding']

# グローバルな置換辞書とタイムスタンプキャッシュ
_GLOBAL_REPLACE_DICT_CACHE = {}
_GLOBAL_TIMESTAMP_CACHE = {}

def load_replace_dict(ini_path, force_reload=False):
    """
    replace.iniを読み込んで辞書を作成（TOML風の簡易実装）。
    タイムスタンプをチェックし、更新がなければキャッシュを使用。
    """
    if not ini_path or not os.path.isfile(ini_path):
        return {}

    try:
        current_timestamp = os.path.getmtime(ini_path)
    except OSError:
        # ファイルが存在しない、またはアクセス権がない場合
        return {}

    # キャッシュチェック
    if not force_reload and ini_path in _GLOBAL_REPLACE_DICT_CACHE and \
       _GLOBAL_TIMESTAMP_CACHE.get(ini_path) == current_timestamp:
        return _GLOBAL_REPLACE_DICT_CACHE[ini_path]

    # ファイルの読み込みとパース
    replace_dict = {}
    try:
        encoding = detect_encoding(ini_path)
        if encoding is None: encoding = 'utf-8'
        with open(ini_path, 'r', encoding=encoding) as f:
            for line in f:
                line = line.rstrip('\n')
                if line.startswith('#') or '=' not in line:
                    continue

                # キーと値を抽出する正規表現（キーはクォートあり/なしに対応）
                match = re.match(r"""^(['"].+?['"]|[^=]+?)=(.*)$""", line)
                if not match:
                    continue

                raw_key, val = match.groups()
                # キーからクォートを除去
                key = raw_key[1:-1] if (raw_key.startswith("'") and raw_key.endswith("'")) or (raw_key.startswith('"') and raw_key.endswith('"')) else raw_key.strip()
                replace_dict[key] = val.strip()

        # キャッシュを更新
        _GLOBAL_REPLACE_DICT_CACHE[ini_path] = replace_dict
        _GLOBAL_TIMESTAMP_CACHE[ini_path] = current_timestamp
        print(f"Status: Loaded/Reloaded INI file: {os.path.basename(ini_path)}")
        return replace_dict

    except Exception as e:
        print(f"  [skip] Failed to read/parse {ini_path}: {e}")
        return {}

def apply_replacements(text, replace_dict: Dict[str, str]):
    """テキストに対して正規表現による置換を適用"""
    
    replace_list = list(replace_dict.items())

    for pattern, replacement in replace_list:
        try:
            # re.IGNORECASE (大文字小文字無視) と re.MULTILINE (複数行モード) を適用
            text = re.sub(pattern, replacement, text, flags=re.IGNORECASE | re.MULTILINE)
        except Exception as e:
            print(f"re.sub error for [{pattern}]: {e}")
    return text


class ArgsStub:
    """tkttsのヘルパー関数に引数を渡すためのスタブクラス"""
    def __init__(self, **kwargs):
        for k, v in kwargs.items():
            setattr(self, k, v)


class MyTTSWorker(QThread):
    """音声生成を非同期で実行するワーカークラス (tkttsの内部ロジックを直接実行)"""
    finished = Signal(str)
    error = Signal(str)
    progress = Signal(int)

    def __init__(self, text, outfile, tts_engine, speed_rate, pitch, instruction, voice_name, tmp_files,
                 aquestalk_path, temp_dir, voicevox_endpoint, parent=None):
        super().__init__(parent)
        self.text = text
        self.user_outfile = outfile if outfile and outfile.strip() else None

        self.tts_engine = tts_engine.lower()
        self.speed_rate = speed_rate
        self.pitch = pitch
        self.instruction = instruction
        self.voice_name = voice_name
        self.tmp_files = tmp_files

        self.aquestalk_path = aquestalk_path
        self.temp_dir = temp_dir
        self.voicevox_endpoint = voicevox_endpoint

        # tkttsの引数スタブを更新
        self.tktts_args = ArgsStub(
            tts=self.tts_engine,
            monologue=1,
            voices=self.voice_name,
            speak_rate=150,
            fspeak_rate=self.speed_rate,
            fspeak_pitch=self.pitch,
            tinterval=0.5,
            temp_dir=self.temp_dir, 
            outfile="",
            instruction=self.instruction,
            aquestalk_path=self.aquestalk_path 
        )

    def run(self):
        """非同期で音声生成とファイル変換を実行"""

        self.progress.emit(10)

        try:
            tts = tktts.get_tts(self.tts_engine)
            if tts is None:
                 self.error.emit(f"TTSエンジン [{self.tts_engine}] のロードに失敗しました。")
                 return
            
            config = tktts.TTS_ENGINES.get(self.tts_engine)
            if config is None:
                 self.error.emit(f"TTSエンジン [{self.tts_engine}] の設定が見つかりません。")
                 return

            lines = self.text.strip().split('\n')
            dialogue = [(None, line.strip()) for line in lines if line.strip()]

            """
            target_voices = tktts.get_speaker_dict(
                self.tts_engine, dialogue, self.tktts_args.voices,
                default_voicevox_voice=tktts.default_voicevox_voice,
                default_pyttsx3_voice=tktts.default_pyttsx3_voice,
                default_aqt_preset=tktts.default_aqt_preset,
                default_optnai_voice=tktts.default_optnai_voice
            )
            if target_voices is None:
                 self.error.emit("話者マップの作成に失敗しました。")
                 return
            """

            temp_dir = tktts.create_temp_dir(self.temp_dir)

            if self.user_outfile:
                tktts_outfile = self.user_outfile
            else:
                temp_filebody = TEMP_WAV_PREFIX + uuid.uuid4().hex[:4]
                temp_filename = temp_filebody + TEMP_WAV_EXT
                tktts_outfile = os.path.abspath(os.path.join(temp_dir, temp_filename))

            output_dir = os.path.dirname(tktts_outfile)
            if output_dir and not os.path.exists(output_dir):
                os.makedirs(output_dir, exist_ok=True)
                print(f"Status: 一時ディレクトリ {output_dir} を作成しました。")

            replacements = {}
            call_kwargs: Dict[str, Any] = {
                "dialogue": dialogue,
                "replacements": replacements,
                "target_voices": self.voice_name, #target_voices,
                "temp_dir": temp_dir,
                "outfile": tktts_outfile,
                "ext": config["ext"],
                "cfg": self.tktts_args
            }

            media_format = "wav"
            if self.tts_engine == "pyttsx3":
                call_kwargs["speak_rate"] = int(self.speed_rate * 150)
            elif self.tts_engine == "voicevox":
                call_kwargs["endpoint"] = self.voicevox_endpoint
            elif self.tts_engine == "openai":
                call_kwargs["instruction"] = self.instruction
                call_kwargs["outfile"] = os.path.splitext(tktts_outfile)[0] + ".mp3"
                media_format = "mp3"
            elif self.tts_engine == "aquestalkplayer" or self.tts_engine == "atp":
                call_kwargs["aquestalk_path"] = self.tktts_args.aquestalk_path
            ext = "." + media_format

            print(f"Status: {self.tts_engine.upper()}の音声生成開始...")
            success, tmpfiles = tts.speak_dialogue(**call_kwargs)

            if not success:
                self.error.emit(f"TTSエンジン [{self.tts_engine}] での音声生成に失敗しました。")
                return

            self.progress.emit(50)
            if self.user_outfile:
                self.wav_compatible_path = self.user_outfile
            else:
                WAV_COMPATIBLE_FILE_NAME = temp_filebody + "_merged" + TEMP_WAV_EXT
                self.wav_compatible_path = os.path.abspath(os.path.join(temp_dir, WAV_COMPATIBLE_FILE_NAME))
            
            print()
            print("  ファイルを結合中...")
            combined_audio = AudioSegment.silent(duration=0)
            for tmpfile in tmpfiles:
                segment = AudioSegment.from_file(tmpfile, format = media_format)
                tinterval_ms = int(self.tktts_args.tinterval * 1000)
                combined_audio += segment + AudioSegment.silent(duration=tinterval_ms)
            
            combined_audio.export(self.wav_compatible_path, format=media_format)
            print(f"✅ 出力音声を {self.wav_compatible_path} に保存しました (形式: {media_format})。")

            time.sleep(0.1)

            self.progress.emit(100)
            self.finished.emit(self.wav_compatible_path)

            print("\n🗑️ 一時ファイルを削除中...")
            for f in tmpfiles:
                try:
                    if os.path.exists(f): os.remove(f)
                except:
                    pass
            
            if os.path.exists(self.temp_dir) and not os.listdir(self.temp_dir):
                 shutil.rmtree(self.temp_dir)

            if not self.user_outfile:
                self.tmp_files.append(self.wav_compatible_path)

        except Exception as e:
            error_msg = f"音声生成またはファイル操作エラー: {type(e).__name__}: {e}"
            print(error_msg)
            traceback.print_exc()
            self.error.emit(error_msg)
            if hasattr(self, "wav_compatible_path"):
                if self.user_outfile != self.wav_compatible_path and os.path.exists(self.wav_compatible_path):
                    os.remove(self.wav_compatible_path)


class MyTTSApp(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("統合TTS GUI (コンパクト・リサイズ可能)")
        
        # メディア関連の初期化
        self.worker_thread: Optional[MyTTSWorker] = None
        self.audio_output = QAudioOutput(QMediaDevices.defaultAudioOutput())
        self.player: QMediaPlayer = QMediaPlayer()
        self.player.setAudioOutput(self.audio_output)
        self.current_audio_path: Optional[str] = None
        
        # 状態管理
        self.tmp_files = [] 
        self.slide_data: Dict[int, str] = {}
        self.last_dir: Dict[str, str] = {} 
        self.is_converted_text_dirty: bool = True 

        self._load_settings()

        # シグナルとスロットの接続
        self.player.durationChanged.connect(self.set_slider_range)
        self.player.positionChanged.connect(self.update_slider)
        self.player.playbackStateChanged.connect(self.update_playback_buttons)
        self.player.errorOccurred.connect(self.handle_media_player_error)

        self.initUI() 
        self.setGeometry(self.settings.get('geometry', QRect(100, 100, 800, 650))) 
        self.setMinimumSize(400, 450)

        # TTS設定UIの変更を監視し、ダーティフラグを立てる
        self.text_input_converted.textChanged.connect(self.set_dirty)
        self.speed_spin.valueChanged.connect(self.set_dirty)
        self.pitch_spin.valueChanged.connect(self.set_dirty)
        self.instruction_line.textChanged.connect(self.set_dirty)
        self.engine_combo.currentIndexChanged.connect(self.update_voice_list) # update_voice_list内でもset_dirtyを呼ぶ
        self.voice_combo.currentIndexChanged.connect(self.set_dirty) # ボイス変更時

        self.update_voice_list()

        QApplication.instance().aboutToQuit.connect(self.cleanup_temp_files)
        QApplication.instance().aboutToQuit.connect(self._save_settings)


    # --- 状態管理 ---
    @Slot()
    def set_dirty(self):
        """TTS出力に影響する設定が変更されたとき、フラグを立てる"""
        self.is_converted_text_dirty = True
        if self.player.playbackState() != QMediaPlayer.PlaybackState.PlayingState:
            self.update_playback_buttons(self.player.playbackState())
            
    def _load_settings(self):
        # 設定のロードロジックは省略せずに残す
        default_settings = {
            'geometry': QRect(100, 100, 800, 650),
            'temp_dir': DEFAULT_TEMP_DIR,
            'aquestalk_path': DEFAULT_AQUESTALK_PATH,
            'voicevox_endpoint': DEFAULT_VOICEVOX_ENDPOINT,
            'input_file': "input.md",
            'replace_file': "replace.ini",
            'replace_file2': "user_replace.ini",
            'output_file': "",
        }
        self.settings: Dict[str, Any] = default_settings.copy()
        
        if not os.path.exists(INI_FILE_NAME):
            return

        try:
            with open(INI_FILE_NAME, 'r', encoding='utf-8') as f:
                content = f.read()
                current_section = None
                for line in content.splitlines():
                    line = line.strip()
                    if not line or line.startswith('#'):
                        continue
                    if line.startswith('[') and line.endswith(']'):
                        current_section = line[1:-1].strip()
                    elif '=' in line:
                        key, value = line.split('=', 1)
                        key = key.strip()
                        value = value.strip().strip('"')

                        if current_section == "window":
                            if key == "x": self.settings['x'] = int(value)
                            elif key == "y": self.settings['y'] = int(value)
                            elif key == "width": self.settings['width'] = int(value)
                            elif key == "height": self.settings['height'] = int(value)
                        elif current_section == "tts_paths":
                            if key in default_settings: self.settings[key] = value
                
                if 'x' in self.settings:
                    self.settings['geometry'] = QRect(
                        self.settings['x'], self.settings['y'],
                        self.settings.get('width', 800), self.settings.get('height', 650)
                    )
        except Exception as e:
            print(f"設定ファイル読み込みエラー: {e}")
            self.settings = default_settings.copy()


    def _save_settings(self):
        # 設定保存ロジックは省略せずに残す
        geom = self.geometry()
        
        current_settings = {
            'temp_dir': self.temp_dir_line.text(),
            'aquestalk_path': self.aquestalk_path_line.text(),
            'voicevox_endpoint': self.voicevox_endpoint_line.text(),
            'input_file': self.input_file_line.text(),
            'replace_file': self.replace_ini_line.text(),
            'replace_file2': self.replace_ini2_line.text(),
            'output_file': self.output_line.text(),
        }

        content = (
            f'[window]\n'
            f'x = {geom.x()}\n'
            f'y = {geom.y()}\n'
            f'width = {geom.width()}\n'
            f'height = {geom.height()}\n'
            f'\n'
            f'[tts_paths]\n'
            f'temp_dir = "{current_settings["temp_dir"]}"\n'
            f'aquestalk_path = "{current_settings["aquestalk_path"]}"\n'
            f'voicevox_endpoint = "{current_settings["voicevox_endpoint"]}"\n'
            f'input_file = "{current_settings["input_file"]}"\n'
            f'replace_file = "{current_settings["replace_file"]}"\n'
            f'replace_file2 = "{current_settings["replace_file2"]}"\n'
            f'output_file = "{current_settings["output_file"]}"\n'
        )

        try:
            with open(INI_FILE_NAME, 'w', encoding='utf-8') as f:
                f.write(content)
        except Exception as e:
            print(f"設定ファイル保存エラー: {e}")


    def cleanup_temp_files(self):
        # クリーンアップロジックは省略せずに残す
        self.handle_stop()
        self.player.setSource(QUrl())
        if self.worker_thread and self.worker_thread.isRunning():
             self.worker_thread.quit()
             self.worker_thread.wait()

        for f in self.tmp_files:
            try:
                if os.path.isfile(f):
                    os.remove(f)
            except Exception as e:
                print(f"一時ファイル削除エラー: {f}: {e}")

        temp_dir = self.settings.get('temp_dir', DEFAULT_TEMP_DIR)
        try:
            if os.path.exists(temp_dir):
                search_pattern = os.path.join(temp_dir, f"{TEMP_WAV_PREFIX}*")
                for file_path in glob.glob(search_pattern):
                    try:
                        if os.path.isfile(file_path):
                            os.remove(file_path)
                    except:
                        pass
                if not os.listdir(temp_dir):
                    os.rmdir(temp_dir)
        except Exception as e:
             print(f"一時ディレクトリのクリーンアップエラー: {e}")


    # --- UI初期化 ---
    def initUI(self):
        main_layout = QVBoxLayout()
        self.tabs = QTabWidget()
        
        # --- TTS設定ウィジェットの事前初期化 ---
        # これらのウィジェットはMain/Configタブ間で共有されるため、先に初期化する
        self.engine_combo = QComboBox(self)
        self.engine_combo.addItems(["pyttsx3", "voicevox", "openai", "aquestalkplayer"])
        self.engine_combo.setCurrentText(DEFAULT_ENGINE)
        self.voice_combo = QComboBox(self)
        self.speed_spin = QDoubleSpinBox(self)
        self.speed_spin.setRange(0.1, 5.0)
        self.speed_spin.setSingleStep(0.1)
        self.speed_spin.setValue(1.0)
        self.pitch_spin = QDoubleSpinBox(self)
        self.pitch_spin.setRange(-10.0, 10.0)
        self.pitch_spin.setSingleStep(0.1)
        self.pitch_spin.setValue(0.0)
        self.instruction_line = QLineEdit()
        self.instruction_line.setPlaceholderText("Optional text for OpenAI instruction...")
        
        # --- Tab 1: Main Content (メイン操作) ---
        main_page = QWidget()
        main_page_layout = QVBoxLayout(main_page)

        # 1. ファイル設定
        file_slide_layout = QGridLayout()
        # Input File
        file_slide_layout.addWidget(QLabel("Input File (infile):"), 0, 0)
        self.input_file_line = QLineEdit(self.settings.get('input_file'))
        file_slide_layout.addWidget(self.input_file_line, 0, 1)
        self.input_file_btn = QPushButton("Path")
        self.input_file_btn.clicked.connect(self.select_input_file)
        file_slide_layout.addWidget(self.input_file_btn, 0, 2)
        # Replace INI 1 (Default)
        file_slide_layout.addWidget(QLabel("Replace INI (Default):"), 1, 0)
        self.replace_ini_line = QLineEdit(self.settings.get('replace_file'))
        file_slide_layout.addWidget(self.replace_ini_line, 1, 1)
        self.replace_ini_btn = QPushButton("Path")
        self.replace_ini_btn.clicked.connect(lambda: self.select_ini_file(self.replace_ini_line, 'replace_file'))
        file_slide_layout.addWidget(self.replace_ini_btn, 1, 2)
        # Replace INI 2 (User)
        file_slide_layout.addWidget(QLabel("Replace INI (User):"), 2, 0)
        self.replace_ini2_line = QLineEdit(self.settings.get('replace_file2'))
        file_slide_layout.addWidget(self.replace_ini2_line, 2, 1)
        self.replace_ini2_btn = QPushButton("Path")
        self.replace_ini2_btn.clicked.connect(lambda: self.select_ini_file(self.replace_ini2_line, 'replace_file2'))
        file_slide_layout.addWidget(self.replace_ini2_btn, 2, 2)
        # Output File
        file_slide_layout.addWidget(QLabel("出力ファイル (wav):"), 3, 0)
        self.output_line = QLineEdit(self)
        self.output_line.setPlaceholderText("未指定の場合、一時ファイルを作成して再生します (推奨)")
        self.output_line.setText(self.settings.get('output_file', ''))
        file_slide_layout.addWidget(self.output_line, 3, 1)
        self.output_btn = QPushButton("Path")
        self.output_btn.clicked.connect(self.select_output_file)
        file_slide_layout.addWidget(self.output_btn, 3, 2)
        main_page_layout.addLayout(file_slide_layout)
        
        # Slide Page Pulldown (位置変更)
        slide_page_layout = QHBoxLayout()
        slide_page_layout.addWidget(QLabel("Slide Page:"))
        self.slide_page_combo = QComboBox(self)
        self.slide_page_combo.addItem("1. No file loaded")
        self.slide_page_combo.setCurrentIndex(0)
        self.slide_page_combo.currentIndexChanged.connect(self.on_slide_page_changed)
        slide_page_layout.addWidget(self.slide_page_combo)
        main_page_layout.addLayout(slide_page_layout)

        # 4. テキスト入力エリア (2分割 & 拡張可能に)
        text_layout = QVBoxLayout()
        
        # Original Text
        text_layout.addWidget(QLabel("読み上げテキスト (Original):"))
        self.text_input_original = QTextEdit(self)
        self.text_input_original.setPlaceholderText("入力ファイルの内容（スライドページ）がここに表示されます。")
        self.text_input_original.setMinimumHeight(80) 
        self.text_input_original.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) 
        text_layout.addWidget(self.text_input_original)
        
        # Converted Text
        text_layout.addWidget(QLabel("読み上げテキスト (Converted):"))
        self.text_input_converted = QTextEdit(self)
        self.text_input_converted.setPlaceholderText("置換ルール適用後のテキストがここに表示されます。Play/Generateボタンはこのテキストを読み上げます。")
        self.text_input_converted.setMinimumHeight(80) 
        self.text_input_converted.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        text_layout.addWidget(self.text_input_converted)
        main_page_layout.addLayout(text_layout)
        
        # 5. コントロール (Convertedテキストの直下に配置)
        control_layout = QHBoxLayout()
        
        # Convert Button
        self.convert_btn = QPushButton("⚙️ Convert (Apply Rules)")
        self.convert_btn.clicked.connect(self.handle_convert)
        self.convert_btn.setStyleSheet("font-weight: bold; padding: 5px;")
        control_layout.addWidget(self.convert_btn)

        # 再生/生成ボタン
        self.play_btn = QPushButton("▶ Play/Generate")
        self.generate_btn = QPushButton("⚡ Generate (Force)")
        self.pause_btn = QPushButton("⏸ Pause")
        self.stop_btn = QPushButton("■ Stop")
        
        self.play_btn.clicked.connect(lambda: self.handle_play(force_generate=False))
        self.generate_btn.clicked.connect(lambda: self.handle_play(force_generate=True)) # 強制生成
        self.pause_btn.clicked.connect(self.handle_pause)
        self.stop_btn.clicked.connect(self.handle_stop)
        
        control_layout.addWidget(self.play_btn)
        control_layout.addWidget(self.generate_btn)
        control_layout.addWidget(self.pause_btn)
        control_layout.addWidget(self.stop_btn)
        
        main_page_layout.addLayout(control_layout)


        # --- Tab 2: Config (パス設定) ---
        config_page = QWidget()
        config_page_layout = QVBoxLayout(config_page)
        
        # アプリケーションパス設定
        app_path_layout = QGridLayout()
        # AquesTalk Path
        app_path_layout.addWidget(QLabel("AquesTalk Path:"), 0, 0)
        self.aquestalk_path_line = QLineEdit(self.settings.get('aquestalk_path', DEFAULT_AQUESTALK_PATH))
        app_path_layout.addWidget(self.aquestalk_path_line, 0, 1)
        self.aquestalk_path_btn = QPushButton("Path")
        self.aquestalk_path_btn.clicked.connect(self.select_aquestalk_path)
        app_path_layout.addWidget(self.aquestalk_path_btn, 0, 2)
        # Voicevox Endpoint
        app_path_layout.addWidget(QLabel("Voicevox Endpoint:"), 1, 0)
        self.voicevox_endpoint_line = QLineEdit(self.settings.get('voicevox_endpoint', DEFAULT_VOICEVOX_ENDPOINT))
        app_path_layout.addWidget(self.voicevox_endpoint_line, 1, 1, 1, 2) 
        # Temp Dir
        app_path_layout.addWidget(QLabel("Temp Dir:"), 2, 0)
        self.temp_dir_line = QLineEdit(self.settings.get('temp_dir', DEFAULT_TEMP_DIR))
        app_path_layout.addWidget(self.temp_dir_line, 2, 1, 1, 2)

        config_page_layout.addLayout(app_path_layout)
        
        # TTS設定（Configタブに配置）
        tts_settings_layout_config = QGridLayout()
        tts_settings_layout_config.addWidget(QLabel("Engine:"), 3, 0)
        tts_settings_layout_config.addWidget(self.engine_combo, 3, 1) 
        tts_settings_layout_config.addWidget(QLabel("Voice:"), 3, 2)
        tts_settings_layout_config.addWidget(self.voice_combo, 3, 3) 
        tts_settings_layout_config.addWidget(QLabel("Speed (fspeak_rate):"), 4, 0)
        tts_settings_layout_config.addWidget(self.speed_spin, 4, 1)
        tts_settings_layout_config.addWidget(QLabel("Pitch (ピッチ):"), 4, 2)
        tts_settings_layout_config.addWidget(self.pitch_spin, 4, 3)
        tts_settings_layout_config.addWidget(QLabel("Instruction (OpenAI):"), 5, 0)
        tts_settings_layout_config.addWidget(self.instruction_line, 5, 1, 1, 3)
        
        config_page_layout.addLayout(tts_settings_layout_config)
        config_page_layout.addStretch(1) # 残りのスペースを埋める

        # Tab Widgetに追加
        self.tabs.addTab(main_page, "Main")
        self.tabs.addTab(config_page, "Config")
        main_layout.addWidget(self.tabs)
        
        # --- Tabの外の共通コントロール ---

        # 6. プログレスバーと再生位置を1行に統合
        progress_slider_layout = QHBoxLayout()
        progress_slider_layout.addWidget(QLabel("Prog/Pos:"))
        
        # プログレスバー
        self.progress_bar = QProgressBar(self)
        self.progress_bar.setRange(0, 100)
        self.progress_bar.setValue(0)
        self.progress_bar.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
        progress_slider_layout.addWidget(self.progress_bar)

        # 再生位置スライダー
        self.position_slider = QSlider(Qt.Orientation.Horizontal)
        self.position_slider.setRange(0, 0)
        self.position_slider.setTracking(False)
        self.position_slider.sliderMoved.connect(self.seek_position)
        self.position_slider.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
        progress_slider_layout.addWidget(self.position_slider)

        main_layout.addLayout(progress_slider_layout)

        # 7. ステータスラベル
        self.status_label = QLabel("Status: Ready")
        main_layout.addWidget(self.status_label)

        self.setLayout(main_layout)
        self.update_playback_buttons(self.player.playbackState())

        self.text_input_original.setText("TTSアプリへようこそ！\nInput Fileを選択すると、内容がここに表示され、Convertボタンで置換が適用されます。")
        self.text_input_converted.setText("Play/Generateボタンを押すと、このconvertedテキストが読み上げられます。")


    # --- ファイル選択/スロット群 ---
    
    def _get_initial_dir(self, path_line: QLineEdit, key: str) -> str:
        """QLineEditの内容または記憶されたディレクトリを元に初期ディレクトリを返す"""
        current_path = path_line.text()
        if os.path.isfile(current_path):
            dir_name = os.path.dirname(current_path)
            self.last_dir[key] = dir_name
            return dir_name
        elif os.path.isdir(current_path):
            self.last_dir[key] = current_path
            return current_path
        elif key in self.last_dir and os.path.isdir(self.last_dir[key]):
            return self.last_dir[key]
        return QDir.currentPath()

    @Slot()
    def select_input_file(self):
        """Input Fileを選択し、ファイルを読み込み、スライドをパースする"""
        key = 'input_file'
        initial_dir = self._get_initial_dir(self.input_file_line, key)
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "入力テキストファイルの選択",
            initial_dir,
            "テキストファイル (*.txt *.md);;全てのファイル (*)"
        )
        if file_path:
            self.input_file_line.setText(file_path)
            self.last_dir[key] = os.path.dirname(file_path)
            self.load_input_file(file_path)

    @Slot()
    def select_output_file(self):
        """出力音声ファイルを選択し、パスを更新する"""
        key = 'output_file'
        initial_dir = self._get_initial_dir(self.output_line, key)
        file_path, _ = QFileDialog.getSaveFileName(
            self, 
            "出力音声ファイルの選択", 
            initial_dir, 
            "Wave Files (*.wav);;All Files (*)"
        )
        if file_path:
            if not file_path.lower().endswith(".wav"):
                 file_path += ".wav"
            self.output_line.setText(file_path)
            self.last_dir[key] = os.path.dirname(file_path)

    @Slot()
    def select_aquestalk_path(self):
        """AquesTalkPlayer.exeのファイルパスを選択するダイアログを開く"""
        key = 'aquestalk_path'
        initial_dir = self._get_initial_dir(self.aquestalk_path_line, key)
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "AquesTalkPlayer.exeの選択",
            initial_dir,
            "実行ファイル (*.exe);;全てのファイル (*)" 
        )
        if file_path:
            self.aquestalk_path_line.setText(file_path)
            self.last_dir[key] = os.path.dirname(file_path)
            
    @Slot(QLineEdit, str)
    def select_ini_file(self, line_edit: QLineEdit, key: str):
        """INIファイルを選択するダイアログを開く"""
        initial_dir = self._get_initial_dir(line_edit, key)
        file_path, _ = QFileDialog.getOpenFileName(
            self,
            "INIファイル（置換ルール）の選択",
            initial_dir,
            "INIファイル (*.ini);;全てのファイル (*)"
        )
        if file_path:
            line_edit.setText(file_path)
            self.last_dir[key] = os.path.dirname(file_path)

    def load_input_file(self, file_path: str):
        self.slide_data = {}
        self.current_infile_path = file_path
        
        try:
            encoding = detect_encoding(file_path)
            if encoding is None: encoding = 'utf-8'
            with open(file_path, 'r', encoding=encoding) as f:
                full_text = f.read()

            self.slide_data[0] = full_text.strip()
            current_slide_number = 1
            
            # スライド区切りパターン: `# Slide` で始まる行、または `*1, *2` などの行
            slide_separator_pattern = re.compile(r"^\s*(#\s*Slide.*|\*\d+)\s*$", re.IGNORECASE | re.MULTILINE)
            
            parts = slide_separator_pattern.split(full_text)
            
            if len(parts) > 1:
                
                # parts[0]は最初の区切りより前のテキスト
                if parts[0].strip():
                    self.slide_data[1] = parts[0].strip()
                    current_slide_number = 2
                
                # parts[1]以降は区切りと区切りの間のテキスト
                for part in parts[1:]:
                    part_content = part.strip()
                    # 区切りパターンにマッチしたテキスト（空か、区切り文字自体）はスキップ
                    if part_content and not slide_separator_pattern.match(part_content): 
                         self.slide_data[current_slide_number] = part_content
                         current_slide_number += 1
                
                # スライドが分割された場合は、改めてページ0(全文)を再構築
                self.slide_data[0] = full_text.strip()


            # 3. Slide pageプルダウンを更新
            self.slide_page_combo.clear()
            
            is_slide_parsed = len(self.slide_data) > 1 
            
            self.slide_page_combo.addItem("0. (All Document)")
            if is_slide_parsed:
                for i in sorted([k for k in self.slide_data.keys() if k > 0]):
                    self.slide_page_combo.addItem(f"{i}. Slide {i}")
                self.slide_page_combo.setEnabled(True)
            else:
                self.slide_page_combo.setEnabled(False)
                
            self.slide_page_combo.setCurrentIndex(0)

        except Exception as e:
            QMessageBox.critical(self, "ファイル読み込みエラー", f"ファイルの読み込みに失敗しました: {e}")
            self.slide_page_combo.clear()
            self.slide_page_combo.addItem("1. No file loaded")
            self.slide_page_combo.setEnabled(False)
            self.text_input_original.setText("")
            self.text_input_converted.setText("")

    @Slot(int)
    def on_slide_page_changed(self, index):
        page_key = index
        
        if page_key in self.slide_data:
            original_text = self.slide_data[page_key]
            
            # Convertedテキストの変更を一時的に無効化
            self.text_input_converted.textChanged.disconnect(self.set_dirty) 
            
            self.text_input_original.setText(original_text)
            self.text_input_converted.setText("")
            self.is_converted_text_dirty = True # ページが変わったので変換が必要
            
            # Convertedテキストの変更監視を再開
            self.text_input_converted.textChanged.connect(self.set_dirty)
            
            self.status_label.setText(f"Status: Page {page_key} loaded. Ready to Convert.")
        else:
             self.text_input_original.setText("")
             self.text_input_converted.setText("")
             self.status_label.setText("Status: Error - Page content missing.")
             
    @Slot()
    def handle_convert(self):
        """Convertボタンが押されたとき、置換処理を実行する"""
        original_text = self.text_input_original.toPlainText()
        if not original_text.strip():
            QMessageBox.warning(self, "Convertエラー", "Originalテキストが空です。ファイルを読み込むか、テキストを入力してください。")
            return
        
        default_ini_path = self.replace_ini_line.text()
        user_ini_path = self.replace_ini2_line.text()
        
        self.status_label.setText("Status: Loading/Checking replacement rules...")
        QApplication.processEvents()
        
        # タイムスタンプチェックと再読み込み (force_reload=True)
        dict2 = load_replace_dict(user_ini_path, force_reload=True)
        dict1 = load_replace_dict(default_ini_path, force_reload=True)
        
        dict1_filtered = {k: v for k, v in dict1.items() if k not in dict2}
        
        final_replace_dict = {}
        final_replace_dict.update(dict1_filtered) 
        final_replace_dict.update(dict2) 
        
        if not final_replace_dict:
             QMessageBox.information(self, "Convert情報", "有効な置換ルールがINIファイルから見つかりませんでした。")
             self.text_input_converted.setText(original_text)
             
             # Convertedテキストの変更を一時的に無効化
             self.text_input_converted.textChanged.disconnect(self.set_dirty)
             self.is_converted_text_dirty = False 
             self.text_input_converted.textChanged.connect(self.set_dirty)
             
             self.status_label.setText("Status: No rules applied. Converted = Original.")
             return
             
        self.status_label.setText("Status: Applying replacement rules...")
        QApplication.processEvents()

        replaced_text = apply_replacements(original_text, final_replace_dict)
        
        # 修正: `# Slide...` および `*N` 形式のマーカー行を完全に削除
        # 1. `# Slide...` 行の削除（行頭/行末に空白があっても良い）
        replaced_text = re.sub(r"^\s*#\s*Slide.*$", "\n", replaced_text, flags=re.IGNORECASE | re.MULTILINE)
        
        # 2. `*N` (スライド番号) 行の削除（行頭/行末に空白があっても良い）
        replaced_text = re.sub(r"^\s*\*\d+\s*$", "\n", replaced_text, flags=re.IGNORECASE | re.MULTILINE)
        
        # 空行のみの行を削除（連続する空行を一つにまとめる）
        replaced_text = re.sub(r'\n\s*\n', '\n\n', replaced_text).strip()
        
        # Convertedテキストの変更を一時的に無効化してから更新
        self.text_input_converted.textChanged.disconnect(self.set_dirty)
        self.text_input_converted.setText(replaced_text)
        self.is_converted_text_dirty = False # 変換完了
        self.text_input_converted.textChanged.connect(self.set_dirty)

        self.status_label.setText("Status: Conversion complete. Ready to Play.")


    def handle_play(self, force_generate: bool = False):
        """
        Play/Generateボタンの動作。
        force_generate=True の場合は、ダーティフラグにかかわらず強制的に生成。
        """

        # 1. 既に再生中の場合は無視
        if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
            return

        # 2. ポーズ状態からの再開
        if self.player.playbackState() == QMediaPlayer.PlaybackState.PausedState:
            self.player.play()
            return
            
        # 3. 強制生成が不要かつダーティでない場合 -> 再生
        is_ready_to_play = (
            not force_generate and 
            not self.is_converted_text_dirty and 
            self.current_audio_path and 
            os.path.exists(self.current_audio_path)
        )
        
        if is_ready_to_play:
             self.status_label.setText(f"Status: Playing existing audio: {os.path.basename(self.current_audio_path)}")
             try:
                audio_url = QUrl.fromLocalFile(self.current_audio_path)
                self.player.stop()
                self.player.setSource(audio_url)
                self.player.play()
             except Exception as e:
                 QMessageBox.critical(self, "再生エラー", f"既存の音声ファイルの再生に失敗しました: {e}")
                 self.current_audio_path = None 
             return

        # 4. 生成が必要な場合 (ダーティ or ファイルがない or 強制生成)
        
        if self.worker_thread and self.worker_thread.isRunning():
            QMessageBox.warning(self, "処理中", "現在、音声生成が実行中です。完了をお待ちください。")
            return

        text = self.text_input_converted.toPlainText() 
        outfile = self.output_line.text().strip()
        tts_engine = self.engine_combo.currentText()
        speed_rate = self.speed_spin.value()
        voice_name = self.voice_combo.currentText()
        pitch = self.pitch_spin.value()
        instruction = self.instruction_line.text()
        
        aquestalk_path = self.aquestalk_path_line.text().strip()
        temp_dir = self.temp_dir_line.text().strip()
        voicevox_endpoint = self.voicevox_endpoint_line.text().strip()

        if not text.strip():
            QMessageBox.warning(self, "入力エラー", "読み上げテキスト (Converted) が空です。")
            return

        if not voice_name or "No voices found" in voice_name or "Error loading voices" in voice_name:
             QMessageBox.warning(self, "ボイス選択エラー", "有効なボイスが選択されていません。エンジンを確認してください。")
             return

        self.status_label.setText("Status: 音声生成を開始しました... (非同期実行中)")
        self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState, is_generating=True)
        self.progress_bar.setValue(0)

        self.worker_thread = MyTTSWorker(
            text, outfile, tts_engine, speed_rate, pitch, instruction, voice_name, self.tmp_files,
            aquestalk_path, temp_dir, voicevox_endpoint,
        )
        self.worker_thread.finished.connect(self.on_synthesis_finished)
        self.worker_thread.error.connect(self.on_synthesis_error)
        self.worker_thread.progress.connect(self.progress_bar.setValue)
        self.worker_thread.start()


    @Slot(str)
    def on_synthesis_finished(self, file_path):
        self.current_audio_path = file_path
        self.is_converted_text_dirty = False 
        self.status_label.setText(f"Status: 音声生成が完了しました: {os.path.basename(file_path)}")
        self.progress_bar.setValue(100)

        self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState, is_generating=False)

        if not file_path or not os.path.exists(file_path):
             self.on_synthesis_error(f"音声ファイルが見つかりません: {file_path}")
             return

        try:
            audio_url = QUrl.fromLocalFile(file_path)
            self.player.stop()
            self.player.setSource(audio_url)
            self.player.play()
        except Exception as e:
            self.on_synthesis_error(f"音声再生の開始に失敗しました: {e}")

    @Slot(str)
    def on_synthesis_error(self, message):
        self.status_label.setText("Status: エラー発生")
        self.progress_bar.setValue(0)
        QMessageBox.critical(self, "エラー", message)
        self.current_audio_path = None 

        self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState, is_generating=False)
        if self.worker_thread:
            self.worker_thread.quit()
            self.worker_thread.wait()

    @Slot()
    def update_voice_list(self):
        """エンジン選択に応じてボイスリストを更新し、ダーティフラグを立てる"""
        
        # 変更前のボイス名とエンジン名を取得（ダーティチェック用）
        old_engine = self.engine_combo.currentText()
        old_voice = self.voice_combo.currentText()
        
        engine = self.engine_combo.currentText()
        self.voice_combo.clear()

        engine_lower = engine.lower()

        is_voicevox = engine_lower == "voicevox"
        is_openai = engine_lower == "openai"
        is_aqt = engine_lower == "aquestalkplayer"

        self.pitch_spin.setEnabled(is_voicevox or is_aqt)
        self.instruction_line.setEnabled(is_openai)
        
        if hasattr(self, 'voicevox_endpoint_line'):
             self.voicevox_endpoint_line.setEnabled(is_voicevox)
             self.aquestalk_path_line.setEnabled(is_aqt)

        try:
            tts = tktts.get_tts(engine)
            voices: List[str] = tts.get_available_voices()

            if voices:
                 self.voice_combo.addItems(voices)
                 default_voice = None
                 if engine_lower == "pyttsx3" and tktts.default_pyttsx3_voice in voices:
                      default_voice = tktts.default_pyttsx3_voice
                 elif is_openai and tktts.default_optnai_voice in voices:
                      default_voice = tktts.default_optnai_voice
                 elif is_voicevox and tktts.default_voicevox_voice in voices:
                      default_voice = tktts.default_voicevox_voice
                 elif is_aqt and tktts.default_aqt_preset in voices:
                      default_voice = tktts.default_aqt_preset

                 if default_voice and default_voice in voices:
                      self.voice_combo.setCurrentText(default_voice)
                 else:
                      self.voice_combo.setCurrentIndex(0)
            else:
                 self.voice_combo.addItem(f"No voices found for {engine}")
        except Exception as e:
            error_msg = f"Error loading voices for {engine}: {e.__class__.__name__}"
            self.voice_combo.addItem(error_msg)
            print(error_msg)

        # エンジンまたはボイスが変わったらダーティフラグを立てる
        if old_engine != engine or old_voice != self.voice_combo.currentText():
            self.set_dirty()

    def update_playback_buttons(self, state: QMediaPlayer.PlaybackState, is_generating: Optional[bool] = None):
        if is_generating is None:
            is_worker_running = self.worker_thread and self.worker_thread.isRunning()
        else:
            is_worker_running = is_generating

        self.convert_btn.setEnabled(not is_worker_running)
        self.generate_btn.setEnabled(not is_worker_running)
        
        if is_worker_running:
            self.play_btn.setText("▶ Generating...")
            self.play_btn.setEnabled(False)
            self.pause_btn.setEnabled(False)
            self.stop_btn.setEnabled(False)
            return

        if state == QMediaPlayer.PlaybackState.PlayingState:
            self.play_btn.setText("▶ Playing...")
            self.play_btn.setEnabled(False)
            self.pause_btn.setEnabled(True)
            self.stop_btn.setEnabled(True)
        elif state == QMediaPlayer.PlaybackState.PausedState:
            self.play_btn.setText("▶ Resume")
            self.play_btn.setEnabled(True)
            self.pause_btn.setEnabled(False)
            self.stop_btn.setEnabled(True)
        elif state == QMediaPlayer.PlaybackState.StoppedState:
            if self.is_converted_text_dirty:
                 self.play_btn.setText("▶ Generate (Text changed)")
            elif self.current_audio_path:
                 self.play_btn.setText("▶ Play (Existing)")
            else:
                 self.play_btn.setText("▶ Play/Generate")

            self.play_btn.setEnabled(True)
            self.pause_btn.setEnabled(False)
            self.stop_btn.setEnabled(False)
            if not is_worker_running and "Status: エラー" not in self.status_label.text():
                 if self.current_audio_path and not self.is_converted_text_dirty:
                      self.status_label.setText("Status: Generated (Ready to play)")
                 else:
                      self.status_label.setText("Status: Ready")

    # --- メディア制御系は変更なし ---
    def handle_pause(self):
        if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
            self.player.pause()

    def handle_stop(self):
        self.player.stop()
        self.position_slider.setValue(0)
        self.status_label.setText("Status: 停止")
        self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState)

    def handle_media_player_error(self, error: QMediaPlayer.Error, error_string: str):
        if error != QMediaPlayer.Error.NoError:
             print(f"--- QMediaPlayer Error ---")
             print(f"Code: {error.name}, Message: {error_string}")
             print(f"--------------------------")
             self.status_label.setText(f"Status: 再生エラー発生 ({error_string})")
             self.player.stop()
             self.update_playback_buttons(QMediaPlayer.PlaybackState.StoppedState)

    def set_slider_range(self, duration):
        self.position_slider.setRange(0, duration)

    def update_slider(self, position):
        self.position_slider.setValue(position)
        total_duration = self.player.duration()
        if total_duration > 0:
            current_sec = position // 1000
            total_sec = total_duration // 1000
            self.status_label.setText(f"Status: Playing ({current_sec} sec / {total_sec} sec)")

    def seek_position(self, position):
        self.player.setPosition(position)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    ex = MyTTSApp()
    ex.show()
    sys.exit(app.exec())