# cspell:ignore A_INVIS
"""Tokenize and color text."""

from __future__ import annotations

import colorsys
import copy
import curses
import functools
import json
import logging
import re

from itertools import chain
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any

from ansible_navigator.tm_tokenize.grammars import Grammars
from ansible_navigator.tm_tokenize.tokenize import tokenize

from .curses_defs import CursesLine
from .curses_defs import CursesLinePart
from .curses_defs import CursesLines
from .curses_defs import RgbTuple
from .curses_defs import SimpleLinePart
from .ui_constants import Color
from .ui_constants import Decoration


if TYPE_CHECKING:
    from ansible_navigator.tm_tokenize.region import Regions
    from ansible_navigator.utils.compatibility import Traversable


CURSES_STYLES = {
    0: None,
    1: getattr(curses, "A_BOLD", None),
    2: getattr(curses, "A_DIM", None),
    3: getattr(curses, "A_ITALIC", None),
    4: getattr(curses, "A_UNDERLINE", None),
    5: getattr(curses, "A_BLINK", None),
    6: getattr(curses, "A_BLINK", None),
    7: getattr(curses, "A_REVERSE", None),
    8: getattr(curses, "A_INVIS", None),
}


class ColorSchema:
    """A storage mechanism for the schema (theme)."""

    def __init__(self, schema: dict[str, str | list[Any] | dict[Any, Any]]) -> None:
        """Initialize the ColorSchema class.

        Args:
            schema: The color scheme, theme to use
        """
        self._logger = logging.getLogger(__name__)
        self._schema = schema

    @functools.lru_cache(maxsize=128)  # noqa: B019
    def get_color_and_style(self, scope: str) -> tuple[RgbTuple | None, str | None]:
        """Get a color from the schema, traverse all to aggregate color and style.

        Args:
            scope: The scope, aka format

        Returns:
            The color in RGB format or nothing
        """
        found_color = None
        found_style = None
        for name in scope:
            for parts in range(len(name.split("."))):
                prop = name.split()[-1].rsplit(".", parts)[0]
                color = next(
                    (
                        token_color
                        for token_color in self._schema["tokenColors"]
                        if (
                            isinstance(token_color, dict)
                            and prop in scope_to_list(token_color.get("scope", []))
                        )
                    ),
                    None,
                )
                if color:
                    found_color = color.get("settings", {}).get("foreground", None)
                    found_style = color.get("settings", {}).get("fontStyle", None)
        if found_color:
            found_color = hex_to_rgb(found_color)
        return found_color, found_style


class Colorize:
    """Functionality for coloring."""

    def __init__(self, grammar_dir: Traversable, theme_path: Traversable) -> None:
        """Initialize the colorizer.

        Args:
            grammar_dir: The directory in which the grammars reside
            theme_path: The path to the currently configured color theme
        """
        self._logger = logging.getLogger(__name__)
        self._schema: ColorSchema
        self._grammars = Grammars(str(grammar_dir))
        self._theme_path = Path(str(theme_path))
        self._load()

    def _load(self) -> None:
        """Load the color scheme from the file system."""
        with self._theme_path.open(mode="r", encoding="utf-8") as fh:
            self._schema = ColorSchema(json.load(fh))

    @staticmethod
    @functools.lru_cache(maxsize=100)
    def render_ansi(doc: str) -> CursesLines:
        """Convert ansi colored text into curses lines.

        Args:
            doc: The text to convert

        Returns:
            Lines ready to present using the TUI
        """
        lines = tuple(ansi_to_curses(line) for line in doc.splitlines())
        return CursesLines(lines)

    @functools.lru_cache(maxsize=100)  # noqa: B019
    def render(self, doc: str, scope: str) -> list[list[SimpleLinePart]]:
        """Render text lines into lines of columns and colors.

        Args:
            doc: The string to split, tokenize and color
            scope: The scope, aka the format of the string

        Returns:
            A list of lines, each a list of dicts
        """
        try:
            compiler = self._grammars.compiler_for_scope(scope)
        except KeyError:
            compiler = None

        if compiler and scope != "no_color":
            state = compiler.root_state
            lines = []
            for line_idx, line in enumerate(doc.splitlines()):
                line += "\n"
                first_line = line_idx == 0
                try:
                    state, regions = tokenize(compiler, state, line, first_line)
                except Exception as exc:  # noqa: BLE001
                    self._logger.critical(
                        (
                            "An unexpected error occurred within the tokenization"
                            " subsystem.  Please log an issue with the following:"
                        ),
                    )
                    self._logger.critical(
                        "  Err: '%s', Scope: '%s', Line follows....",
                        str(exc),
                        scope,
                    )
                    self._logger.critical("  '%s'", line)
                    self._logger.critical("  The current content will be rendered without color")
                    break
                else:
                    lines.append((regions, line))
            else:
                assembled = columns_and_colors(lines, self._schema)
                if scope == "text.html.markdown":
                    assembled = strip_markdown(assembled)
                return assembled

        res = [
            [SimpleLinePart(column=0, chars=doc_line, color=None, style=None)]
            for doc_line in doc.splitlines()
        ]
        return res


def scope_to_list(scope: str | list[Any]) -> list[Any]:
    """Convert a token scope to a list if necessary.

    A scope in a theme should always be a string or list,
    but just in case return an empty list if not

    Args:
        scope: The scope

    Returns:
        Scope as list
    """
    if isinstance(scope, list):
        return scope
    if isinstance(scope, str):
        return [scope]
    return []


def hex_to_rgb(value: str) -> RgbTuple:
    """Convert a hex value to RGB tuple.

    Args:
        value: The hex color

    Returns:
        RGB tuple
    """
    value = value.lstrip("#")
    value_length = len(value)
    red, green, blue = (
        int(value[i : i + value_length // 3], 16) for i in range(0, value_length, value_length // 3)
    )
    return red, green, blue


def scale_for_curses(rgb_value: int) -> int:
    """Scale a single RGB value for curses.

    Args:
        rgb_value: One RGB value

    Returns:
        The value scaled for curses
    """
    curses_ceiling = 1000
    rgb_ceiling = 255
    return int(rgb_value * curses_ceiling / rgb_ceiling)


def hex_to_rgb_curses(value: str) -> RgbTuple:
    """Convert a hex color to RGB scaled for curses.

    Args:
        value: an RGB color

    Returns:
        The colors scaled to 1000
    """
    red, green, blue = hex_to_rgb(value)
    return (scale_for_curses(red), scale_for_curses(green), scale_for_curses(blue))


def _rgb_to_ansi_256(red: int, green: int, blue: int) -> int:
    """Convert an RGB color to a 256-color ansi value.

    Args:
        red: The red component
        green: The green component
        blue: The blue component

    Returns:
        A 256-color ansi value
    """
    if red == green and green == blue:
        if red < 8:
            return 16
        if red > 248:
            return 231
        return round(((red - 8) / 247) * 24) + 232
    return 16 + (36 * round(red / 255 * 5)) + (6 * round(green / 255 * 5)) + round(blue / 255 * 5)


def _rgb_to_ansi_16(red: int, green: int, blue: int) -> int:
    """Convert an RGB color to a 16-color ansi value.

    Args:
        red: The red component
        green: The green component
        blue: The blue component

    Returns:
        A 16-color ansi value
    """
    value = colorsys.rgb_to_hsv(red, green, blue)[2]
    value = round(value / 50)
    if value == 0:
        return 30
    ansi = (round(blue / 255) << 2) | (round(green / 255) << 1) | round(red / 255)
    if value == 2:
        ansi += 8
    return ansi


def rgb_to_ansi(red: int, green: int, blue: int, colors: int) -> int:
    """Convert an RGB color to an ansi color.

    Args:
        red: The red component
        green: The green component
        blue: The blue component
        colors: The number of color supported by the terminal

    Returns:
        A color suitable for the terminal
    """
    # https://github.com/Qix-/color-convert/blob/master/conversions.js
    if colors == 256:
        ansi = _rgb_to_ansi_256(red, green, blue)
    elif colors == 16:
        ansi = _rgb_to_ansi_16(red, green, blue)
    else:  # colors == 8, sorry
        ansi = (round(blue / 255) << 2) | (round(green / 255) << 1) | round(red / 255)
    return ansi


def _apply_regions_to_parts(
    line_parts: list[SimpleLinePart],
    regions: Regions,
    schema: ColorSchema,
) -> None:
    """Apply color and style from regions to line parts.

    Args:
        line_parts: The individual character parts of a line
        regions: The tokenized regions for the line
        schema: An instance of the ColorSchema
    """
    for region in regions:
        color, style = schema.get_color_and_style(region.scope)
        if color:
            for idx in range(region.start, region.end):
                line_parts[idx].color = color
        if style:
            for idx in range(region.start, region.end):
                line_parts[idx].style = style


def _compress_line_parts(
    line_parts: list[SimpleLinePart],
    line_text: str,
) -> list[SimpleLinePart]:
    """Compress adjacent line parts with the same color and style.

    Args:
        line_parts: The individual character parts of a line
        line_text: The original line text

    Returns:
        Compressed line parts
    """
    if not line_parts:
        return [SimpleLinePart(chars=line_text, color=None, column=0, style=None)]
    grouped = [line_parts.pop(0)]
    while line_parts:
        entry = line_parts.pop(0)
        if entry.color == grouped[-1].color and entry.style == grouped[-1].style:
            grouped[-1].chars += entry.chars
        else:
            grouped.append(entry)
    return grouped


def _update_line_part_columns(results: list[list[SimpleLinePart]]) -> None:
    """Update column offsets in each line part based on preceding text lengths.

    Args:
        results: Lines of text parts to update
    """
    for result in results:
        column = 0
        for line_part in result:
            line_part.column = column
            column += len(line_part.chars)


def columns_and_colors(
    lines: list[tuple[Regions, str]],
    schema: ColorSchema,
) -> list[list[SimpleLinePart]]:
    """Convert to colors and columns.

    Args:
        lines: Lines of text and their regions
        schema: An instance of the ColorSchema

    Returns:
        Lines of text, each broken into sections
    """
    results: list[list[SimpleLinePart]] = []

    for line in lines:
        # Break the into 1 character parts, temporarily set the column to 0
        line_parts = [
            SimpleLinePart(chars=character, color=None, column=0, style=None)
            for character in line[1]
        ]

        # Replace the color with the RgbTuple
        _apply_regions_to_parts(line_parts, line[0], schema)

        # Compress the line, grouping characters of like color and decoration
        results.append(_compress_line_parts(line_parts, line[1]))

    # Update the column in each line part, based on the total of the preceding text lengths
    _update_line_part_columns(results)

    return results


def _parse_ansi_match(
    cap: dict[str, str | None],
    color: int = 0,
    style: int = 0,
) -> tuple[int, int]:
    """Parse an ANSI color match groupdict into a color and style.

    Args:
        cap: The groupdict from a color_regex match
        color: The current color state to preserve across calls
        style: The current style state to preserve across calls

    Returns:
        A (color, style) tuple
    """
    one = cap["one"]
    two = cap["two"]
    if cap["fg_action"] == "39;" or (one == "0" and two is None):
        pass  # default color
    elif cap["fg_action"] == "38;5;":
        color = int(one)  # type: ignore[arg-type]
        if two:
            style = CURSES_STYLES.get(int(two)) or 0
    elif not cap["fg_action"]:
        ansi_16 = list(chain(range(30, 38), range(90, 98)))
        if two is None:
            color = ansi_16.index(int(one)) if int(one) in ansi_16 else int(one)  # type: ignore[arg-type]
        else:
            color = ansi_16.index(int(two)) if int(two) in ansi_16 else int(two)
            style = CURSES_STYLES.get(int(one)) or 0  # type: ignore[arg-type]
    return color, style


def ansi_to_curses(line: str) -> CursesLine:
    """Convert ansible color codes to curses colors.

    Args:
        line: A string with ansi colors

    Returns:
        A line ready for presentation in the TUI
    """
    if line == "":
        line_part = CursesLinePart(
            column=0,
            string="",
            color=Color.BLACK,
            decoration=Decoration.NORMAL,
        )
        return CursesLine((line_part,))

    printable = []
    ansi_regex = re.compile(r"(\x1b\[[\d;]*m)")
    color_regex = re.compile(
        r"""(?x)
            \x1b\[                              # Control Sequence Introducer
            (?P<fg_action>(38;5|39);)?          # optional FG color action
            (?P<_bg_action>(48;5|49);)?         # optional BG color action
            (?P<one>\d+)                        # required, one number
            (;(?P<two>\d+))?                    # optional 2nd number
            m
        """,
    )
    parts = ansi_regex.split(line)
    colno = 0
    color = 0
    style = 0
    while parts:
        part = parts.pop(0)
        if part:
            match = color_regex.match(part)
            if match:
                color, style = _parse_ansi_match(match.groupdict(), color, style)
            else:
                curses_line = CursesLinePart(
                    column=colno,
                    string=part,
                    color=color,
                    decoration=style,
                )
                printable.append(curses_line)
                colno += len(part)
                color = 0
                style = 0
    return CursesLine(tuple(printable))


def _process_markdown_part(
    lines: list[list[SimpleLinePart]],
    line_idx: int,
    part_idx: int,
    part: SimpleLinePart,
    in_a_code_block: bool,
    full_dash_line: list[SimpleLinePart],
) -> bool:
    """Process a single markdown part and apply transformations.

    Args:
        lines: Lines of text and their parts (mutated in place)
        line_idx: The index of the current line
        part_idx: The index of the current part within the line
        part: The current part to process
        in_a_code_block: Whether we are currently inside a code block
        full_dash_line: The dash line to use for separators

    Returns:
        The updated in_a_code_block state
    """
    if part.chars.startswith("```"):
        # Remove ```x from a line
        lines[line_idx][part_idx].chars = "\n"
        return not in_a_code_block

    if in_a_code_block:
        # Don't modify inside a code block
        return in_a_code_block

    if part.chars.startswith("#"):
        # Remove # headings
        lines[line_idx][part_idx].chars = re.sub(r"^(#{1,6}\s)(.*$)", r"\2", part.chars)
        if part.chars.startswith("# "):
            # Insert a full line after a heading 1
            lines.insert(line_idx + 1, full_dash_line)
        return in_a_code_block

    if part.chars == "---\n":
        # Replace all dash line with no-break dashes if \n before and after
        try:
            if lines[line_idx - 1][0].chars == "\n" and lines[line_idx + 1][0].chars == "\n":
                lines[line_idx] = full_dash_line
                return in_a_code_block
        except IndexError:
            pass

    if "`" in part.chars:
        # Remove `` from code blocks
        lines[line_idx][part_idx].chars = re.sub(r"`(.*)`", r"\1", part.chars)

    if "*" in part.chars:
        # Remove `` from code blocks
        lines[line_idx][part_idx].chars = re.sub(r"\*(.*)\*", r"\1", part.chars)

    return in_a_code_block


def strip_markdown(lines: list[list[SimpleLinePart]]) -> list[list[SimpleLinePart]]:
    """Strip some markdown from the regions.

    This is not a complete removal of markdown, but it removes some of the
    common markdown that is in use.

    Args:
        lines: Lines of text and their parts

    Returns:
        Lines of text and their parts without some markdown
    """
    full_dash_line = [
        SimpleLinePart(
            chars=f"{'—' * 132}\n",
            column=0,
            color=(128, 128, 128),
            style=None,
        ),
    ]
    in_a_code_block = False
    for line_idx, line in reversed(list(enumerate(copy.deepcopy(lines)))):
        for part_idx, part in enumerate(line):
            in_a_code_block = _process_markdown_part(
                lines,
                line_idx,
                part_idx,
                part,
                in_a_code_block,
                full_dash_line,
            )

    return lines
