#!/usr/bin/env python3
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>

import ctypes
import sys
from functools import partial
from math import ceil, cos, floor, pi
from typing import (
    TYPE_CHECKING, Any, Callable, Dict, Generator, List, Optional, Tuple,
    Union, cast
)

from kitty.constants import is_macos
from kitty.fast_data_types import (
    Screen, create_test_font_group, get_fallback_font, set_font_data,
    set_options, set_send_sprite_to_gpu, sprite_map_set_limits,
    test_render_line, test_shape, NUM_UNDERLINE_STYLES
)
from kitty.fonts.box_drawing import (
    BufType, distribute_dots, render_box_char, render_missing_glyph
)
from kitty.options.types import Options, defaults
from kitty.typing import CoreTextFont, FontConfigPattern
from kitty.types import _T
from kitty.utils import log_error

if is_macos:
    from .core_text import (
        find_font_features, font_for_family as font_for_family_macos,
        get_font_files as get_font_files_coretext
    )
else:
    from .fontconfig import (
        find_font_features, font_for_family as font_for_family_fontconfig,
        get_font_files as get_font_files_fontconfig
    )

FontObject = Union[CoreTextFont, FontConfigPattern]
current_faces: List[Tuple[FontObject, bool, bool]] = []


def get_font_files(opts: Options) -> Dict[str, Any]:
    if is_macos:
        return get_font_files_coretext(opts)
    return get_font_files_fontconfig(opts)


def font_for_family(family: str) -> Tuple[FontObject, bool, bool]:
    if is_macos:
        return font_for_family_macos(family)
    return font_for_family_fontconfig(family)


def merge_ranges(
    a: Tuple[Tuple[int, int], _T], b: Tuple[Tuple[int, int], _T], priority_map: Dict[Tuple[int, int], int]
) -> Generator[Tuple[Tuple[int, int], _T], None, None]:
    a_start, a_end = a[0]
    b_start, b_end = b[0]
    a_val, b_val = a[1], b[1]
    a_prio, b_prio = priority_map[a[0]], priority_map[b[0]]
    if b_start > a_end:
        if b_start == a_end + 1 and a_val == b_val:
            # ranges can be coalesced
            r = ((a_start, b_end), a_val)
            priority_map[r[0]] = max(a_prio, b_prio)
            yield r
            return
        # disjoint ranges
        yield a
        yield b
        return
    if a_val == b_val:
        # mergeable ranges
        r = ((a_start, max(a_end, b_end)), a_val)
        priority_map[r[0]] = max(a_prio, b_prio)
        yield r
        return
    before_range = mid_range = after_range = None
    before_range_prio = mid_range_prio = after_range_prio = 0
    if b_start > a_start:
        before_range = ((a_start, b_start - 1), a_val)
        before_range_prio = a_prio
    mid_end = min(a_end, b_end)
    if mid_end >= b_start:
        # overlap range
        mid_range = ((b_start, mid_end), a_val if priority_map[a[0]] >= priority_map[b[0]] else b_val)
        mid_range_prio = max(a_prio, b_prio)
    # after range
    if mid_end is a_end:
        if b_end > a_end:
            after_range = ((a_end + 1, b_end), b_val)
            after_range_prio = b_prio
    else:
        if a_end > b_end:
            after_range = ((b_end + 1, a_end), a_val)
            after_range_prio = a_prio
    # check if the before, mid and after ranges can be coalesced
    ranges: List[Tuple[Tuple[int, int], _T]] = []
    priorities: List[int] = []
    for rq, prio in ((before_range, before_range_prio), (mid_range, mid_range_prio), (after_range, after_range_prio)):
        if rq is None:
            continue
        r = rq
        if ranges:
            x = ranges[-1]
            if x[0][1] + 1 == r[0][0] and x[1] == r[1]:
                ranges[-1] = ((x[0][0], r[0][1]), x[1])
                priorities[-1] = max(priorities[-1], prio)
            else:
                ranges.append(r)
                priorities.append(prio)
        else:
            ranges.append(r)
            priorities.append(prio)
    for r, p in zip(ranges, priorities):
        priority_map[r[0]] = p
    yield from ranges


def coalesce_symbol_maps(maps: Dict[Tuple[int, int], _T]) -> Dict[Tuple[int, int], _T]:
    if not maps:
        return maps
    priority_map = {r: i for i, r in enumerate(maps.keys())}
    ranges = tuple((r, maps[r]) for r in sorted(maps))
    ans = [ranges[0]]

    for i in range(1, len(ranges)):
        r = ranges[i]
        new_ranges = merge_ranges(ans[-1], r, priority_map)
        if ans:
            del ans[-1]
        if not ans:
            ans = list(new_ranges)
        else:
            for r in new_ranges:
                prev = ans[-1]
                if prev[0][1] + 1 == r[0][0] and prev[1] == r[1]:
                    ans[-1] = (prev[0][0], r[0][1]), prev[1]
                else:
                    ans.append(r)
    return dict(ans)


def create_symbol_map(opts: Options) -> Tuple[Tuple[int, int, int], ...]:
    val = coalesce_symbol_maps(opts.symbol_map)
    family_map: Dict[str, int] = {}
    count = 0
    for family in val.values():
        if family not in family_map:
            font, bold, italic = font_for_family(family)
            family_map[family] = count
            count += 1
            current_faces.append((font, bold, italic))
    sm = tuple((a, b, family_map[f]) for (a, b), f in val.items())
    return sm


def create_narrow_symbols(opts: Options) -> Tuple[Tuple[int, int, int], ...]:
    return tuple((a, b, v) for (a, b), v in coalesce_symbol_maps(opts.narrow_symbols).items())


def descriptor_for_idx(idx: int) -> Tuple[FontObject, bool, bool]:
    return current_faces[idx]


def dump_faces(ftypes: List[str], indices: Dict[str, int]) -> None:
    def face_str(f: Tuple[FontObject, bool, bool]) -> str:
        fo = f[0]
        if 'index' in fo:
            return '{}:{}'.format(fo['path'], cast('FontConfigPattern', fo)['index'])
        fo = cast('CoreTextFont', fo)
        return fo['path']

    log_error('Preloaded font faces:')
    log_error('normal face:', face_str(current_faces[0]))
    for ftype in ftypes:
        if indices[ftype]:
            log_error(ftype, 'face:', face_str(current_faces[indices[ftype]]))
    si_faces = current_faces[max(indices.values())+1:]
    if si_faces:
        log_error('Symbol map faces:')
        for face in si_faces:
            log_error(face_str(face))


def set_font_family(opts: Optional[Options] = None, override_font_size: Optional[float] = None, debug_font_matching: bool = False) -> None:
    global current_faces
    opts = opts or defaults
    sz = override_font_size or opts.font_size
    font_map = get_font_files(opts)
    current_faces = [(font_map['medium'], False, False)]
    ftypes = 'bold italic bi'.split()
    indices = {k: 0 for k in ftypes}
    for k in ftypes:
        if k in font_map:
            indices[k] = len(current_faces)
            current_faces.append((font_map[k], 'b' in k, 'i' in k))
    before = len(current_faces)
    sm = create_symbol_map(opts)
    ns = create_narrow_symbols(opts)
    num_symbol_fonts = len(current_faces) - before
    font_features = {}
    for face, _, _ in current_faces:
        font_features[face['postscript_name']] = find_font_features(face['postscript_name'])
    font_features.update(opts.font_features)
    if debug_font_matching:
        dump_faces(ftypes, indices)
    set_font_data(
        render_box_drawing, prerender_function, descriptor_for_idx,
        indices['bold'], indices['italic'], indices['bi'], num_symbol_fonts,
        sm, sz, font_features, ns
    )


if TYPE_CHECKING:
    CBufType = ctypes.Array[ctypes.c_ubyte]
else:
    CBufType = None
UnderlineCallback = Callable[[CBufType, int, int, int, int], None]


def add_line(buf: CBufType, cell_width: int, position: int, thickness: int, cell_height: int) -> None:
    y = position - thickness // 2
    while thickness > 0 and -1 < y < cell_height:
        thickness -= 1
        ctypes.memset(ctypes.addressof(buf) + (cell_width * y), 255, cell_width)
        y += 1


def add_dline(buf: CBufType, cell_width: int, position: int, thickness: int, cell_height: int) -> None:
    a = min(position - thickness, cell_height - 1)
    b = min(position, cell_height - 1)
    top, bottom = min(a, b), max(a, b)
    deficit = 2 - (bottom - top)
    if deficit > 0:
        if bottom + deficit < cell_height:
            bottom += deficit
        elif bottom < cell_height - 1:
            bottom += 1
            if deficit > 1:
                top -= deficit - 1
        else:
            top -= deficit
    top = max(0, min(top, cell_height - 1))
    bottom = max(0, min(bottom, cell_height - 1))
    for y in {top, bottom}:
        ctypes.memset(ctypes.addressof(buf) + (cell_width * y), 255, cell_width)


def add_curl(buf: CBufType, cell_width: int, position: int, thickness: int, cell_height: int) -> None:
    max_x, max_y = cell_width - 1, cell_height - 1
    xfactor = 2.0 * pi / max_x
    thickness = max(1, thickness)
    if thickness < 3:
        half_height = thickness
        thickness -= 1
    elif thickness == 3:
        half_height = thickness = 2
    else:
        half_height = thickness // 2
        thickness -= 2

    def add_intensity(x: int, y: int, val: int) -> None:
        y += position
        y = min(y, max_y)
        idx = cell_width * y + x
        buf[idx] = min(255, buf[idx] + val)

    # Ensure curve doesn't exceed cell boundary at the bottom
    position += half_height * 2
    if position + half_height > max_y:
        position = max_y - half_height

    # Use the Wu antialias algorithm to draw the curve
    # cosine waves always have slope <= 1 so are never steep
    for x in range(cell_width):
        y = half_height * cos(x * xfactor)
        y1, y2 = floor(y - thickness), ceil(y)
        i1 = int(255 * abs(y - floor(y)))
        add_intensity(x, y1, 255 - i1)  # upper bound
        add_intensity(x, y2, i1)  # lower bound
        # fill between upper and lower bound
        for t in range(1, thickness + 1):
            add_intensity(x, y1 + t, 255)


def add_dots(buf: CBufType, cell_width: int, position: int, thickness: int, cell_height: int) -> None:
    spacing, size = distribute_dots(cell_width, cell_width // (2 * thickness))

    y = 1 + position - thickness // 2
    for i in range(y, min(y + thickness, cell_height)):
        for j, s in enumerate(spacing):
            buf[cell_width * i + j * size + s: cell_width * i + (j + 1) * size + s] = [255] * size


def add_dashes(buf: CBufType, cell_width: int, position: int, thickness: int, cell_height: int) -> None:
    halfspace_width = cell_width // 4
    y = 1 + position - thickness // 2
    for i in range(y, min(y + thickness, cell_height)):
        buf[cell_width * i:cell_width * i + (cell_width - 3 * halfspace_width)] = [255] * (cell_width - 3 * halfspace_width)
        buf[cell_width * i + 3 * halfspace_width:cell_width * (i + 1)] = [255] * (cell_width - 3 * halfspace_width)


def render_special(
    underline: int = 0,
    strikethrough: bool = False,
    missing: bool = False,
    cell_width: int = 0, cell_height: int = 0,
    baseline: int = 0,
    underline_position: int = 0,
    underline_thickness: int = 0,
    strikethrough_position: int = 0,
    strikethrough_thickness: int = 0,
    dpi_x: float = 96.,
    dpi_y: float = 96.,
) -> CBufType:
    underline_position = min(underline_position, cell_height - sum(divmod(underline_thickness, 2)))
    CharTexture = ctypes.c_ubyte * (cell_width * cell_height)

    if missing:
        buf = bytearray(cell_width * cell_height)
        render_missing_glyph(buf, cell_width, cell_height)
        return CharTexture.from_buffer(buf)

    ans = CharTexture()

    def dl(f: UnderlineCallback, *a: Any) -> None:
        try:
            f(ans, cell_width, *a)
        except Exception as e:
            log_error(f'Failed to render {f.__name__} at cell_width={cell_width} and cell_height={cell_height} with error: {e}')

    if underline:
        t = underline_thickness
        if underline > 1:
            t = max(1, min(cell_height - underline_position - 1, t))
        dl([add_line, add_line, add_dline, add_curl, add_dots, add_dashes][underline], underline_position, t, cell_height)
    if strikethrough:
        dl(add_line, strikethrough_position, strikethrough_thickness, cell_height)

    return ans


def render_cursor(
    which: int,
    cursor_beam_thickness: float,
    cursor_underline_thickness: float,
    cell_width: int = 0,
    cell_height: int = 0,
    dpi_x: float = 0,
    dpi_y: float = 0
) -> CBufType:
    CharTexture = ctypes.c_ubyte * (cell_width * cell_height)
    ans = CharTexture()

    def vert(edge: str, width_pt: float = 1) -> None:
        width = max(1, min(int(round(width_pt * dpi_x / 72.0)), cell_width))
        left = 0 if edge == 'left' else max(0, cell_width - width)
        for y in range(cell_height):
            offset = y * cell_width + left
            for x in range(offset, offset + width):
                ans[x] = 255

    def horz(edge: str, height_pt: float = 1) -> None:
        height = max(1, min(int(round(height_pt * dpi_y / 72.0)), cell_height))
        top = 0 if edge == 'top' else max(0, cell_height - height)
        for y in range(top, top + height):
            offset = y * cell_width
            for x in range(cell_width):
                ans[offset + x] = 255

    if which == 1:  # beam
        vert('left', cursor_beam_thickness)
    elif which == 2:  # underline
        horz('bottom', cursor_underline_thickness)
    elif which == 3:  # hollow
        vert('left')
        vert('right')
        horz('top')
        horz('bottom')
    return ans


def prerender_function(
    cell_width: int,
    cell_height: int,
    baseline: int,
    underline_position: int,
    underline_thickness: int,
    strikethrough_position: int,
    strikethrough_thickness: int,
    cursor_beam_thickness: float,
    cursor_underline_thickness: float,
    dpi_x: float,
    dpi_y: float
) -> Tuple[Tuple[int, ...], Tuple[CBufType, ...]]:
    # Pre-render the special underline, strikethrough and missing and cursor cells
    f = partial(
        render_special, cell_width=cell_width, cell_height=cell_height, baseline=baseline,
        underline_position=underline_position, underline_thickness=underline_thickness,
        strikethrough_position=strikethrough_position, strikethrough_thickness=strikethrough_thickness,
        dpi_x=dpi_x, dpi_y=dpi_y
    )
    c = partial(
        render_cursor, cursor_beam_thickness=cursor_beam_thickness,
        cursor_underline_thickness=cursor_underline_thickness, cell_width=cell_width,
        cell_height=cell_height, dpi_x=dpi_x, dpi_y=dpi_y)
    # If you change the mapping of these cells you will need to change
    # NUM_UNDERLINE_STYLES and BEAM_IDX in shader.c and STRIKE_SPRITE_INDEX in
    # window.py and MISSING_GLYPH in font.c
    cells = list(map(f, range(1, NUM_UNDERLINE_STYLES + 1)))  # underline sprites
    cells.append(f(0, strikethrough=True))  # strikethrough sprite
    cells.append(f(missing=True))  # missing glyph
    cells.extend((c(1), c(2), c(3)))  # cursor glyphs
    tcells = tuple(cells)
    return tuple(map(ctypes.addressof, tcells)), tcells


def render_box_drawing(codepoint: int, cell_width: int, cell_height: int, dpi: float) -> Tuple[int, CBufType]:
    CharTexture = ctypes.c_ubyte * (cell_width * cell_height)
    buf = CharTexture()
    render_box_char(
        chr(codepoint), cast(BufType, buf), cell_width, cell_height, dpi
    )
    return ctypes.addressof(buf), buf


class setup_for_testing:

    def __init__(self, family: str = 'monospace', size: float = 11.0, dpi: float = 96.0):
        self.family, self.size, self.dpi = family, size, dpi

    def __enter__(self) -> Tuple[Dict[Tuple[int, int, int], bytes], int, int]:
        opts = defaults._replace(font_family=self.family, font_size=self.size)
        set_options(opts)
        sprites = {}

        def send_to_gpu(x: int, y: int, z: int, data: bytes) -> None:
            sprites[(x, y, z)] = data

        sprite_map_set_limits(100000, 100)
        set_send_sprite_to_gpu(send_to_gpu)
        try:
            set_font_family(opts)
            cell_width, cell_height = create_test_font_group(self.size, self.dpi, self.dpi)
            return sprites, cell_width, cell_height
        except Exception:
            set_send_sprite_to_gpu(None)
            raise

    def __exit__(self, *args: Any) -> None:
        set_send_sprite_to_gpu(None)


def render_string(text: str, family: str = 'monospace', size: float = 11.0, dpi: float = 96.0) -> Tuple[int, int, List[bytes]]:
    with setup_for_testing(family, size, dpi) as (sprites, cell_width, cell_height):
        s = Screen(None, 1, len(text)*2)
        line = s.line(0)
        s.draw(text)
        test_render_line(line)
    cells = []
    found_content = False
    for i in reversed(range(s.columns)):
        sp = list(line.sprite_at(i))
        sp[2] &= 0xfff
        tsp = sp[0], sp[1], sp[2]
        if tsp == (0, 0, 0) and not found_content:
            continue
        found_content = True
        cells.append(sprites[tsp])
    return cell_width, cell_height, list(reversed(cells))


def shape_string(
    text: str = "abcd", family: str = 'monospace', size: float = 11.0, dpi: float = 96.0, path: Optional[str] = None
) -> List[Tuple[int, int, int, Tuple[int, ...]]]:
    with setup_for_testing(family, size, dpi) as (sprites, cell_width, cell_height):
        s = Screen(None, 1, len(text)*2)
        line = s.line(0)
        s.draw(text)
        return test_shape(line, path)


def display_bitmap(rgb_data: bytes, width: int, height: int) -> None:
    from tempfile import NamedTemporaryFile

    from kittens.icat.main import detect_support, show
    if not hasattr(display_bitmap, 'detected') and not detect_support():
        raise SystemExit('Your terminal does not support the graphics protocol')
    setattr(display_bitmap, 'detected', True)
    with NamedTemporaryFile(suffix='.rgba', delete=False) as f:
        f.write(rgb_data)
    assert len(rgb_data) == 4 * width * height
    show(f.name, width, height, 0, 32, align='left')


def test_render_string(
        text: str = 'Hello, world!',
        family: str = 'monospace',
        size: float = 64.0,
        dpi: float = 96.0
) -> None:
    from kitty.fast_data_types import concat_cells, current_fonts

    cell_width, cell_height, cells = render_string(text, family, size, dpi)
    rgb_data = concat_cells(cell_width, cell_height, True, tuple(cells))
    cf = current_fonts()
    fonts = [cf['medium'].display_name()]
    fonts.extend(f.display_name() for f in cf['fallback'])
    msg = 'Rendered string {} below, with fonts: {}\n'.format(text, ', '.join(fonts))
    try:
        print(msg)
    except UnicodeEncodeError:
        sys.stdout.buffer.write(msg.encode('utf-8') + b'\n')
    display_bitmap(rgb_data, cell_width * len(cells), cell_height)
    print('\n')


def test_fallback_font(qtext: Optional[str] = None, bold: bool = False, italic: bool = False) -> None:
    with setup_for_testing():
        if qtext:
            trials = [qtext]
        else:
            trials = ['你', 'He\u0347\u0305', '\U0001F929']
        for text in trials:
            f = get_fallback_font(text, bold, italic)
            try:
                print(text, f)
            except UnicodeEncodeError:
                sys.stdout.buffer.write(f'{text} {f}\n'.encode('utf-8'))


def showcase() -> None:
    f = 'monospace' if is_macos else 'Liberation Mono'
    test_render_string('He\u0347\u0305llo\u0337, w\u0302or\u0306l\u0354d!', family=f)
    test_render_string('你好,世界', family=f)
    test_render_string('│😁│🙏│😺│', family=f)
    test_render_string('A=>>B!=C', family='Fira Code')
