# Orca
#
# Copyright 2026 Igalia, S.L.
# Author: Joanmarie Diggs <jdiggs@igalia.com>
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA  02110-1301 USA.

"""Reader for the speech and braille JSONL logs written by orca.output_recorder."""

import contextlib
import json
import os
import queue
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum
from typing import cast


class Kind(Enum):
    """Record kinds matching the JSON records written by the presenters and event manager."""

    SPEECH = "speech"
    BRAILLE = "braille"
    INTERRUPT = "interrupt"


@dataclass
class SpeechRecord:
    """A record parsed from the speech log. One record per synthesizer utterance."""

    text: str
    language: str = ""
    dialect: str = ""


@dataclass
class BrailleRecord:
    """A record parsed from the braille log."""

    cursor_cell: int
    full: str
    visible: str
    mask: str | None = None


@dataclass
class InterruptRecord:
    """Marks a deliberate speech interruption so observers drop the superseded speech."""


class OutputReader:
    """Reads the JSONL files written by the speech and braille presenters."""

    _POLL_INTERVAL = 0.02

    def __init__(self, speech_path: str, braille_path: str) -> None:
        self._speech_path = speech_path
        self._braille_path = braille_path
        self._queue: queue.Queue = queue.Queue()
        self._pending: list[SpeechRecord | BrailleRecord | InterruptRecord] = []
        self._stop_event = threading.Event()
        self._threads: list[threading.Thread] = []
        self._consumed: dict[str, int] = {speech_path: 0, braille_path: 0}
        self._idle_check: Callable[[], bool] | None = None

    def set_idle_check(self, idle_check: Callable[[], bool] | None) -> None:
        """Sets a callable reporting whether Orca has finished processing the current input."""

        self._idle_check = idle_check

    def start(self) -> None:
        """Starts the reader threads."""

        self._stop_event.clear()
        self._threads = [
            threading.Thread(
                target=self._enqueue_records,
                args=(self._speech_path, self._parse_speech),
                name="orca-output-speech-reader",
                daemon=True,
            ),
            threading.Thread(
                target=self._enqueue_records,
                args=(self._braille_path, self._parse_braille),
                name="orca-output-braille-reader",
                daemon=True,
            ),
        ]
        for thread in self._threads:
            thread.start()

    def stop(self, join_timeout: float = 1.0) -> None:
        """Signals readers to stop and joins the threads."""

        self._stop_event.set()
        for thread in self._threads:
            thread.join(timeout=join_timeout)
        self._threads = []

    def drain(
        self,
        quiescence_timeout: float = 0.1,
        overall_timeout: float = 5.0,
        first_record_timeout: float = 0.3,
        idle_aware: bool = True,
    ) -> list[SpeechRecord | BrailleRecord]:
        """Returns records that have arrived, waiting for the stream to go quiet."""

        scale = float(os.environ.get("ORCA_TEST_TIMEOUT_SCALE") or 1.0)
        if scale > 1.0:
            # Slowed-down (coverage) runs space deferred presentations further apart than the
            # fast-run quiescence allows, and is_idle() only sees the object-event queue, not
            # pending presents, so it can read idle mid-response. Restore a conservative floor.
            quiescence_timeout = max(quiescence_timeout, 0.3)
        quiescence_timeout *= scale
        overall_timeout *= scale
        first_record_timeout *= scale
        records: list[SpeechRecord | BrailleRecord | InterruptRecord] = list(self._pending)
        self._pending.clear()
        # The overall timeout bounds inactivity, not the whole drain: Say All produces
        # records for as long as it runs.
        deadline = time.monotonic() + overall_timeout
        while True:
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                return self._honor_interrupts(records)
            wait = quiescence_timeout if records else first_record_timeout
            try:
                record = self._queue.get(timeout=min(wait, remaining))
            except queue.Empty:
                if idle_aware and self._idle_check is not None:
                    if not self._idle_check():
                        continue
                    if self._await_more_output(records, remaining):
                        continue
                return self._honor_interrupts(records)
            records.append(record)
            deadline = time.monotonic() + overall_timeout

    def _await_more_output(self, records: list, remaining: float) -> bool:
        """Returns True if output arrived after Orca first reported itself idle."""

        limit = time.monotonic() + remaining
        while time.monotonic() < limit and not self._readers_are_caught_up():
            with contextlib.suppress(queue.Empty):
                records.append(self._queue.get(timeout=self._POLL_INTERVAL))
                return True
        return False

    def _readers_are_caught_up(self) -> bool:
        """Returns True if both logs have been read as far as they have been written."""

        for path, consumed in self._consumed.items():
            with contextlib.suppress(OSError):
                if consumed < os.path.getsize(path):
                    return False
        return self._queue.empty()

    @staticmethod
    def _honor_interrupts(
        records: list[SpeechRecord | BrailleRecord | InterruptRecord],
    ) -> list[SpeechRecord | BrailleRecord]:
        """Drops speech preceding the last interrupt marker and strips the markers."""

        last_interrupt = -1
        for index, record in enumerate(records):
            if isinstance(record, InterruptRecord):
                last_interrupt = index

        result: list[SpeechRecord | BrailleRecord] = []
        for index, record in enumerate(records):
            if isinstance(record, InterruptRecord):
                continue
            if isinstance(record, SpeechRecord) and index < last_interrupt:
                continue
            result.append(record)
        return result

    def wait_for_speech(self, substring: str, timeout: float = 2.0) -> SpeechRecord:
        """Drains records until a SpeechRecord containing substring arrives or timeout."""

        record = self._wait_for(
            lambda r: isinstance(r, SpeechRecord) and substring in r.text,
            timeout,
            f"speech containing {substring!r}",
        )
        return cast("SpeechRecord", record)

    def wait_for_braille(self, substring: str, timeout: float = 2.0) -> BrailleRecord:
        """Drains records until a BrailleRecord containing substring arrives or timeout."""

        record = self._wait_for(
            lambda r: isinstance(r, BrailleRecord) and substring in r.full,
            timeout,
            f"braille containing {substring!r}",
        )
        return cast("BrailleRecord", record)

    def reset(self) -> None:
        """Discards buffered records so the next wait_for_* sees only new activity."""

        self._pending.clear()
        try:
            while True:
                self._queue.get_nowait()
        except queue.Empty:
            pass

    def _wait_for(
        self, predicate, timeout: float, description: str
    ) -> SpeechRecord | BrailleRecord | InterruptRecord:
        """Returns the first record matching predicate; non-matching records are buffered."""

        for index, record in enumerate(self._pending):
            if predicate(record):
                return self._pending.pop(index)

        deadline = time.monotonic() + timeout
        while True:
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                raise TimeoutError(f"No {description} arrived within {timeout}s")
            try:
                record = self._queue.get(timeout=remaining)
            except queue.Empty:
                raise TimeoutError(f"No {description} arrived within {timeout}s") from None
            if predicate(record):
                return record
            self._pending.append(record)

    def _enqueue_records(self, path: str, parser) -> None:
        """Reads lines from path as they arrive and enqueues each parsed record."""

        while not os.path.exists(path):
            if self._stop_event.wait(timeout=self._POLL_INTERVAL):
                return

        # Read bytes, so that what has been consumed can be compared against the size of
        # the file, and so that a record split across two reads cannot fail to decode.
        with open(path, "rb") as handle:
            # A read can land mid-write, so hold a partial record until its newline arrives.
            partial = b""
            while not self._stop_event.is_set():
                if chunk := handle.readline():
                    partial += chunk
                    if partial.endswith(b"\n"):
                        if record := parser(partial.decode("utf-8")):
                            self._queue.put(record)
                        self._consumed[path] += len(partial)
                        partial = b""
                    continue
                if self._stop_event.wait(timeout=self._POLL_INTERVAL):
                    return

    @staticmethod
    def _parse_speech(line: str) -> SpeechRecord | InterruptRecord | None:
        """Parses a line from the speech log."""

        try:
            data = json.loads(line)
        except json.JSONDecodeError:
            return None
        kind = data.get("kind")
        if kind == Kind.INTERRUPT.value:
            return InterruptRecord()
        if kind != Kind.SPEECH.value:
            return None
        return SpeechRecord(
            text=data.get("text", ""),
            language=data.get("language", ""),
            dialect=data.get("dialect", ""),
        )

    @staticmethod
    def _parse_braille(line: str) -> BrailleRecord | None:
        """Parses a line from the braille log."""

        try:
            data = json.loads(line)
        except json.JSONDecodeError:
            return None
        if data.get("kind") != Kind.BRAILLE.value:
            return None
        return BrailleRecord(
            cursor_cell=int(data.get("cursor_cell", 0)),
            full=data.get("full", ""),
            visible=data.get("visible", ""),
            mask=data.get("mask"),
        )


def speech(records: list[SpeechRecord | BrailleRecord]) -> list[str]:
    """Returns the spoken text strings from records."""

    return [r.text for r in records if isinstance(r, SpeechRecord)]


def braille(records: list[SpeechRecord | BrailleRecord]) -> list[str]:
    """Returns the full braille line strings from records."""

    return [r.full for r in records if isinstance(r, BrailleRecord)]
