"""Colorized, package-manager-style CLI output + a structured-output toolkit.

Color is a *presentation* layer only. It is auto-disabled whenever output must stay
machine-parseable or reproducible — a non-TTY stream, ``NO_COLOR`` set, ``--json``, or the
non-interactive ``--yes`` flag — and it NEVER touches machine-readable output or verdict bytes.
Every ANSI escape this module emits is generated by ``rich`` style
primitives — no hand-rolled SGR construction — and the
styler degrades to a no-op when color is off (its absence never fails a command).
No network is ever touched.

Beyond the semantic color vocabulary, this module provides a small **structured-output toolkit**:
section headers, key/value blocks, aligned tables, status rows, dividers, and wrapped
bullet lists. Every primitive is a pure function of its :class:`Styler` — with color off it degrades
to plain, alignment-preserving, escape-free text that equals the colored output with the ANSI
removed. Meaning is never carried by color alone: a glyph or word always accompanies it, and an
ASCII glyph set is used when the stream cannot encode the Unicode marks.
"""

from __future__ import annotations

import os
import re
import shutil
import sys
import textwrap
from dataclasses import dataclass
from typing import Iterable, Sequence, TextIO

from rich.style import Style as _RichStyle

# The semantic style vocabulary, each name a rich Style. rich renders the standard
# 16-color SGR bytes the old hand-rolled table produced, so plain/colored output is unchanged.
_RICH_STYLES: dict[str, _RichStyle] = {
    "bold": _RichStyle(bold=True),
    "dim": _RichStyle(dim=True),
    "red": _RichStyle(color="red"),
    "green": _RichStyle(color="green"),
    "yellow": _RichStyle(color="yellow"),
    "blue": _RichStyle(color="blue"),
    "magenta": _RichStyle(color="magenta"),
    "cyan": _RichStyle(color="cyan"),
    "gray": _RichStyle(color="bright_black"),
    # Semantic guidance/chrome roles layered over the base colors — the remediation-panel
    # vocabulary. Named so the renderer never hard-codes a color for a guidance role: what a
    # finding, its meaning, its fix, and the last-resort deviation look like all live here.
    "guidance_meaning": _RichStyle(dim=True),
    "guidance_fix": _RichStyle(color="green"),
    "guidance_warn": _RichStyle(color="yellow"),
    "guidance_waived": _RichStyle(color="yellow", dim=True),
    "accent": _RichStyle(color="magenta", bold=True),
    # Panel chrome — the border + title of a failure panel. Consumed as rich Style objects.
    "panel_border": _RichStyle(dim=True),
    "panel_title": _RichStyle(dim=True),
}

_STATUS_GLYPH: dict[str, str] = {
    "pass": "✓",
    "ok": "✓",
    "fail": "✗",
    "warn": "⚠",
    "todo": "＋",
    "skip": "–",
    "info": "ⓘ",
}

# ASCII fallbacks for a stream whose encoding cannot represent the Unicode marks.
_STATUS_GLYPH_ASCII: dict[str, str] = {
    "pass": "v",
    "ok": "v",
    "fail": "x",
    "warn": "!",
    "todo": "+",
    "skip": "-",
    "info": "i",
}

_ANSI_RE = re.compile(r"\033\[[0-9;]*m")  # a strip matcher only, never construction


def strip_ansi(text: str) -> str:
    """The text with every ANSI SGR sequence removed."""
    return _ANSI_RE.sub("", text)


def visible_len(text: str) -> int:
    """The printable width of ``text`` — its length ignoring ANSI color sequences."""
    return len(strip_ansi(text))


def term_width(default: int = 80) -> int:
    """The terminal width in columns, clamped to a sane minimum; ``default`` when undetectable.

    Never raises — a width-unknown stream (pipe, CI) falls back to ``default``."""
    try:
        cols = shutil.get_terminal_size((default, 24)).columns
    except (OSError, ValueError):
        cols = default
    if cols <= 0:
        cols = default
    return max(20, cols)


def _can_encode_glyphs(stream: TextIO | None) -> bool:
    """Whether ``stream``'s encoding can represent the Unicode status/progress marks."""
    enc = getattr(stream if stream is not None else sys.stdout, "encoding", None) or "utf-8"
    try:
        "✓✗▶⚠·▌─".encode(enc)
        return True
    except (LookupError, UnicodeEncodeError):
        return False


def color_enabled(
    stream: TextIO | None = None,
    *,
    as_json: bool = False,
    assume_yes: bool = False,
    color_mode: str = "auto",
) -> bool:
    """Whether to emit ANSI color for human output.

    Machine-readable output (``as_json``) and non-interactive runs (``assume_yes`` / CI) are NEVER
    colored — these win over everything, so styled output can never corrupt a parseable payload.
    The resolution order then follows the documented precedence — environment over
    the ``ui.yaml`` ``color_mode`` over the auto default: ``THREEPOWERS_FORCE_COLOR`` forces it on and
    ``NO_COLOR`` forces it off (env), with rich's ``FORCE_COLOR`` convention honored as an
    environment-layer fallback; then ``color_mode``
    (``always``/``never``) from the config file; then the ``auto`` default, which colors only an
    interactive TTY. ``color_mode`` defaults to ``auto`` so existing callers are unchanged."""
    if as_json or assume_yes:
        return False
    if os.environ.get("THREEPOWERS_FORCE_COLOR"):
        return True
    if os.environ.get("NO_COLOR") is not None:
        return False
    if os.environ.get("FORCE_COLOR"):
        return True
    if color_mode == "always":
        return True
    if color_mode == "never":
        return False
    stream = stream if stream is not None else sys.stdout
    try:
        return bool(stream.isatty())
    except (AttributeError, ValueError, OSError):
        return False


@dataclass(frozen=True)
class Styler:
    """Paints text with ANSI color when ``enabled``; a transparent no-op otherwise, and renders the
    structured-output toolkit. Every escape byte is generated by ``rich`` —
    this class holds no SGR knowledge of its own.

    Construct one for human output only. Machine-readable output must never be routed through a
    styler — that keeps the ``--json`` payload byte-identical with and without color.
    ``ascii_only`` swaps the Unicode glyphs for an ASCII set when the stream cannot
    encode them; it defaults off, so a directly-constructed ``Styler`` still uses
    the Unicode marks."""

    enabled: bool = False
    ascii_only: bool = False

    def paint(self, text: str, *names: str) -> str:
        """``text`` styled with the named semantic styles — rendered by rich when ``enabled``,
        returned unchanged otherwise or when no name maps."""
        if not self.enabled or not names:
            return text
        known = [_RICH_STYLES[n] for n in names if n in _RICH_STYLES]
        if not known:
            return text
        combined = known[0]
        for extra in known[1:]:
            combined = combined + extra
        return combined.render(text)

    # Semantic helpers — the color vocabulary the CLI uses.
    def ok(self, text: str) -> str:
        return self.paint(text, "green")

    def err(self, text: str) -> str:
        return self.paint(text, "red")

    def warn(self, text: str) -> str:
        return self.paint(text, "yellow")

    def head(self, text: str) -> str:
        return self.paint(text, "bold", "cyan")

    def bold(self, text: str) -> str:
        return self.paint(text, "bold")

    def dim(self, text: str) -> str:
        return self.paint(text, "dim")

    def accent(self, text: str) -> str:
        """A distinct, scannable accent — used for the coder hand-back header + re-dispatch line."""
        return self.paint(text, "accent")

    # Remediation-panel guidance roles — a colored hierarchy that never carries meaning by color
    # alone (each line keeps its ``↳`` label). Every one is a plain no-op when color is off, so
    # the panel bytes stay identical off-TTY / under ``--json``.
    def guidance_meaning(self, text: str) -> str:
        """A ``↳ what it means`` line — dim, the least-emphasized guidance."""
        return self.paint(text, "guidance_meaning")

    def guidance_fix(self, text: str) -> str:
        """A ``↳ fix`` / ``↳ auto-fix`` line — the success/accent-weighted honest action."""
        return self.paint(text, "guidance_fix")

    def guidance_warn(self, text: str) -> str:
        """A ``↳ last resort`` label + its ``3pwr deviation`` command — a warning, never primary."""
        return self.paint(text, "guidance_warn")

    def guidance_waived(self, text: str) -> str:
        """A ``↳ waived by active deviation`` annotation — a dimmed warning."""
        return self.paint(text, "guidance_waived")

    def panel_border(self) -> _RichStyle:
        """The rich Style for a failure panel's border — the named chrome role, not a literal."""
        return _RICH_STYLES["panel_border"]

    def panel_title(self) -> _RichStyle:
        """The rich Style for a failure panel's title — the named chrome role, not a literal."""
        return _RICH_STYLES["panel_title"]

    def _glyph(self, status: str) -> str:
        table = _STATUS_GLYPH_ASCII if self.ascii_only else _STATUS_GLYPH
        return table.get(status, "*" if self.ascii_only else "•")

    def mark(self, status: str) -> str:
        """A colorized status glyph for a checklist / verdict row (``pass``/``fail``/``warn``/…)."""
        glyph = self._glyph(status)
        if status in ("pass", "ok"):
            return self.ok(glyph)
        if status == "fail":
            return self.err(glyph)
        if status in ("warn", "todo"):
            return self.warn(glyph)
        if status == "info":
            return self.paint(glyph, "cyan")
        return self.dim(glyph)

    # ----------------------------------------------------------- structured-output toolkit
    def header(self, title: str, subject: str = "") -> str:
        """A self-identifying section header: an emphasized title + optional dim subject."""
        bar = "|" if self.ascii_only else "▌"
        line = f"{self.paint(bar, 'cyan')} {self.head(title)}"
        if subject:
            line += f"  {self.dim(subject)}"
        return line

    def rule(self, width: int | None = None) -> str:
        """A horizontal divider."""
        ch = "-" if self.ascii_only else "─"
        n = width if width is not None else min(60, term_width())
        return self.dim(ch * max(1, n))

    def status_row(self, status: str, text: str, detail: str = "", indent: int = 2) -> str:
        """One status line — a colored glyph + text (+ dim detail). The canonical row every command
        uses, so a given status reads the same everywhere. Color is never the sole
        signal: the glyph (or its ASCII fallback) and the text always carry the meaning."""
        line = f"{' ' * indent}{self.mark(status)} {text}"
        if detail:
            line += f"  {self.dim(detail)}"
        return line

    def kv(self, pairs: Sequence[tuple[str, str]], indent: int = 2) -> str:
        """An aligned key/value block: dim labels padded to a common width, then the
        value. Multi-field results render here instead of as one run-on line."""
        rows = list(pairs)
        if not rows:
            return ""
        kw = max(len(k) for k, _ in rows)
        pad = " " * indent
        return "\n".join(f"{pad}{self.dim(k)}{' ' * (kw - len(k) + 2)}{v}" for k, v in rows)

    def table(
        self,
        rows: Sequence[Sequence[str]],
        headers: Sequence[str] | None = None,
        indent: int = 2,
    ) -> str:
        """Aligned columns. Column widths use the *visible* cell width, so colored
        cells still line up and — with color off — the output equals the colored output with the ANSI
        stripped."""
        body = [[str(c) for c in row] for row in rows]
        if not body and not headers:
            return ""
        ncol = max((len(r) for r in body), default=0)
        if headers:
            ncol = max(ncol, len(headers))
        allrows = ([[self.bold(h) for h in headers]] if headers else []) + body
        widths = [0] * ncol
        for r in allrows:
            for i in range(ncol):
                cell = r[i] if i < len(r) else ""
                widths[i] = max(widths[i], visible_len(cell))
        pad = " " * indent
        out = []
        for r in allrows:
            cells = [
                (r[i] if i < len(r) else "")
                + " " * (widths[i] - visible_len(r[i] if i < len(r) else ""))
                for i in range(ncol)
            ]
            out.append((pad + "  ".join(cells)).rstrip())
        return "\n".join(out)

    def bullet(self, items: Iterable[str], indent: int = 2, width: int | None = None) -> str:
        """A wrapped bullet list: long items wrap to the terminal width, never one line."""
        glyph = "-" if self.ascii_only else "•"
        w = max(20, width if width is not None else term_width())
        pad = " " * indent
        cont = " " * (indent + 2)
        return "\n".join(
            textwrap.fill(str(it), width=w, initial_indent=f"{pad}{glyph} ", subsequent_indent=cont)
            for it in items
        )


def styler(
    stream: TextIO | None = None,
    *,
    as_json: bool = False,
    assume_yes: bool = False,
    color_mode: str = "auto",
) -> Styler:
    """Build a :class:`Styler` whose ``enabled`` reflects :func:`color_enabled` and whose glyph set
    matches what ``stream`` can encode."""
    return Styler(
        enabled=color_enabled(
            stream, as_json=as_json, assume_yes=assume_yes, color_mode=color_mode
        ),
        ascii_only=not _can_encode_glyphs(stream),
    )


VERBOSITY_LEVELS = ("quiet", "normal", "verbose")


def resolve_verbosity(
    quiet: bool = False, verbose: bool = False, file_default: str = "normal"
) -> str:
    """The effective human-output verbosity: ``quiet`` | ``normal`` | ``verbose``.

    Precedence: an explicit flag wins over the ``ui.yaml`` default (there is no verbosity environment
    layer). ``--quiet`` and ``--verbose`` are mutually exclusive at the CLI; if both are somehow set,
    the more-informative ``verbose`` wins. A pure function of its inputs."""
    if verbose:
        return "verbose"
    if quiet:
        return "quiet"
    return file_default if file_default in VERBOSITY_LEVELS else "normal"
