#!/usr/bin/env python3
from __future__ import annotations

import argparse
import datetime as dt
import ftplib
import json
import os
import re
import shutil
import subprocess
import sys
import time
import unicodedata
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo

try:
    import fcntl
except ImportError:  # pragma: no cover - Linux service uses fcntl.
    fcntl = None


WHISPER_MODEL = None


def ffmpeg_binary(config: dict[str, Any]) -> str:
    configured = config.get("ffmpeg_path", "ffmpeg") or "ffmpeg"
    if configured != "ffmpeg" or shutil.which(configured):
        return configured
    try:
        import imageio_ffmpeg

        return imageio_ffmpeg.get_ffmpeg_exe()
    except Exception:
        return configured


def deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
    result = dict(base)
    for key, value in overlay.items():
        if isinstance(value, dict) and isinstance(result.get(key), dict):
            result[key] = deep_merge(result[key], value)
        else:
            result[key] = value
    return result


DEFAULT_CONFIG: dict[str, Any] = {
    "timezone": "Europe/Istanbul",
    "stream_url": "https://windows.showradyo.com.tr",
    "schedule_times": ["08:00", "12:00"],
    "enabled_weekdays": [1, 2, 3, 4, 5, 6, 7],
    "start_padding_seconds": 8,
    "auto_trim_leading_audio": True,
    "pre_speech_keep_seconds": 4,
    "start_ignore_phrases": [
        "açarız radyoyu",
        "acariz radyoyu",
        "yollere biz oraya",
    ],
    "output_trim_start_seconds": 0,
    "max_duration_seconds": 240,
    "require_stop_phrase": True,
    "chunk_seconds": 15,
    "min_elapsed_before_stop_seconds": 60,
    "ffmpeg_path": "ffmpeg",
    "mp3_bitrate": "128k",
    "output_dir": "/var/lib/show-haber-recorder/recordings",
    "work_dir": "/var/lib/show-haber-recorder/work",
    "state_file": "/var/lib/show-haber-recorder/state.json",
    "trigger_file": "/var/lib/show-haber-recorder/trigger.json",
    "lock_file": "/var/lib/show-haber-recorder/run.lock",
    "log_file": "/var/log/show-haber-recorder/recorder.log",
    "transcription": {
        "engine": "faster_whisper",
        "model": "small",
        "device": "cpu",
        "compute_type": "int8",
        "language": "tr",
    },
    "stop_phrases": [
        "pilot garaj yol durumunu sundu",
        "pilot garaj yoldurumunu sundu",
        "pilot garaj ekspertiz",
        "pilot garaj expertiz",
        "pilot garanç yoldurumunu sundu",
        "pilot garanç oto ekspertiz",
        "pilot garanç ota ekspert",
    ],
    "stop_required_terms": [],
    "stop_phrase_extra_seconds": {
        "pilot garaj yol durumunu sundu": 1.8,
        "pilot garaj yoldurumunu sundu": 1.8,
        "pilot garanç yoldurumunu sundu": 1.8,
    },
    "wrap_audio": {
        "enabled": False,
        "intro_path": "",
        "intro_max_seconds": 0,
        "outro_path": "",
        "outro_max_seconds": 0,
        "trim_silence": True,
        "silence_threshold": "-45dB",
        "crossfade_seconds": 0.35,
        "intro_crossfade_seconds": 1.5,
        "outro_crossfade_seconds": 0.35,
    },
    "upload": {
        "enabled": False,
        "method": "ftp",
        "host": "",
        "port": 21,
        "username": "",
        "password": "",
        "remote_dir": "/",
        "archive_dir": "",
        "latest_filename": "show-haber-son.mp3",
        "use_tls": False,
        "post_upload_command": "",
    },
    "panel": {
        "host": "127.0.0.1",
        "port": 8787,
        "username": "admin",
        "password": "change-me",
    },
    "loudness_normalization": {
        "enabled": True,
        "mode": "boost_news",
        "target_i": -16.0,
        "news_target_i": -11.0,
        "music_target_i": -11.0,
        "normalize_music": False,
        "target_tp": -1.0,
        "target_lra": 11.0,
    },
    "news_audio_profile": {
        "enabled": True,
        "highpass_hz": 85.0,
        "lowpass_hz": 15000.0,
        "low_mid_hz": 250.0,
        "low_mid_gain": -1.2,
        "presence_hz": 3200.0,
        "presence_gain": 2.4,
        "air_hz": 9000.0,
        "air_gain": 1.3,
        "compressor_enabled": True,
        "compressor_threshold": -18.0,
        "compressor_ratio": 2.2,
        "compressor_makeup": 1.5,
        "limiter_enabled": True,
        "limiter_limit": 0.98,
    },
    "stop_extra_seconds": 0.4,
}


def load_config(config_path: Path) -> dict[str, Any]:
    if not config_path.exists():
        return DEFAULT_CONFIG
    with config_path.open("r", encoding="utf-8") as file:
        return deep_merge(DEFAULT_CONFIG, json.load(file))


def save_json(path: Path, data: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp_path = path.with_suffix(path.suffix + ".tmp")
    with tmp_path.open("w", encoding="utf-8") as file:
        json.dump(data, file, ensure_ascii=False, indent=2)
        file.write("\n")
    tmp_path.replace(path)


def log(config: dict[str, Any], message: str) -> None:
    stamp = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    line = f"[{stamp}] {message}"
    print(line, flush=True)
    log_file = Path(config.get("log_file") or "")
    if log_file:
        try:
            log_file.parent.mkdir(parents=True, exist_ok=True)
            with log_file.open("a", encoding="utf-8") as file:
                file.write(line + "\n")
        except OSError:
            print(f"[{stamp}] log file could not be written: {log_file}", file=sys.stderr)


def normalize_text(value: str) -> str:
    value = value.casefold()
    value = unicodedata.normalize("NFKD", value)
    value = "".join(ch for ch in value if not unicodedata.combining(ch))
    value = value.translate(str.maketrans({"ı": "i", "ğ": "g", "ü": "u", "ş": "s", "ö": "o", "ç": "c"}))
    value = re.sub(r"[^a-z0-9]+", " ", value)
    return re.sub(r"\s+", " ", value).strip()


def stop_detected(config: dict[str, Any], transcript_window: str, elapsed_seconds: float) -> bool:
    if elapsed_seconds < int(config.get("min_elapsed_before_stop_seconds", 0)):
        return False

    normalized_window = normalize_text(transcript_window)
    for phrase in config.get("stop_phrases", []):
        normalized_phrase = normalize_text(str(phrase))
        if normalized_phrase and normalized_phrase in normalized_window:
            return True

    required_terms = [normalize_text(str(term)) for term in config.get("stop_required_terms", [])]
    required_terms = [term for term in required_terms if term]
    return bool(required_terms) and all(term in normalized_window for term in required_terms)


def stop_phrase_extra_seconds(config: dict[str, Any], phrase: str) -> float:
    extras = config.get("stop_phrase_extra_seconds", {})
    if not isinstance(extras, dict):
        return 0.0
    normalized_phrase = normalize_text(phrase)
    for key, value in extras.items():
        if normalize_text(str(key)) != normalized_phrase:
            continue
        try:
            return float(value)
        except (TypeError, ValueError):
            return 0.0
    return 0.0


def stop_phrase_cut_position(config: dict[str, Any], phrase: str) -> str:
    positions = config.get("stop_phrase_cut_positions", {})
    if not isinstance(positions, dict):
        return "after"
    normalized_phrase = normalize_text(phrase)
    for key, value in positions.items():
        if normalize_text(str(key)) == normalized_phrase and str(value).casefold() == "before":
            return "before"
    return "after"


def start_text_ignored(config: dict[str, Any], text: str) -> bool:
    normalized_text = normalize_text(text)
    if not normalized_text:
        return True
    for phrase in config.get("start_ignore_phrases", []):
        normalized_phrase = normalize_text(str(phrase))
        if normalized_phrase and normalized_phrase in normalized_text:
            return True
    return False


def transcribe_chunk(config: dict[str, Any], chunk_path: Path) -> tuple[str, float | None, list[dict[str, Any]]]:
    transcription = config.get("transcription", {})
    engine = transcription.get("engine", "faster_whisper")
    if engine == "none":
        return "", None, []
    if engine != "faster_whisper":
        raise RuntimeError(f"Unsupported transcription engine: {engine}")

    global WHISPER_MODEL
    if WHISPER_MODEL is None:
        from faster_whisper import WhisperModel

        WHISPER_MODEL = WhisperModel(
            transcription.get("model", "small"),
            device=transcription.get("device", "cpu"),
            compute_type=transcription.get("compute_type", "int8"),
        )

    segments, _info = WHISPER_MODEL.transcribe(
        str(chunk_path),
        language=transcription.get("language", "tr"),
        vad_filter=True,
        beam_size=5,
        word_timestamps=True,
    )
    text_parts: list[str] = []
    segment_parts: list[dict[str, Any]] = []
    first_start: float | None = None
    for segment in segments:
        text = segment.text.strip()
        if text:
            text_parts.append(text)
            segment_parts.append({
                "start": float(segment.start or 0),
                "end": float(segment.end or segment.start or 0),
                "text": text,
                "words": [
                    {
                        "start": float(word.start or segment.start or 0),
                        "end": float(word.end or segment.end or segment.start or 0),
                        "word": str(word.word).strip(),
                    }
                    for word in (getattr(segment, "words", None) or [])
                    if str(word.word).strip()
                ],
            })
            if first_start is None:
                first_start = float(segment.start or 0)
    return " ".join(text_parts).strip(), first_start, segment_parts


def stop_end_offset(config: dict[str, Any], segments: list[dict[str, Any]], elapsed_seconds: float) -> float | None:
    if elapsed_seconds < int(config.get("min_elapsed_before_stop_seconds", 0)) or not segments:
        return None

    words: list[dict[str, Any]] = []
    for segment in segments[-10:]:
        for word in segment.get("words", []) or []:
            token = normalize_text(str(word.get("word", "")))
            if token:
                words.append({
                    "token": token,
                    "start": float(word.get("start") or segment.get("start") or 0),
                    "end": float(word.get("end") or segment.get("end") or 0),
                })
    if words:
        text_parts: list[str] = []
        spans: list[tuple[int, int, float, float]] = []
        pos = 0
        for word in words[-100:]:
            if text_parts:
                pos += 1
            start = pos
            token = word["token"]
            text_parts.append(token)
            pos += len(token)
            spans.append((start, pos, word["start"], word["end"]))
        normalized_words = " ".join(text_parts)

        def word_time_for_char(char_index: int, edge: str) -> float | None:
            for start, end, word_start, word_end in spans:
                if start <= char_index <= end:
                    return word_start if edge == "start" else word_end
            if not spans:
                return None
            return spans[-1][2] if edge == "start" else spans[-1][3]

        best_end: float | None = None
        for phrase in config.get("stop_phrases", []):
            normalized_phrase = normalize_text(str(phrase))
            if not normalized_phrase:
                continue
            index = normalized_words.rfind(normalized_phrase)
            if index >= 0:
                if stop_phrase_cut_position(config, str(phrase)) == "before":
                    word_end = word_time_for_char(index, "start")
                else:
                    word_end = word_time_for_char(index + len(normalized_phrase), "end")
                if word_end is not None:
                    word_end += stop_phrase_extra_seconds(config, str(phrase))
                    word_end = max(0.0, word_end)
                    best_end = word_end if best_end is None else max(best_end, word_end)
        if best_end is not None:
            return best_end

        term_ends: list[float] = []
        for term in [normalize_text(str(item)) for item in config.get("stop_required_terms", [])]:
            if not term:
                continue
            index = normalized_words.rfind(term)
            if index < 0:
                term_ends = []
                break
            word_end = word_time_for_char(index + len(term), "end")
            if word_end is not None:
                term_ends.append(word_end)
        if term_ends:
            return max(term_ends)

    for index in range(max(0, len(segments) - 10), len(segments)):
        window_start = max(0, index - 7)
        transcript_window = " ".join(str(item.get("text", "")) for item in segments[window_start:index + 1])
        if stop_detected(config, transcript_window, elapsed_seconds):
            return float(segments[index].get("end") or 0)
    return None


def chunk_number(path: Path) -> int:
    match = re.search(r"chunk_(\d+)\.wav$", path.name)
    return int(match.group(1)) if match else -1


def concat_quote(path: Path) -> str:
    return "'" + str(path).replace("'", "'\\''") + "'"


def build_mp3(
    config: dict[str, Any],
    chunks: list[Path],
    output_path: Path,
    trim_start_seconds: float = 0,
    trim_end_seconds: float | None = None,
) -> None:
    if not chunks:
        raise RuntimeError("No audio chunks were created")

    concat_path = output_path.with_suffix(".concat.txt")
    with concat_path.open("w", encoding="utf-8") as file:
        for chunk in chunks:
            file.write(f"file {concat_quote(chunk.resolve())}\n")

    tmp_output = output_path.with_suffix(".tmp.mp3")
    command = [
        ffmpeg_binary(config),
        "-hide_banner",
        "-loglevel",
        "warning",
        "-y",
        "-f",
        "concat",
        "-safe",
        "0",
        "-i",
        str(concat_path),
    ]
    if trim_start_seconds > 0:
        command.extend(["-ss", f"{trim_start_seconds:.3f}"])
    if trim_end_seconds is not None:
        duration = max(0.1, float(trim_end_seconds) - max(0.0, trim_start_seconds))
        command.extend(["-t", f"{duration:.3f}"])
    command.extend([
        "-vn",
        "-c:a",
        "libmp3lame",
        "-b:a",
        config.get("mp3_bitrate", "128k"),
        str(tmp_output),
    ])
    subprocess.run(command, check=True)
    tmp_output.replace(output_path)


def ftp_upload(config: dict[str, Any], local_path: Path, remote_dir: str, remote_name: str) -> None:
    upload = config["upload"]
    method = upload.get("method", "ftp")
    host = upload["host"]
    port = int(upload.get("port") or (22 if method == "sftp" else 21))
    username = upload["username"]
    password = upload["password"]

    if method == "sftp":
        import paramiko

        transport = paramiko.Transport((host, port))
        transport.connect(username=username, password=password)
        try:
            sftp = paramiko.SFTPClient.from_transport(transport)
            try:
                if remote_dir:
                    sftp.chdir(remote_dir)
                sftp.put(str(local_path), remote_name)
            finally:
                sftp.close()
        finally:
            transport.close()
        return

    ftp_class = ftplib.FTP_TLS if upload.get("use_tls") or method == "ftps" else ftplib.FTP
    with ftp_class() as ftp:
        ftp.connect(host, port, timeout=30)
        ftp.login(username, password)
        if isinstance(ftp, ftplib.FTP_TLS):
            ftp.prot_p()
        if remote_dir:
            ftp.cwd(remote_dir)
        with local_path.open("rb") as file:
            ftp.storbinary(f"STOR {remote_name}", file)


def media_path_to_remote(path: str) -> str:
    value = path.strip().replace("\\", "/")
    mappings = (
        ("/web-radyo/muzik/", "/media/"),
        ("/web-radyo/jingle/", "/jingle/"),
        ("/web-radyo/haber/", "/haber/"),
    )
    for prefix, remote_prefix in mappings:
        if value.startswith(prefix):
            return remote_prefix + value[len(prefix) :]
    return value


def sftp_download(config: dict[str, Any], remote_path: str, local_path: Path) -> None:
    import paramiko

    upload = config.get("upload", {})
    host = upload["host"]
    port = int(upload.get("port") or 22)
    username = upload["username"]
    password = upload["password"]
    transport = paramiko.Transport((host, port))
    transport.connect(username=username, password=password)
    try:
        sftp = paramiko.SFTPClient.from_transport(transport)
        try:
            local_path.parent.mkdir(parents=True, exist_ok=True)
            sftp.get(remote_path, str(local_path))
        finally:
            sftp.close()
    finally:
        transport.close()


def prepared_wrap_source(
    config: dict[str, Any],
    source_path: str,
    work_dir: Path,
    name: str,
    max_seconds: float,
) -> Path | None:
    if not source_path.strip():
        return None

    source = Path(source_path)
    if source.exists():
        local_source = source
    else:
        remote_path = media_path_to_remote(source_path)
        local_source = work_dir / f"{name}-source{Path(remote_path).suffix or '.mp3'}"
        log(config, f"Downloading {name} audio from SFTP: {remote_path}")
        sftp_download(config, remote_path, local_source)

    if max_seconds <= 0:
        return local_source

    trimmed_path = work_dir / f"{name}-trimmed.mp3"
    command = [
        ffmpeg_binary(config),
        "-hide_banner",
        "-loglevel",
        "warning",
        "-y",
        "-t",
        f"{max_seconds:.3f}",
        "-i",
        str(local_source),
        "-vn",
        "-c:a",
        "libmp3lame",
        "-b:a",
        config.get("mp3_bitrate", "128k"),
        str(trimmed_path),
    ]
    subprocess.run(command, check=True)
    return trimmed_path


def normalize_concat_source(
    config: dict[str, Any],
    source_path: Path,
    work_dir: Path,
    name: str,
    role: str = "news",
) -> Path:
    loudness = config.get("loudness_normalization", {})
    wrap = config.get("wrap_audio", {})
    target_tp = float(loudness.get("target_tp", -1.0))
    target_lra = float(loudness.get("target_lra", 11.0))
    output_path = work_dir / f"{name}-concat-ready.mp3"
    audio_filter = "aresample=44100"

    def audio_profile_float(key: str, default: float, minimum: float | None = None, maximum: float | None = None) -> float:
        try:
            value = float(news_profile.get(key, default))
        except (TypeError, ValueError):
            value = float(default)
        if minimum is not None:
            value = max(minimum, value)
        if maximum is not None:
            value = min(maximum, value)
        return value

    if wrap.get("trim_silence", True):
        threshold = str(wrap.get("silence_threshold") or "-45dB")
        audio_filter += (
            f",silenceremove=start_periods=1:start_duration=0.08:start_threshold={threshold}"
            f",areverse,silenceremove=start_periods=1:start_duration=0.12:start_threshold={threshold},areverse"
        )
    news_profile = config.get("news_audio_profile", {})
    if role == "news" and news_profile.get("enabled", True):
        highpass_hz = audio_profile_float("highpass_hz", 85.0, 20.0, 300.0)
        lowpass_hz = audio_profile_float("lowpass_hz", 15000.0, 6000.0, 22000.0)
        low_mid_hz = audio_profile_float("low_mid_hz", 250.0, 120.0, 600.0)
        low_mid_gain = audio_profile_float("low_mid_gain", -1.2, -6.0, 3.0)
        presence_hz = audio_profile_float("presence_hz", 3200.0, 1500.0, 5000.0)
        presence_gain = audio_profile_float("presence_gain", 2.4, -3.0, 6.0)
        air_hz = audio_profile_float("air_hz", 9000.0, 6000.0, 14000.0)
        air_gain = audio_profile_float("air_gain", 1.3, -3.0, 5.0)
        audio_filter += (
            f",highpass=f={highpass_hz:.1f}"
            f",lowpass=f={lowpass_hz:.1f}"
            f",equalizer=f={low_mid_hz:.1f}:t=q:w=1.0:g={low_mid_gain:.2f}"
            f",equalizer=f={presence_hz:.1f}:t=q:w=1.2:g={presence_gain:.2f}"
            f",equalizer=f={air_hz:.1f}:t=q:w=1.0:g={air_gain:.2f}"
        )
        if news_profile.get("compressor_enabled", True):
            threshold_db = audio_profile_float("compressor_threshold", -18.0, -35.0, -6.0)
            ratio = audio_profile_float("compressor_ratio", 2.2, 1.0, 6.0)
            makeup = audio_profile_float("compressor_makeup", 1.5, 0.0, 6.0)
            audio_filter += (
                f",acompressor=threshold={threshold_db:.1f}dB:ratio={ratio:.2f}:"
                f"attack=8:release=120:makeup={makeup:.2f}"
            )
    if loudness.get("enabled", True):
        mode = str(loudness.get("mode", "boost_news"))
        normalize_music = bool(loudness.get("normalize_music", False))
        target_i: float | None = None
        if mode == "legacy_all":
            target_i = float(loudness.get("target_i", -16.0))
        elif role == "news":
            target_i = float(loudness.get("news_target_i", loudness.get("target_i", -11.0)))
        elif normalize_music:
            target_i = float(loudness.get("music_target_i", loudness.get("target_i", -11.0)))
        if target_i is not None:
            audio_filter += f",loudnorm=I={target_i}:TP={target_tp}:LRA={target_lra}"
    if role == "news" and news_profile.get("enabled", True) and news_profile.get("limiter_enabled", True):
        limiter_limit = audio_profile_float("limiter_limit", 0.98, 0.7, 1.0)
        audio_filter += f",alimiter=limit={limiter_limit:.3f}"

    command = [
        ffmpeg_binary(config),
        "-hide_banner",
        "-loglevel",
        "warning",
        "-y",
        "-i",
        str(source_path),
        "-vn",
        "-af",
        audio_filter,
        "-ar",
        "44100",
        "-ac",
        "2",
        "-c:a",
        "libmp3lame",
        "-b:a",
        config.get("mp3_bitrate", "128k"),
        str(output_path),
    ]
    subprocess.run(command, check=True)
    return output_path


def crossfade_sources(
    config: dict[str, Any],
    sources: list[Path],
    output_path: Path,
    crossfade_seconds: float | list[float],
) -> None:
    if len(sources) < 2:
        shutil.copyfile(sources[0], output_path)
        return

    command = [
        ffmpeg_binary(config),
        "-hide_banner",
        "-loglevel",
        "warning",
        "-y",
    ]
    for source in sources:
        command.extend(["-i", str(source)])

    filters = [f"[{index}:a]asetpts=PTS-STARTPTS[a{index}]" for index in range(len(sources))]
    current = "[a0]"
    for index in range(1, len(sources)):
        if isinstance(crossfade_seconds, list):
            transition_seconds = crossfade_seconds[index - 1] if index - 1 < len(crossfade_seconds) else 0.35
        else:
            transition_seconds = crossfade_seconds
        transition_seconds = max(0.001, transition_seconds)
        output_label = f"[xf{index}]"
        filters.append(
            f"{current}[a{index}]acrossfade=d={transition_seconds:.3f}:c1=tri:c2=tri{output_label}"
        )
        current = output_label

    command.extend([
        "-filter_complex",
        ";".join(filters),
        "-map",
        current,
        "-vn",
        "-c:a",
        "libmp3lame",
        "-b:a",
        config.get("mp3_bitrate", "128k"),
        str(output_path),
    ])
    subprocess.run(command, check=True)


def wrap_recording_audio(config: dict[str, Any], mp3_path: Path, work_dir: Path) -> None:
    wrap = config.get("wrap_audio", {})
    if not wrap.get("enabled"):
        return

    sources: list[tuple[str, Path]] = []
    intro = prepared_wrap_source(
        config,
        str(wrap.get("intro_path", "")),
        work_dir,
        "intro",
        float(wrap.get("intro_max_seconds", 0) or 0),
    )
    outro = prepared_wrap_source(
        config,
        str(wrap.get("outro_path", "")),
        work_dir,
        "outro",
        float(wrap.get("outro_max_seconds", 0) or 0),
    )
    if intro:
        sources.append(("intro", intro))
    sources.append(("news", mp3_path))
    if outro:
        sources.append(("outro", outro))
    if len(sources) <= 1:
        return

    roles = [role for role, _source in sources]
    ready_sources = [
        normalize_concat_source(config, source, work_dir, f"wrap-{index:02d}-{role}", role=role)
        for index, (role, source) in enumerate(sources)
    ]

    tmp_output = mp3_path.with_suffix(".wrapped.tmp.mp3")
    fallback_crossfade = max(0.0, min(2.0, float(wrap.get("crossfade_seconds", 0.35) or 0)))
    intro_crossfade = max(
        0.0,
        min(2.0, float(wrap.get("intro_crossfade_seconds", fallback_crossfade) or 0)),
    )
    outro_crossfade = max(
        0.0,
        min(2.0, float(wrap.get("outro_crossfade_seconds", fallback_crossfade) or 0)),
    )
    transition_crossfades: list[float] = []
    for left_role, right_role in zip(roles, roles[1:]):
        if left_role == "intro" and right_role == "news":
            transition_crossfades.append(intro_crossfade)
        elif left_role == "news" and right_role == "outro":
            transition_crossfades.append(outro_crossfade)
        else:
            transition_crossfades.append(fallback_crossfade)

    if any(seconds > 0 for seconds in transition_crossfades):
        crossfade_sources(config, ready_sources, tmp_output, transition_crossfades)
    else:
        concat_path = work_dir / "wrapped.concat.txt"
        with concat_path.open("w", encoding="utf-8") as file:
            for source in ready_sources:
                file.write(f"file {concat_quote(source.resolve())}\n")
        command = [
            ffmpeg_binary(config),
            "-hide_banner",
            "-loglevel",
            "warning",
            "-y",
            "-f",
            "concat",
            "-safe",
            "0",
            "-i",
            str(concat_path),
            "-vn",
            "-c:a",
            "libmp3lame",
            "-b:a",
            config.get("mp3_bitrate", "128k"),
            str(tmp_output),
        ]
        subprocess.run(command, check=True)
    tmp_output.replace(mp3_path)
    log(
        config,
        "Intro/news/outro audio wrapped into one MP3 "
        f"(intro crossfade {intro_crossfade:.2f}s, outro crossfade {outro_crossfade:.2f}s)",
    )


def upload_recording(config: dict[str, Any], mp3_path: Path) -> None:
    upload = config.get("upload", {})
    if not upload.get("enabled"):
        log(config, "Upload disabled; MP3 kept locally")
        return

    missing = [key for key in ("host", "username", "password") if not upload.get(key)]
    if missing:
        raise RuntimeError(f"Upload enabled, but missing fields: {', '.join(missing)}")

    archive_dir = upload.get("archive_dir") or upload.get("remote_dir") or "/"
    remote_dir = upload.get("remote_dir") or "/"
    log(config, f"Uploading archive copy to {archive_dir}/{mp3_path.name}")
    ftp_upload(config, mp3_path, archive_dir, mp3_path.name)

    latest_filename = upload.get("latest_filename")
    if latest_filename:
        log(config, f"Uploading latest copy to {remote_dir}/{latest_filename}")
        ftp_upload(config, mp3_path, remote_dir, latest_filename)

    post_upload = upload.get("post_upload_command") or ""
    if post_upload.strip():
        env = os.environ.copy()
        env["SHOW_HABER_MP3"] = str(mp3_path)
        log(config, "Running post-upload command")
        subprocess.run(post_upload, shell=True, check=True, env=env)


class RunLock:
    def __init__(self, path: Path):
        self.path = path
        self.file = None

    def __enter__(self) -> "RunLock":
        self.path.parent.mkdir(parents=True, exist_ok=True)
        self.file = self.path.open("w", encoding="utf-8")
        if fcntl is not None:
            try:
                fcntl.flock(self.file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
            except BlockingIOError as exc:
                raise RuntimeError("A recording is already running") from exc
        self.file.write(str(os.getpid()))
        self.file.flush()
        return self

    def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
        if self.file and fcntl is not None:
            fcntl.flock(self.file.fileno(), fcntl.LOCK_UN)
        if self.file:
            self.file.close()


def run_recording(config: dict[str, Any], reason: str = "manual") -> Path | None:
    with RunLock(Path(config["lock_file"])):
        tz = ZoneInfo(config.get("timezone", "Europe/Istanbul"))
        started_at = dt.datetime.now(tz)
        run_id = started_at.strftime("%Y%m%d-%H%M%S")
        output_dir = Path(config["output_dir"])
        work_root = Path(config["work_dir"])
        run_work_dir = work_root / run_id
        chunk_dir = run_work_dir / "chunks"
        chunk_dir.mkdir(parents=True, exist_ok=True)
        output_dir.mkdir(parents=True, exist_ok=True)

        output_path = output_dir / f"show-haber-{started_at:%Y-%m-%d-%H%M}.mp3"
        transcript_path = output_path.with_suffix(".transcript.txt")
        log(config, f"Recording started ({reason}) from {config['stream_url']}")

        ffmpeg_command = [
            ffmpeg_binary(config),
            "-hide_banner",
            "-loglevel",
            "warning",
            "-y",
            "-i",
            config["stream_url"],
            "-vn",
            "-ac",
            "1",
            "-ar",
            "16000",
            "-f",
            "segment",
            "-segment_time",
            str(int(config.get("chunk_seconds", 15))),
            "-reset_timestamps",
            "1",
            str(chunk_dir / "chunk_%05d.wav"),
        ]
        process = subprocess.Popen(ffmpeg_command)

        processed: set[Path] = set()
        transcript_parts: list[str] = []
        transcript_segments: list[dict[str, Any]] = []
        first_speech_offset: float | None = None
        detected_at_chunk: int | None = None
        detected_end_offset: float | None = None
        pending_stop_end_offset: float | None = None
        max_duration = int(config.get("max_duration_seconds", 1200))
        start_monotonic = time.monotonic()

        try:
            while time.monotonic() - start_monotonic < max_duration:
                chunks = sorted(chunk_dir.glob("chunk_*.wav"), key=chunk_number)
                ready_chunks = chunks[:-1] if process.poll() is None else chunks
                for chunk in ready_chunks:
                    if chunk in processed or chunk.stat().st_size == 0:
                        continue
                    processed.add(chunk)
                    elapsed = time.monotonic() - start_monotonic
                    try:
                        text, first_segment_start, chunk_segments = transcribe_chunk(config, chunk)
                    except Exception as exc:
                        log(config, f"Transcription failed for {chunk.name}: {exc}")
                        text = ""
                        first_segment_start = None
                        chunk_segments = []
                    if text:
                        chunk_start = chunk_number(chunk) * int(config.get("chunk_seconds", 15))
                        for segment in chunk_segments:
                            transcript_segments.append({
                                "start": chunk_start + float(segment.get("start") or 0),
                                "end": chunk_start + float(segment.get("end") or 0),
                                "text": segment.get("text", ""),
                                "words": [
                                    {
                                        "start": chunk_start + float(word.get("start") or 0),
                                        "end": chunk_start + float(word.get("end") or 0),
                                        "word": word.get("word", ""),
                                    }
                                    for word in (segment.get("words") or [])
                                ],
                            })
                        if first_speech_offset is None:
                            for segment in chunk_segments:
                                segment_text = str(segment.get("text", ""))
                                if start_text_ignored(config, segment_text):
                                    continue
                                first_speech_offset = chunk_start + float(segment.get("start") or 0)
                                break
                            if first_speech_offset is None and first_segment_start is not None and not start_text_ignored(config, text):
                                first_speech_offset = chunk_start + float(first_segment_start or 0)
                        transcript_parts.append(text)
                        log(config, f"Transcript {chunk.name}: {text}")
                    recent_text = " ".join(transcript_parts[-6:])
                    if stop_detected(config, recent_text, elapsed):
                        candidate_end_offset = stop_end_offset(config, transcript_segments, elapsed)
                        if candidate_end_offset is not None:
                            candidate_end_offset += float(config.get("stop_extra_seconds", 0.8) or 0)
                            current_chunk_end = (chunk_number(chunk) + 1) * int(config.get("chunk_seconds", 15))
                            if candidate_end_offset > current_chunk_end:
                                if pending_stop_end_offset is None or candidate_end_offset > pending_stop_end_offset:
                                    pending_stop_end_offset = candidate_end_offset
                                    log(
                                        config,
                                        f"Stop phrase detected in {chunk.name}; waiting until {pending_stop_end_offset:.1f}s",
                                    )
                                continue
                            detected_at_chunk = chunk_number(chunk)
                            detected_end_offset = candidate_end_offset
                            log(config, f"Stop phrase detected in {chunk.name}; cutting at {detected_end_offset:.1f}s")
                        else:
                            detected_at_chunk = chunk_number(chunk)
                            log(config, f"Stop phrase detected in {chunk.name}")
                        raise StopIteration
                    if pending_stop_end_offset is not None:
                        current_chunk_end = (chunk_number(chunk) + 1) * int(config.get("chunk_seconds", 15))
                        if current_chunk_end >= pending_stop_end_offset:
                            detected_at_chunk = chunk_number(chunk)
                            detected_end_offset = pending_stop_end_offset
                            log(config, f"Delayed stop reached in {chunk.name}; cutting at {detected_end_offset:.1f}s")
                            raise StopIteration
                if process.poll() is not None:
                    break
                time.sleep(2)
        except StopIteration:
            pass
        finally:
            if process.poll() is None:
                process.terminate()
                try:
                    process.wait(timeout=10)
                except subprocess.TimeoutExpired:
                    process.kill()
                    process.wait(timeout=10)

        elapsed_total = time.monotonic() - start_monotonic
        chunks = sorted(chunk_dir.glob("chunk_*.wav"), key=chunk_number)
        chunks = [chunk for chunk in chunks if chunk.stat().st_size > 0]
        if detected_at_chunk is not None:
            chunks = [chunk for chunk in chunks if chunk_number(chunk) <= detected_at_chunk]

        if not chunks:
            log(config, "No chunks were captured; no MP3 created")
            return None
        if config.get("require_stop_phrase", True) and detected_at_chunk is None:
            transcript_path.write_text("\n".join(transcript_parts) + "\n", encoding="utf-8")
            log(config, "Stop phrase was not detected; recording discarded without upload")
            shutil.rmtree(run_work_dir, ignore_errors=True)
            return None

        trim_start = float(config.get("output_trim_start_seconds", 0) or 0)
        if config.get("auto_trim_leading_audio", True) and first_speech_offset is not None:
            pre_speech_keep = float(config.get("pre_speech_keep_seconds", 3) or 0)
            trim_start = max(trim_start, first_speech_offset - pre_speech_keep)
        if trim_start > 0:
            log(config, f"Trimming first {trim_start:.1f}s from output")
        if detected_end_offset is not None:
            log(config, f"Trimming output after {detected_end_offset:.1f}s")
        build_mp3(config, chunks, output_path, trim_start_seconds=trim_start, trim_end_seconds=detected_end_offset)
        wrap_recording_audio(config, output_path, run_work_dir)
        transcript_path.write_text("\n".join(transcript_parts) + "\n", encoding="utf-8")
        log(config, f"MP3 ready: {output_path} ({elapsed_total:.0f}s)")
        upload_recording(config, output_path)
        shutil.rmtree(run_work_dir, ignore_errors=True)
        return output_path


def load_state(config: dict[str, Any]) -> dict[str, Any]:
    path = Path(config["state_file"])
    if not path.exists():
        return {"runs": []}
    try:
        with path.open("r", encoding="utf-8") as file:
            state = json.load(file)
            return state if isinstance(state, dict) else {"runs": []}
    except (OSError, json.JSONDecodeError):
        return {"runs": []}


def save_state(config: dict[str, Any], state: dict[str, Any]) -> None:
    save_json(Path(config["state_file"]), state)


def consume_trigger(config: dict[str, Any]) -> str:
    trigger_file = config.get("trigger_file")
    if not trigger_file:
        return ""
    path = Path(trigger_file)
    if not path.exists():
        return ""
    try:
        with path.open("r", encoding="utf-8") as file:
            payload = json.load(file)
    except (OSError, json.JSONDecodeError):
        payload = {}
    try:
        path.unlink()
    except OSError:
        pass
    reason = str(payload.get("reason") or payload.get("command") or "panel-trigger")
    return reason[:80]


def parse_schedule_time(value: str) -> tuple[int, int]:
    hour, minute = value.strip().split(":", 1)
    return int(hour), int(minute)


def daemon_loop(config_path: Path) -> None:
    log(load_config(config_path), f"Daemon started with config {config_path}")
    while True:
        config = load_config(config_path)
        tz = ZoneInfo(config.get("timezone", "Europe/Istanbul"))
        now = dt.datetime.now(tz)
        trigger_reason = consume_trigger(config)
        if trigger_reason:
            try:
                run_recording(config, reason=trigger_reason)
            except Exception as exc:
                log(config, f"Triggered recording failed: {exc}")
            time.sleep(5)
            continue
        if now.isoweekday() not in set(config.get("enabled_weekdays", [])):
            time.sleep(10)
            continue

        state = load_state(config)
        runs = set(state.get("runs", []))
        keep_after = (now - dt.timedelta(days=14)).strftime("%Y-%m-%d")
        runs = {run for run in runs if run[:10] >= keep_after}

        for item in config.get("schedule_times", []):
            try:
                hour, minute = parse_schedule_time(str(item))
            except ValueError:
                log(config, f"Invalid schedule time ignored: {item}")
                continue
            scheduled = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
            run_at = scheduled - dt.timedelta(seconds=int(config.get("start_padding_seconds", 0)))
            seconds_since_run_at = (now - run_at).total_seconds()
            run_key = f"{scheduled:%Y-%m-%d} {hour:02d}:{minute:02d}"
            if 0 <= seconds_since_run_at < 10 and run_key not in runs:
                runs.add(run_key)
                state["runs"] = sorted(runs)
                save_state(config, state)
                try:
                    run_recording(config, reason=f"schedule {hour:02d}:{minute:02d}")
                except Exception as exc:
                    log(config, f"Recording failed: {exc}")
                break
        time.sleep(5)


def main() -> int:
    parser = argparse.ArgumentParser(description="Show Radyo haber bulteni kaydedici")
    parser.add_argument("--config", default=os.environ.get("SHOW_HABER_CONFIG", "/etc/show-haber-recorder.json"))
    subparsers = parser.add_subparsers(dest="command", required=True)
    run_parser = subparsers.add_parser("run", help="Start one recording immediately")
    run_parser.add_argument("--reason", default="manual")
    subparsers.add_parser("daemon", help="Run scheduler loop")
    args = parser.parse_args()

    config_path = Path(args.config)
    config = load_config(config_path)
    if args.command == "run":
        run_recording(config, reason=args.reason)
        return 0
    if args.command == "daemon":
        daemon_loop(config_path)
        return 0
    return 1


if __name__ == "__main__":
    raise SystemExit(main())

