"""Drill screen — predict-output challenges + coding problems with inline editor."""

import json
import logging
from pathlib import Path

log = logging.getLogger(__name__)

from rich.syntax import Syntax
from textual import work
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.widgets import Input, Static, TextArea

from skilark_cli.answer_checker import check_answer
from skilark_cli.client import SkilarkClient
from skilark_cli.executor import build_python_harness, run_code_local


# Language mapping for Pygments syntax highlighting (quiz mode).
_LANG_MAP = {
    "python": "python", "go": "go", "java": "java",
    "javascript": "javascript", "typescript": "typescript",
    "rust": "rust", "c": "c", "cpp": "cpp", "sql": "sql",
    "spark": "python", "pytorch": "python", "spring-boot": "java",
    "kubernetes": "yaml", "docker": "dockerfile", "terraform": "hcl",
    "cicd": "yaml", "bash": "bash", "linux": "bash",
    "redis": "bash", "airflow": "python",
}

# Languages available for coding problems (order for L cycling).
_CODING_LANGUAGES = ["python"]

# Difficulty stars.
_DIFFICULTY_STARS = {1: "★☆☆", 2: "★★☆", 3: "★★★"}


def _ensure_parsed(value, fallback_type: type = list):
    """Parse a JSON string into a Python object, or return as-is if already parsed.

    Returns fallback_type() (empty list or dict) on failure.
    """
    if isinstance(value, str):
        try:
            return json.loads(value)
        except (json.JSONDecodeError, TypeError):
            return fallback_type()
    return value if value is not None else fallback_type()


class DrillPane(Vertical, can_focus=True):
    """Daily drill — predict-output challenges + coding problems."""

    BINDINGS = [
        Binding("h", "show_hint", "Hint", show=True),
        Binding("s", "skip", "Skip", show=True),
        Binding("n", "next_challenge", "Next", show=True),
        Binding("m", "toggle_mode", "Mode", show=True),
        Binding("ctrl+r", "run_code", "Run", show=False),
    ]

    # Seconds of inactivity before editor changes are autosaved to cache.
    AUTOSAVE_DELAY: float = 5.0

    def __init__(
        self,
        client: SkilarkClient | None = None,
        topics: list[str] | None = None,
        coding_language: str | None = None,
        code_cache_dir: Path | None = None,
        autosave_delay: float | None = None,
    ) -> None:
        super().__init__()
        self.client = client
        self.topics = topics or []
        if autosave_delay is not None:
            self.AUTOSAVE_DELAY = autosave_delay
        # Quiz mode state
        self._challenge: dict | None = None
        self._hints: list[str] = []
        self._hint_idx: int = 0
        self._day: int = 1
        self._answered: bool = False
        self._stats_loaded: bool = False  # True after first stats fetch (shared across modes)
        self._pending_answer: str = ""
        # Mode state
        self._mode: str = "quiz"  # "quiz" or "code"
        # Code mode state
        self._coding_language: str = coding_language if coding_language in _CODING_LANGUAGES else "python"
        self._coding_problem: dict | None = None
        self._coding_hints: list[str] = []
        self._coding_hint_idx: int = 0
        self._code_answered: bool = False
        # Code cache
        from skilark_cli.code_cache import CodeCache
        self._code_cache = CodeCache(
            language=self._coding_language,
            config_dir=code_cache_dir,
        )
        # Snapshot of editor text, kept in sync via on_text_area_changed.
        # Used by on_unmount to avoid querying a detached DOM.
        self._editor_text_snapshot: str = ""
        # Last code string that was run via ctrl+r, used for dedup on submit.
        self._last_run_code: str = ""
        # Handle for the active debounce timer; reset on each keystroke.
        self._autosave_timer: "Timer | None" = None

    def compose(self) -> ComposeResult:
        # Shared
        yield Static("Loading...", id="drill-loading")
        yield Static("", id="drill-empty")
        yield Static("", id="drill-header")
        # Quiz mode widgets
        yield Static("", id="drill-question")
        yield Static("", id="drill-code")
        yield Static("", id="drill-choices")
        yield Static("[dim]Tab to focus answer[/dim]", id="drill-status")
        yield Input(placeholder="Your answer (Tab to focus, Esc for shortcuts)", id="drill-answer")
        yield Static("", id="drill-hint")
        yield Static("", id="drill-result")
        yield Static("Were you actually right? [y/n]", id="drill-self-correct")
        # Code mode widgets
        yield Static("", id="drill-description")
        yield TextArea("", language="python", id="drill-editor")
        yield Static("", id="drill-code-status")
        yield Static("", id="drill-test-results")

    def on_mount(self) -> None:
        # Hide everything initially
        for widget_id in [
            "#drill-empty", "#drill-header",
            "#drill-question", "#drill-code", "#drill-choices",
            "#drill-status", "#drill-answer",
            "#drill-hint", "#drill-result", "#drill-self-correct",
            "#drill-description", "#drill-editor",
            "#drill-code-status", "#drill-test-results",
        ]:
            self.query_one(widget_id).display = False
        # Editor config
        editor = self.query_one("#drill-editor", TextArea)
        editor.show_line_numbers = True
        editor.tab_behavior = "indent"
        # Start in quiz mode
        self._fetch_challenge()

    # ── Shared helpers ────────────────────────────────────

    @work(thread=True, exit_on_error=False)
    def _fetch_challenge(self) -> None:
        self.app.call_from_thread(self._set_loading, True)
        try:
            if self.client is None:
                self.app.call_from_thread(self._display_challenge, None)
                return

            # Get day count from stats on first load
            if not self._stats_loaded:
                try:
                    stats = self.client.get_stats()
                    self._day = stats.get("total_completed", 0) + 1
                except Exception:
                    self._day = 1

            challenge = self.client.get_next_challenge(self.topics)
            self.app.call_from_thread(self._display_challenge, challenge)
        except Exception:
            log.debug("failed to fetch challenge", exc_info=True)
            self.app.call_from_thread(self._display_challenge, None)

    @work(thread=True, exit_on_error=False)
    def _fetch_coding_problem(self) -> None:
        self.app.call_from_thread(self._set_loading, True)
        try:
            if self.client is None:
                self.app.call_from_thread(self._display_coding_problem, None)
                return

            if not self._stats_loaded:
                try:
                    stats = self.client.get_stats()
                    self._day = stats.get("total_completed", 0) + 1
                except Exception:
                    self._day = 1

            # Coding problems are all topic=dsa; ensure it's in the filter
            coding_topics = list(set(self.topics + ["dsa"]))
            problem = self.client.get_next_coding_problem(
                coding_topics, self._coding_language
            )
            self.app.call_from_thread(self._display_coding_problem, problem)
        except Exception:
            log.debug("failed to fetch coding problem", exc_info=True)
            self.app.call_from_thread(self._display_coding_problem, None)

    def _set_loading(self, show: bool) -> None:
        if self.is_attached:
            self.query_one("#drill-loading").display = show

    def _hide_all_content(self) -> None:
        """Hide all content widgets (both modes). Loading/empty handled separately."""
        if not self.is_attached:
            return
        for widget_id in [
            "#drill-header",
            "#drill-question", "#drill-code", "#drill-choices",
            "#drill-status", "#drill-answer",
            "#drill-hint", "#drill-result", "#drill-self-correct",
            "#drill-description", "#drill-editor",
            "#drill-code-status", "#drill-test-results",
        ]:
            self.query_one(widget_id).display = False

    # ── Quiz mode ─────────────────────────────────────────

    def _display_challenge(self, challenge: dict | None) -> None:
        if not self.is_attached:
            return
        self._stats_loaded = True
        self._set_loading(False)
        self._answered = False
        self._hint_idx = 0
        self._hide_all_content()

        # Reset hint widget text
        self.query_one("#drill-hint", Static).update("")
        self.query_one("#drill-result", Static).update("")

        if challenge is None:
            self._challenge = None
            empty = self.query_one("#drill-empty", Static)
            empty.update("No more challenges available for your topics.")
            empty.display = True
            return

        self._challenge = challenge
        self.query_one("#drill-empty").display = False

        self._hints = _ensure_parsed(challenge.get("hints"))

        # Header: Day N · source · title
        source = challenge.get("source", "")
        title = challenge.get("title", "")
        header = self.query_one("#drill-header", Static)
        header.update(f"Day {self._day} \u00b7 {source} \u00b7 {title}")
        header.display = True

        # Question
        question = challenge.get("question", "")
        q_widget = self.query_one("#drill-question", Static)
        q_widget.update(question)
        q_widget.display = bool(question)

        # Code (Rich Syntax for proper highlighting)
        code = challenge.get("code_display") or ""
        code_widget = self.query_one("#drill-code", Static)
        if code.strip():
            lang = _LANG_MAP.get(source.lower(), "text")
            code_widget.update(
                Syntax(code, lang, theme="monokai", line_numbers=False)
            )
            code_widget.display = True
        else:
            code_widget.display = False

        # Multiple choice
        challenge_type = challenge.get("challenge_type", "predict_output")
        choices = _ensure_parsed(challenge.get("choices"))

        choices_widget = self.query_one("#drill-choices", Static)
        if challenge_type == "multiple_choice" and choices:
            choices_widget.update("\n".join(choices))
            choices_widget.display = True
        else:
            choices_widget.display = False

        # Status hint and answer input
        self.query_one("#drill-status").display = True
        answer_input = self.query_one("#drill-answer", Input)
        answer_input.value = ""
        answer_input.display = True

    # ── Code mode ─────────────────────────────────────────

    def _display_coding_problem(self, problem: dict | None) -> None:
        if not self.is_attached:
            return
        self._stats_loaded = True
        self._set_loading(False)
        self._code_answered = False
        self._coding_hint_idx = 0
        self._hide_all_content()

        # Reset hint widget
        self.query_one("#drill-hint", Static).update("")

        if problem is None:
            self._coding_problem = None
            empty = self.query_one("#drill-empty", Static)
            empty.update("No coding problems available. Press [bold]m[/bold] to switch to quiz mode.")
            empty.display = True
            return

        self._coding_problem = problem
        self.query_one("#drill-empty").display = False

        # Parse hints
        hints = problem.get("hints") or []
        if isinstance(hints, list):
            self._coding_hints = hints
        else:
            self._coding_hints = []

        # Header: Day N · ★★☆ · title · [Code]
        difficulty = problem.get("difficulty", 1)
        stars = _DIFFICULTY_STARS.get(difficulty, "★☆☆")
        title = problem.get("title", "")
        header = self.query_one("#drill-header", Static)
        header.update(f"Day {self._day} \u00b7 {stars} \u00b7 {title} \u00b7 \\[Code]")
        header.display = True

        # Description
        desc = problem.get("description", "")
        desc_widget = self.query_one("#drill-description", Static)
        desc_widget.update(desc)
        desc_widget.display = True

        # Editor — load from cache or starter code
        languages = _ensure_parsed(problem.get("languages"), dict)
        lang_data = languages.get(self._coding_language, {})
        starter_code = lang_data.get("starter_code", "")

        cached = self._code_cache.load(problem["id"])
        editor_code = cached["current"] if cached["current"] else starter_code

        editor = self.query_one("#drill-editor", TextArea)
        editor.text = editor_code
        self._editor_text_snapshot = editor_code  # seed snapshot before any typing
        editor.display = True
        editor.focus()

        # Status line
        self.query_one("#drill-code-status").display = True

        # Clear previous test results
        results_widget = self.query_one("#drill-test-results", Static)
        results_widget.update("")
        results_widget.display = False

    # ── Input handling ────────────────────────────────────

    def on_text_area_changed(self, event: TextArea.Changed) -> None:
        """Keep editor text snapshot in sync and trigger debounced autosave."""
        if event.text_area.id == "drill-editor":
            self._editor_text_snapshot = event.text_area.text
            # Debounced autosave: cancel any pending timer and restart it.
            if self._mode == "code" and self._coding_problem:
                if self._autosave_timer is not None:
                    self._autosave_timer.stop()
                self._autosave_timer = self.set_timer(
                    self.AUTOSAVE_DELAY, self._autosave_current
                )

    def _autosave_current(self) -> None:
        """Save current editor text to cache (called by debounce timer)."""
        if self._mode == "code" and self._coding_problem:
            self._code_cache.save_current(
                self._coding_problem["id"], self._editor_text_snapshot
            )
        self._autosave_timer = None

    def on_input_submitted(self, event: Input.Submitted) -> None:
        if event.input.id == "drill-answer" and not self._answered:
            self._check_answer(event.value.strip())

    def _check_answer(self, user_answer: str) -> None:
        if not user_answer or not self._challenge:
            return

        expected = self._challenge.get("expected_answer", "")
        correct = check_answer(user_answer, expected)
        self._answered = True

        # Hide answer input and status, move focus to pane for n/y/n keys
        self.query_one("#drill-answer").display = False
        self.query_one("#drill-status").display = False
        self.screen.set_focus(self)

        explanation = self._challenge.get("explanation", "")
        deep_link = self._challenge.get("deep_link", "")

        result = self.query_one("#drill-result", Static)

        if correct:
            lines = ["Correct!", "", explanation]
            if deep_link:
                lines.append(f"\n-> {deep_link}")
            result.update("\n".join(lines))
            result.display = True
            self._record_completion(user_answer, correct=True, self_corrected=False)
        else:
            lines = [f"Not quite. The answer is: {expected}", "", explanation]
            if deep_link:
                lines.append(f"\n-> {deep_link}")
            result.update("\n".join(lines))
            result.display = True
            # Wait for self-correct y/n before recording
            self.query_one("#drill-self-correct").display = True
            self._pending_answer = user_answer

    def on_key(self, event) -> None:
        # Escape to unfocus input or editor
        if event.key == "escape":
            answer_input = self.query_one("#drill-answer", Input)
            if answer_input.has_focus:
                self.screen.set_focus(self)
                event.prevent_default()
                event.stop()
                return
            editor = self.query_one("#drill-editor", TextArea)
            if editor.has_focus:
                self.screen.set_focus(self)
                event.prevent_default()
                event.stop()
                return

        # Language cycling (code mode, only when editor not focused)
        if event.key == "L" and self._mode == "code":
            editor = self.query_one("#drill-editor", TextArea)
            if not editor.has_focus:
                self._cycle_language()
                event.prevent_default()
                event.stop()
                return

        # Handle self-correct y/n
        self_correct = self.query_one("#drill-self-correct")
        if self_correct.display and event.key in ("y", "n"):
            self_corrected = event.key == "y"
            correct = self_corrected
            self_correct.display = False
            self._record_completion(
                self._pending_answer, correct=correct, self_corrected=self_corrected
            )
            event.prevent_default()
            event.stop()

    def _cycle_language(self) -> None:
        """Cycle through available coding languages."""
        idx = _CODING_LANGUAGES.index(self._coding_language)
        self._coding_language = _CODING_LANGUAGES[(idx + 1) % len(_CODING_LANGUAGES)]
        # Reload current problem with new language starter code
        if self._coding_problem:
            self._display_coding_problem(self._coding_problem)

    # ── Completion recording ──────────────────────────────

    def _record_completion(
        self, answer: str, *, correct: bool, self_corrected: bool
    ) -> None:
        if self._challenge:
            self._submit_completion(
                self._challenge["id"], answer, correct, self_corrected
            )

    @work(thread=True, exit_on_error=False)
    def _submit_completion(
        self, challenge_id: str, answer: str, correct: bool, self_corrected: bool
    ) -> None:
        try:
            if self.client:
                self.client.complete_challenge(
                    challenge_id,
                    answer=answer,
                    correct=correct,
                    self_corrected=self_corrected,
                )
        except Exception:
            pass  # Best-effort recording

    # ── Code execution ────────────────────────────────────

    def action_run_code(self) -> None:
        """Run the user's code from the editor against test cases."""
        if self._mode != "code" or not self._coding_problem or self._code_answered:
            return
        # Capture editor text on the main thread (DOM access is not thread-safe)
        user_code = self.query_one("#drill-editor", TextArea).text
        self._execute_code(user_code)

    @work(thread=True, exit_on_error=False)
    def _execute_code(self, user_code: str) -> None:
        """Run code in a background thread."""
        problem = self._coding_problem
        if not problem:
            return

        self.app.call_from_thread(self._show_running)

        # Get function name and test cases
        languages = _ensure_parsed(problem.get("languages"), dict)
        lang_data = languages.get(self._coding_language, {})
        function_name = lang_data.get("function_name", "solution")

        test_cases = _ensure_parsed(problem.get("test_cases"))

        harness = build_python_harness(user_code, function_name, test_cases)
        result = run_code_local("python", harness)

        self._last_run_code = user_code
        self.app.call_from_thread(self._display_test_results, result)

    def _show_running(self) -> None:
        if not self.is_attached:
            return
        results_widget = self.query_one("#drill-test-results", Static)
        results_widget.update("[dim]Running...[/dim]")
        results_widget.display = True

    def _display_test_results(self, result: dict) -> None:
        if not self.is_attached:
            return

        results_widget = self.query_one("#drill-test-results", Static)

        error = result.get("error")
        if error:
            results_widget.update(f"[red]Error:[/red] {error}")
            results_widget.display = True
            return

        # Build per-case output
        lines = []
        cases = result.get("results", [])
        hidden_passed = 0
        hidden_total = 0
        for r in cases:
            if r.get("hidden"):
                hidden_total += 1
                if r["passed"]:
                    hidden_passed += 1
                continue
            marker = "[green]✓[/green]" if r["passed"] else "[red]✗[/red]"
            line = f"  {marker} Case {r['case'] + 1}"
            if not r["passed"]:
                line += f": expected {r['expected']}, got {r['actual']}"
            lines.append(line)

        if hidden_total > 0:
            marker = "[green]✓[/green]" if hidden_passed == hidden_total else "[red]✗[/red]"
            lines.append(f"  {marker} {hidden_passed}/{hidden_total} hidden tests")

        passed = result.get("passed_count", 0)
        total = result.get("total_count", 0)
        all_passed = result.get("all_passed", False)

        if all_passed:
            lines.insert(0, f"[green bold]All {total} tests passed![/green bold]")
            self._code_answered = True
        else:
            lines.insert(0, f"[yellow]{passed}/{total} tests passed[/yellow]")

        results_widget.update("\n".join(lines))
        results_widget.display = True

        if all_passed:
            explanation = self._coding_problem.get("explanation", "") if self._coding_problem else ""
            if explanation:
                hint = self.query_one("#drill-hint", Static)
                hint.update(explanation)
                hint.display = True

        # Record attempt in cache and submit to API (dedup by code content).
        # For a failed run: dedup against both attempts and passed lists.
        # For a passed run: dedup against the passed list only — a code that
        # previously failed with the same content should still get submitted
        # when it finally passes (e.g. after fixing the test harness).
        user_code = self._last_run_code
        if self._coding_problem:
            pid = self._coding_problem["id"]
            if all_passed:
                is_new = self._code_cache.is_new_pass(pid, user_code)
            else:
                is_new = self._code_cache.is_new_attempt(pid, user_code)
            if is_new:
                self._code_cache.record_attempt(pid, user_code, passed=all_passed)
                self._submit_coding_result(passed, total, all_passed)

        if all_passed and self._coding_problem:
            self._code_cache.clear_current(self._coding_problem["id"])

    @work(thread=True, exit_on_error=False)
    def _submit_coding_result(self, passed_count: int, total_count: int, all_passed: bool = True) -> None:
        try:
            if self.client and self._coding_problem:
                self.client.submit_coding_problem(
                    self._coding_problem["id"],
                    language=self._coding_language,
                    all_passed=all_passed,
                    passed_count=passed_count,
                    total_count=total_count,
                )
        except Exception:
            pass

    # ── Actions ───────────────────────────────────────────

    def action_toggle_mode(self) -> None:
        """Toggle between quiz and code mode."""
        if self._mode == "quiz":
            self._mode = "code"
            self._hide_all_content()
            self._fetch_coding_problem()
        else:
            # Save editor text before switching away from code mode.
            # Use the snapshot (not a DOM query) because _hide_all_content() runs next.
            if self._coding_problem:
                self._code_cache.save_current(
                    self._coding_problem["id"], self._editor_text_snapshot
                )
            self._mode = "quiz"
            self._hide_all_content()
            self._fetch_challenge()
        self._update_footer_bindings()

    def _update_footer_bindings(self) -> None:
        """Push mode-aware bindings to the footer."""
        from skilark_cli.tui.app import FooterBar

        try:
            footer = self.app.query_one(FooterBar)
        except Exception:
            return
        bindings = list(self.BINDINGS)
        if self._mode == "code":
            bindings.append(Binding("ctrl+r", "run_code", "Run", show=True))
        footer.set_context_bindings(bindings)

    def on_unmount(self) -> None:
        """Flush current editor text to cache on teardown.

        Child widgets are already removed from the DOM when this fires, so
        we read from _editor_text_snapshot (kept current via on_text_area_changed)
        rather than querying #drill-editor.

        Skip saving if the problem was already solved — clear_current() ran
        during _display_test_results and we must not overwrite it.
        """
        if self._mode == "code" and self._coding_problem and not self._code_answered:
            self._code_cache.save_current(
                self._coding_problem["id"], self._editor_text_snapshot
            )

    def action_show_hint(self) -> None:
        if self._mode == "code":
            self._show_coding_hint()
            return
        if self._answered:
            return
        hint_widget = self.query_one("#drill-hint", Static)
        if not self._hints:
            hint_widget.update("No hints available for this challenge.")
        elif self._hint_idx < len(self._hints):
            hint_widget.update(self._hints[self._hint_idx])
            self._hint_idx += 1
        else:
            hint_widget.update("No more hints.")
        hint_widget.display = True

    def _show_coding_hint(self) -> None:
        if self._code_answered:
            return
        hint_widget = self.query_one("#drill-hint", Static)
        if not self._coding_hints:
            hint_widget.update("No hints available.")
        elif self._coding_hint_idx < len(self._coding_hints):
            hint_widget.update(self._coding_hints[self._coding_hint_idx])
            self._coding_hint_idx += 1
        else:
            hint_widget.update("No more hints.")
        hint_widget.display = True

    def action_skip(self) -> None:
        if self._mode == "code":
            if self._code_answered:
                return
            if self._coding_problem:
                self._code_cache.save_current(
                    self._coding_problem["id"], self._editor_text_snapshot
                )
            self._fetch_coding_problem()
            return
        if self._answered:
            return
        self._fetch_challenge()

    def action_next_challenge(self) -> None:
        if self._mode == "code":
            if not self._code_answered:
                return
            self._day += 1
            self._fetch_coding_problem()
            return
        if not self._answered:
            return
        self._day += 1
        self._fetch_challenge()
