from __future__ import annotations

import collections
import contextlib
import curses
import functools
import hashlib
import io
import itertools
import os.path
import re
from collections.abc import Callable
from collections.abc import Generator
from re import Match
from re import Pattern
from typing import Concatenate
from typing import IO
from typing import Literal
from typing import NamedTuple
from typing import ParamSpec
from typing import TYPE_CHECKING
from typing import TypedDict
from typing import TypeVar

from babi.buf import Buf
from babi.buf import Modification
from babi.dim import Dim
from babi.hl.interface import FileHL
from babi.hl.lint_errors import LintErrors
from babi.hl.replace import Replace
from babi.hl.selection import Selection
from babi.hl.syntax import Syntax
from babi.hl.trailing_whitespace import TrailingWhitespace
from babi.prompt import PromptResult
from babi.status import Status

if TYPE_CHECKING:
    from babi.main import Screen  # XXX: circular

P = ParamSpec('P')
R = TypeVar('R')
FileMethod = Callable[Concatenate['File', P], R]

WS_RE = re.compile(r'^\s*')


class OpenSettings(TypedDict):
    encoding: Literal['UTF-8']
    newline: Literal['']


OPEN_SETTINGS = OpenSettings(encoding='UTF-8', newline='')


class NullByteError(ValueError):
    pass


def get_lines(sio: IO[str]) -> tuple[list[str], str, bool, str]:
    sha256 = hashlib.sha256()
    lines = []
    newlines = collections.Counter({'\n': 0})  # default to `\n`
    for line in sio:
        if '\0' in line:
            raise NullByteError
        sha256.update(line.encode())
        for ending in ('\r\n', '\n'):
            if line.endswith(ending):
                lines.append(line[:-1 * len(ending)])
                newlines[ending] += 1
                break
        else:
            lines.append(line)
    # always make sure we end in a newline
    lines.append('')
    (nl, _), = newlines.most_common(1)
    mixed = len({k for k, v in newlines.items() if v}) > 1
    return lines, nl, mixed, sha256.hexdigest()


class OpenError(RuntimeError):
    pass


def _load_file(filename: str) -> tuple[list[str], str, bool, str]:
    try:
        with open(filename, encoding='UTF-8', newline='') as f:
            return get_lines(f)
    except NullByteError:
        raise OpenError(fr'error! file contains \0 bytes: {filename!r}')
    except UnicodeDecodeError:
        raise OpenError(f'error! not utf-8: {filename!r}')
    except OSError:
        # XXX: not quite correct, but maybe fix another day
        raise OpenError(f'error! not a file: {filename!r}')


class Action:
    def __init__(
            self, *, name: str, modifications: list[Modification],
            start_x: int, start_y: int, start_modified: bool,
            end_x: int, end_y: int, end_modified: bool,
            final: bool,
    ):
        self.name = name
        self.modifications = modifications
        self.start_x = start_x
        self.start_y = start_y
        self.start_modified = start_modified
        self.end_x = end_x
        self.end_y = end_y
        self.end_modified = end_modified
        self.final = final

    def apply(self, file: File) -> Action:
        action = Action(
            name=self.name, modifications=file.buf.apply(self.modifications),
            start_x=self.end_x, start_y=self.end_y,
            start_modified=self.end_modified,
            end_x=self.start_x, end_y=self.start_y,
            end_modified=self.start_modified,
            final=True,
        )

        file.buf.y = self.start_y
        file.buf.x = self.start_x
        file.modified = self.start_modified

        return action


def action(func: FileMethod[P, R]) -> FileMethod[P, R]:
    @functools.wraps(func)
    def action_inner(self: File, *args: P.args, **kwargs: P.kwargs) -> R:
        self.finalize_previous_action()
        return func(self, *args, **kwargs)
    return action_inner


def edit_action(
        name: str,
        *,
        final: bool,
) -> Callable[[FileMethod[P, R]], FileMethod[P, R]]:
    def edit_action_decorator(func: FileMethod[P, R]) -> FileMethod[P, R]:
        @functools.wraps(func)
        def edit_action_inner(
                self: File,
                *args: P.args,
                **kwargs: P.kwargs,
        ) -> R:
            with self.edit_action_context(name, final=final):
                return func(self, *args, **kwargs)
        return edit_action_inner
    return edit_action_decorator


def keep_selection(func: FileMethod[P, R]) -> FileMethod[P, R]:
    @functools.wraps(func)
    def keep_selection_inner(
            self: File,
            *args: P.args,
            **kwargs: P.kwargs,
    ) -> R:
        with self.select():
            return func(self, *args, **kwargs)
    return keep_selection_inner


def clear_selection(func: FileMethod[P, R]) -> FileMethod[P, R]:
    @functools.wraps(func)
    def clear_selection_inner(
            self: File,
            *args: P.args,
            **kwargs: P.kwargs,
    ) -> R:
        ret = func(self, *args, **kwargs)
        self.selection.clear()
        return ret
    return clear_selection_inner


class Found(NamedTuple):
    y: int
    match: Match[str]


class _SearchIter:
    def __init__(
            self,
            file: File,
            reg: Pattern[str],
            *,
            offset: int,
    ) -> None:
        self.file = file
        self.reg = reg
        self.offset = offset
        self.wrapped = False
        self._start_x = file.buf.x + offset
        self._start_y = file.buf.y

    def __iter__(self) -> _SearchIter:
        return self

    def replaced(self, y: int, match: Match[str], new: str) -> None:
        if not self.wrapped or y != self._start_y:
            return
        self._start_x += len(new) - match.end() - match.start()

    def _stop_if_past_original(self, y: int, match: Match[str]) -> Found:
        if (
                self.wrapped and (
                    y > self._start_y or
                    y == self._start_y and match.start() >= self._start_x
                )
        ):
            raise StopIteration()
        return Found(y, match)

    def __next__(self) -> tuple[int, Match[str]]:
        x = self.file.buf.x + self.offset
        y = self.file.buf.y

        match = self.reg.search(self.file.buf[y], x)
        if match:
            return self._stop_if_past_original(y, match)

        if self.wrapped:
            for line_y in range(y + 1, self._start_y + 1):
                match = self.reg.search(self.file.buf[line_y])
                if match:
                    return self._stop_if_past_original(line_y, match)
        else:
            for line_y in range(y + 1, len(self.file.buf) - 1):
                match = self.reg.search(self.file.buf[line_y])
                if match:
                    return self._stop_if_past_original(line_y, match)

            self.wrapped = True

            for line_y in range(0, self._start_y + 1):
                match = self.reg.search(self.file.buf[line_y])
                if match:
                    return self._stop_if_past_original(line_y, match)

        raise StopIteration()


class File:
    def __init__(
            self,
            filename: str | None,
            initial_line: int,
            syntax: Syntax,
            *,
            is_stdin: bool,
    ) -> None:
        self.filename = filename
        self.initial_line = initial_line
        self.is_stdin = is_stdin
        self.modified = False
        self.buf = Buf([])
        self.nl = '\n'
        self.sha256: str | None = None
        self._in_edit_action = False
        self.undo_stack: list[Action] = []
        self.redo_stack: list[Action] = []
        self._syntax = syntax
        self._file_syntax = syntax.blank_file_highlighter()
        self.lint_errors = LintErrors(syntax.color_manager, syntax.theme)
        self._trailing_whitespace = TrailingWhitespace(syntax.color_manager)
        self._replace_hl = Replace()
        self.selection = Selection()
        self._file_hls: tuple[FileHL, ...] = ()

    def ensure_loaded(
            self,
            status: Status,
            dim: Dim,
            stdin: str,
    ) -> None:
        if self.buf:
            return

        if self.is_stdin:
            status.update('(from stdin)')
            self.is_stdin = False
            self.filename = None
            self.modified = True
            sio = io.StringIO(stdin)
            lines, self.nl, mixed, self.sha256 = get_lines(sio)
        elif self.filename is not None and os.path.lexists(self.filename):
            try:
                lines, self.nl, mixed, self.sha256 = _load_file(self.filename)
            except OpenError as e:
                status.update(str(e))
                self.filename = None
                lines, self.nl, mixed, self.sha256 = get_lines(io.StringIO(''))
        else:
            if self.filename is not None:
                status.update('(new file)')
            lines, self.nl, mixed, self.sha256 = get_lines(io.StringIO(''))

        self.buf = Buf(lines, self.buf.tab_size)

        if mixed:
            status.update(f'mixed newlines will be converted to {self.nl!r}')
            self.modified = True

        self._initialize_highlighters()

        self.go_to_line(self.initial_line, dim)

    @property
    def root_scope(self) -> str:
        return self._file_syntax.root_scope

    def _initialize_highlighters(self) -> None:
        if self.filename is not None:
            self._file_syntax = self._syntax.file_highlighter(
                self.filename,
                self.buf[0],
            )
        else:
            self._file_syntax = self._syntax.blank_file_highlighter()

        # hack due to https://github.com/python/mypy/issues/12360
        file_hls: tuple[FileHL, ...] = (
            self._file_syntax,
            self.lint_errors,
            self._trailing_whitespace,
            self._replace_hl,
            self.selection,
        )
        self._file_hls = file_hls

        self.buf.clear_callbacks()
        for file_hl in self._file_hls:
            file_hl.register_callbacks(self.buf)

    def reload_theme(self, syntax: Syntax) -> None:
        self._syntax = syntax
        self.lint_errors = self.lint_errors.clone(
            syntax.color_manager,
            syntax.theme,
        )
        self._trailing_whitespace = TrailingWhitespace(syntax.color_manager)
        # only re-initialize the highlighters if we've loaded once
        if self._file_hls:
            self._initialize_highlighters()

    def __repr__(self) -> str:
        return f'<{type(self).__name__} {self.filename!r}>'

    def reset_modified_state(self) -> None:
        for stack in (self.undo_stack, self.redo_stack):
            first = True
            for action in reversed(stack):
                action.end_modified = not first
                action.start_modified = True
                first = False

    # movement

    @action
    def up(self, dim: Dim) -> None:
        self.buf.up(dim)

    @action
    def down(self, dim: Dim) -> None:
        self.buf.down(dim)

    @action
    def right(self, dim: Dim) -> None:
        self.buf.right(dim)

    @action
    def left(self, dim: Dim) -> None:
        self.buf.left(dim)

    @action
    def home(self, dim: Dim) -> None:
        self.buf.x = 0

    @action
    def end(self, dim: Dim) -> None:
        self.buf.x = len(self.buf[self.buf.y])

    @action
    def ctrl_up(self, dim: Dim) -> None:
        self.buf.file_up(dim)

    @action
    def ctrl_down(self, dim: Dim) -> None:
        self.buf.file_down(dim)

    @action
    def ctrl_right(self, dim: Dim) -> None:
        line = self.buf[self.buf.y]
        # if we're at the second to last character, jump to end of line
        if self.buf.x == len(line) - 1:
            self.buf.right(dim)
        # if we're at the end of the line, jump forward to the next non-ws
        elif self.buf.x == len(line):
            while (
                    self.buf.y < len(self.buf) - 1 and (
                        self.buf.x == len(self.buf[self.buf.y]) or
                        self.buf[self.buf.y][self.buf.x].isspace()
                    )
            ):
                self.buf.right(dim)
        # if we're inside the line, jump to next position that's not our type
        else:
            self.buf.right(dim)
            tp = line[self.buf.x].isalnum()
            while self.buf.x < len(line) and tp == line[self.buf.x].isalnum():
                self.buf.right(dim)

    @action
    def ctrl_left(self, dim: Dim) -> None:
        line = self.buf[self.buf.y]
        # if we're at position 1 and it's not a space, go to the beginning
        if self.buf.x == 1 and not line[:self.buf.x].isspace():
            self.buf.left(dim)
        # if we're at the beginning or it's all space up to here jump to the
        # end of the previous non-space line
        elif self.buf.x == 0 or line[:self.buf.x].isspace():
            self.buf.x = 0
            while self.buf.y > 0 and self.buf.x == 0:
                self.buf.left(dim)
        else:
            self.buf.left(dim)
            tp = line[self.buf.x - 1].isalnum()
            while self.buf.x > 0 and tp == line[self.buf.x - 1].isalnum():
                self.buf.left(dim)

    @action
    def ctrl_home(self, dim: Dim) -> None:
        self.buf.x = 0
        self.buf.y = self.buf.file_y = 0

    @action
    def ctrl_end(self, dim: Dim) -> None:
        self.buf.x = 0
        self.buf.y = len(self.buf) - 1
        self.buf.scroll_screen_if_needed(dim)

    @action
    def go_to_line(self, lineno: int, dim: Dim) -> None:
        self.buf.x = 0
        if lineno == 0:
            self.buf.y = 0
        elif lineno > len(self.buf):
            self.buf.y = len(self.buf) - 1
        elif lineno < 0:
            self.buf.y = max(0, lineno + len(self.buf))
        else:
            self.buf.y = lineno - 1
        self.buf.scroll_screen_if_needed(dim)

    @action
    def search(
            self,
            reg: Pattern[str],
            status: Status,
            dim: Dim,
    ) -> None:
        search = _SearchIter(self, reg, offset=1)
        try:
            line_y, match = next(iter(search))
        except StopIteration:
            status.update('no matches')
        else:
            if line_y == self.buf.y and match.start() == self.buf.x:
                status.update('this is the only occurrence')
            else:
                if search.wrapped:
                    status.update('search wrapped')
                self.buf.y = line_y
                self.buf.x = match.start()
                self.buf.scroll_screen_if_needed(dim)

    @clear_selection
    def replace(
            self,
            screen: Screen,
            reg: Pattern[str],
            replace: str,
    ) -> None:
        self.finalize_previous_action()

        count = 0
        res: str | PromptResult = ''
        search = _SearchIter(self, reg, offset=0)
        for line_y, match in search:
            end = match.end()
            self.buf.y = line_y
            self.buf.x = match.start()
            self.buf.scroll_screen_if_needed(screen.layout.file)
            if res != 'a':  # make `a` replace the rest of them
                with self._replace_hl.region(self.buf.y, self.buf.x, end):
                    screen.draw()
                    res = screen.quick_prompt('replace', ('yes', 'no', 'all'))
            if res in {'y', 'a'}:
                count += 1
                with self.edit_action_context('replace', final=True):
                    replaced = match.expand(replace)
                    search.replaced(line_y, match, replaced)
                    line = screen.file.buf[line_y]
                    if '\n' in replaced:
                        replaced_lines = replaced.split('\n')
                        self.buf[line_y] = (
                            f'{line[:match.start()]}{replaced_lines[0]}'
                        )
                        for i, ins_line in enumerate(replaced_lines[1:-1], 1):
                            self.buf.insert(line_y + i, ins_line)
                        last_insert = line_y + len(replaced_lines) - 1
                        self.buf.insert(
                            last_insert, f'{replaced_lines[-1]}{line[end:]}',
                        )
                        self.buf.y = last_insert
                        self.buf.x = 0
                        search.offset = len(replaced_lines[-1])
                    else:
                        self.buf[line_y] = (
                            f'{line[:match.start()]}{replaced}{line[end:]}'
                        )
                        search.offset = len(replaced)
            elif res == 'n':
                search.offset = 1
            else:
                assert res is PromptResult.CANCELLED
                return

        if res == '':  # we never went through the loop
            screen.status.update('no matches')
        else:
            occurrences = 'occurrence' if count == 1 else 'occurrences'
            screen.status.update(f'replaced {count} {occurrences}')

    def _page_size(self, dim: Dim) -> int:
        return max(dim.height - 2, 1)

    @action
    def page_up(self, dim: Dim) -> None:
        if self.buf.y < dim.height:
            self.buf.y = self.buf.file_y = 0
        else:
            pos = max(self.buf.file_y - self._page_size(dim), 0)
            self.buf.y = self.buf.file_y = pos
        self.buf.x = 0

    @action
    def page_down(self, dim: Dim) -> None:
        if self.buf.file_y + dim.height >= len(self.buf):
            self.buf.y = len(self.buf) - 1
        else:
            pos = self.buf.file_y + self._page_size(dim)
            self.buf.y = self.buf.file_y = pos
        self.buf.x = 0

    @action
    def alt_up(self, dim: Dim) -> None:
        if self.buf.y > 0:
            offset = 1
            while (
                self.buf[self.buf.y - offset] == '' and
                self.buf.y - offset - 1 >= 0
            ):
                offset += 1
            if offset >= 2:
                self.buf.y -= offset
                self.buf.scroll_screen_if_needed(dim)
                self.buf.x = 0
                return
            while (
                self.buf[self.buf.y - offset - 1] != '' and
                self.buf.y - offset - 1 >= 0
            ):
                offset += 1
            self.buf.y -= offset
            self.buf.scroll_screen_if_needed(dim)
            self.buf.x = 0

    @action
    def alt_down(self, dim: Dim) -> None:
        if self.buf.y < len(self.buf) - 1:
            offset = 0
            while self.buf[self.buf.y + offset] != '':
                offset += 1
            if offset > 1:
                self.buf.y += offset - 1
                self.buf.scroll_screen_if_needed(dim)
                self.buf.x = 0
                return
            while self.buf[self.buf.y + offset] == '':
                if self.buf.y + offset >= len(self.buf) - 1:
                    break
                offset += 1
            self.buf.y += offset
            self.buf.scroll_screen_if_needed(dim)
            self.buf.x = 0

    # editing

    @edit_action('backspace text', final=False)
    @clear_selection
    def backspace(self, dim: Dim) -> None:
        # backspace at the beginning of the file does nothing
        if self.buf.y == 0 and self.buf.x == 0:
            pass
        # backspace at the end of the file does not change the contents
        elif (
                self.buf.y == len(self.buf) - 1 and
                # still allow backspace if there are 2+ blank lines
                self.buf[self.buf.y - 1] != ''
        ):
            self.buf.left(dim)
        # at the beginning of the line, we join the current line and
        # the previous line
        elif self.buf.x == 0:
            y, victim = self.buf.y, self.buf.pop(self.buf.y)
            self.buf.left(dim)
            self.buf[y - 1] += victim
        else:
            s = self.buf[self.buf.y]
            self.buf[self.buf.y] = s[:self.buf.x - 1] + s[self.buf.x:]
            self.buf.left(dim)

    @edit_action('delete text', final=False)
    @clear_selection
    def delete(self, dim: Dim) -> None:
        if (
            # noop at end of the file
            self.buf.y == len(self.buf) - 1 or
            # noop at end of last real line
            (
                self.buf.y == len(self.buf) - 2 and
                self.buf.x == len(self.buf[self.buf.y])
            )
        ):
            pass
        # if we're at the end of the line, collapse the line afterwards
        elif self.buf.x == len(self.buf[self.buf.y]):
            victim = self.buf.pop(self.buf.y + 1)
            self.buf[self.buf.y] += victim
        else:
            s = self.buf[self.buf.y]
            self.buf[self.buf.y] = s[:self.buf.x] + s[self.buf.x + 1:]

    @edit_action('line break', final=False)
    @clear_selection
    def enter(self, dim: Dim) -> None:
        s = self.buf[self.buf.y]
        self.buf[self.buf.y] = s[:self.buf.x]
        self.buf.insert(self.buf.y + 1, s[self.buf.x:])
        self.buf.down(dim)
        self.buf.x = 0

    @edit_action('indent selection', final=True)
    def _indent_selection(self, dim: Dim) -> None:
        assert self.selection.start is not None
        sel_y, sel_x = self.selection.start
        (s_y, _), (e_y, _) = self.selection.get()
        tab_string = self.buf.tab_string
        tab_size = len(tab_string)
        for l_y in range(s_y, e_y + 1):
            if self.buf[l_y]:
                self.buf[l_y] = tab_string + self.buf[l_y]
                if l_y == self.buf.y:
                    self.buf.x += tab_size
                if l_y == sel_y and sel_x != 0:
                    sel_x += tab_size
        self.selection.set(sel_y, sel_x, self.buf.y, self.buf.x)

    @edit_action('insert tab', final=False)
    def _tab(self, dim: Dim) -> None:
        tab_string = self.buf.tab_string
        if tab_string == '\t':
            n = 1
        else:
            n = self.buf.tab_size - self.buf.x % self.buf.tab_size
            tab_string = tab_string[:n]
        line = self.buf[self.buf.y]
        self.buf[self.buf.y] = (
            line[:self.buf.x] + tab_string + line[self.buf.x:]
        )
        self.buf.x += n
        self.buf.restore_eof_invariant()

    def tab(self, dim: Dim) -> None:
        if self.selection.start is not None:
            self._indent_selection(dim)
        else:
            self._tab(dim)

    def _dedent_line(self, s: str) -> int:
        bound = min(len(s), len(self.buf.tab_string))
        i = 0
        while i < bound and s[i] in (' ', '\t'):
            i += 1
        return i

    @edit_action('dedent selection', final=True)
    def _dedent_selection(self, dim: Dim) -> None:
        assert self.selection.start is not None
        sel_y, sel_x = self.selection.start
        (s_y, _), (e_y, _) = self.selection.get()
        for l_y in range(s_y, e_y + 1):
            n = self._dedent_line(self.buf[l_y])
            if n:
                self.buf[l_y] = self.buf[l_y][n:]
                if l_y == self.buf.y:
                    self.buf.x = max(self.buf.x - n, 0)
                if l_y == sel_y:
                    sel_x = max(sel_x - n, 0)
        self.selection.set(sel_y, sel_x, self.buf.y, self.buf.x)

    @edit_action('dedent', final=True)
    def _dedent(self, dim: Dim) -> None:
        n = self._dedent_line(self.buf[self.buf.y])
        if n:
            self.buf[self.buf.y] = self.buf[self.buf.y][n:]
            self.buf.x = max(self.buf.x - n, 0)

    def shift_tab(self, dim: Dim) -> None:
        if self.selection.start is not None:
            self._dedent_selection(dim)
        else:
            self._dedent(dim)

    @edit_action('cut selection', final=True)
    @clear_selection
    def cut_selection(self, dim: Dim) -> tuple[str, ...]:
        ret = []
        (s_y, s_x), (e_y, e_x) = self.selection.get()
        if s_y == e_y:
            ret.append(self.buf[s_y][s_x:e_x])
            self.buf[s_y] = self.buf[s_y][:s_x] + self.buf[s_y][e_x:]
        else:
            ret.append(self.buf[s_y][s_x:])
            for l_y in range(s_y + 1, e_y):
                ret.append(self.buf[l_y])
            ret.append(self.buf[e_y][:e_x])

            self.buf[s_y] = self.buf[s_y][:s_x] + self.buf[e_y][e_x:]
            for _ in range(s_y + 1, e_y + 1):
                self.buf.pop(s_y + 1)
        self.buf.y = s_y
        self.buf.x = s_x
        self.buf.scroll_screen_if_needed(dim)
        self.buf.restore_eof_invariant()
        return tuple(ret)

    def cut(self, cut_buffer: tuple[str, ...]) -> tuple[str, ...]:
        # only continue a cut if the last action is a non-final cut
        if not self._continue_last_action('cut'):
            cut_buffer = ()

        with self.edit_action_context('cut', final=False):
            if self.buf.y == len(self.buf) - 1:
                return cut_buffer
            else:
                victim = self.buf.pop(self.buf.y)
                self.buf.x = 0
                return cut_buffer + (victim,)

    def _uncut(self, cut_buffer: tuple[str, ...], dim: Dim) -> None:
        for cut_line in cut_buffer:
            line = self.buf[self.buf.y]
            before, after = line[:self.buf.x], line[self.buf.x:]
            self.buf[self.buf.y] = before + cut_line
            self.buf.insert(self.buf.y + 1, after)
            self.buf.down(dim)
            self.buf.x = 0

    @edit_action('uncut', final=True)
    @clear_selection
    def uncut(self, cut_buffer: tuple[str, ...], dim: Dim) -> None:
        self._uncut(cut_buffer, dim)

    @edit_action('uncut selection', final=True)
    @clear_selection
    def uncut_selection(
            self,
            cut_buffer: tuple[str, ...], dim: Dim,
    ) -> None:
        self._uncut(cut_buffer, dim)
        self.buf.up(dim)
        self.buf.x = len(self.buf[self.buf.y])
        self.buf[self.buf.y] += self.buf.pop(self.buf.y + 1)
        self.buf.restore_eof_invariant()

    def _sort(self, dim: Dim, s_y: int, e_y: int, reverse: bool) -> None:
        # self.buf intentionally does not support slicing so we use islice
        lines = sorted(itertools.islice(self.buf, s_y, e_y), reverse=reverse)
        for i, line in zip(range(s_y, e_y), lines):
            self.buf[i] = line

        self.buf.y = s_y
        self.buf.x = 0
        self.buf.scroll_screen_if_needed(dim)

    def _selection_lines(self) -> tuple[int, int]:
        (s_y, _), (e_y, _) = self.selection.get()
        e_y = min(e_y + 1, len(self.buf) - 1)
        if self.buf[e_y - 1] == '':
            e_y -= 1
        return s_y, e_y

    @edit_action('sort', final=True)
    def sort(self, dim: Dim, reverse: bool = False) -> None:
        self._sort(dim, 0, len(self.buf) - 1, reverse=reverse)

    @edit_action('sort selection', final=True)
    @clear_selection
    def sort_selection(self, dim: Dim, reverse: bool = False) -> None:
        s_y, e_y = self._selection_lines()
        self._sort(dim, s_y, e_y, reverse=reverse)

    def _is_commented(self, lineno: int, prefix: str) -> bool:
        return self.buf[lineno].lstrip().startswith(prefix)

    def _indent(self, lineno: int) -> str:
        ws_match = WS_RE.match(self.buf[lineno])
        assert ws_match is not None
        return ws_match[0]

    def _minimum_indent_for_selection(self) -> int:
        s_y, e_y = self._selection_lines()
        return min(len(self._indent(lineno)) for lineno in range(s_y, e_y))

    def _comment_remove(self, lineno: int, prefix: str) -> None:
        line = self.buf[lineno]
        indent = self._indent(lineno)
        ws_len = len(indent)

        if line.startswith(f'{prefix} ', ws_len):
            self.buf[lineno] = f'{indent}{line[ws_len + len(prefix) + 1:]}'
        elif line.startswith(prefix, ws_len):
            self.buf[lineno] = f'{indent}{line[ws_len + len(prefix):]}'

        if self.buf.y == lineno and self.buf.x > ws_len:
            self.buf.x -= len(line) - len(self.buf[lineno])

    def _comment_add(self, lineno: int, prefix: str, s_offset: int) -> None:
        line = self.buf[lineno]

        if not line:
            self.buf[lineno] = f'{prefix}'
        else:
            self.buf[lineno] = f'{line[:s_offset]}{prefix} {line[s_offset:]}'

        if lineno == self.buf.y and self.buf.x > s_offset:
            self.buf.x += len(self.buf[lineno]) - len(line)

    @edit_action('comment', final=True)
    def toggle_comment(self, prefix: str) -> None:
        if self._is_commented(self.buf.y, prefix):
            self._comment_remove(self.buf.y, prefix)
        else:
            ws_len = len(self._indent(self.buf.y))
            self._comment_add(self.buf.y, prefix, ws_len)

    @edit_action('comment selection', final=True)
    @clear_selection
    def toggle_comment_selection(self, prefix: str) -> None:
        s_y, e_y = self._selection_lines()
        commented = self._is_commented(s_y, prefix)
        minimum_indent = self._minimum_indent_for_selection()
        for lineno in range(s_y, e_y):
            if commented:
                self._comment_remove(lineno, prefix)
            else:
                self._comment_add(lineno, prefix, minimum_indent)

    def reload(self, status: Status, dim: Dim) -> None:
        assert self.filename is not None
        try:
            lines, nl, mixed, sha256 = _load_file(self.filename)
        except OpenError as e:
            status.update(f'reload: {e}')
            return

        self.selection.clear()
        self.nl, self.sha256 = nl, sha256
        with self.edit_action_context('reload', final=True):
            self.buf.replace_lines(lines)
            self.buf.fixup_position(dim)

        if mixed:
            status.update(
                f'reloaded! (mixed newlines will be converted to {self.nl!r})',
            )
        else:
            self.modified = False
            self.reset_modified_state()
            status.update('reloaded!')

    DISPATCH = {
        # movement
        b'KEY_UP': up,
        b'KEY_DOWN': down,
        b'KEY_RIGHT': right,
        b'KEY_LEFT': left,
        b'KEY_HOME': home,
        b'^A': home,
        b'KEY_END': end,
        b'^E': end,
        b'KEY_PPAGE': page_up,
        b'^Y': page_up,
        b'KEY_NPAGE': page_down,
        b'^V': page_down,
        b'kUP5': ctrl_up,
        b'kDN5': ctrl_down,
        b'kRIT5': ctrl_right,
        b'kLFT5': ctrl_left,
        b'kHOM5': ctrl_home,
        b'kEND5': ctrl_end,
        b'kUP3': alt_up,
        b'kDN3': alt_down,
        # editing
        b'KEY_BACKSPACE': backspace,
        b'KEY_DC': delete,
        b'^M': enter,
        b'^I': tab,
        b'KEY_BTAB': shift_tab,
        # selection (shift + movement)
        b'KEY_SR': keep_selection(up),
        b'KEY_SF': keep_selection(down),
        b'KEY_SLEFT': keep_selection(left),
        b'KEY_SRIGHT': keep_selection(right),
        b'KEY_SHOME': keep_selection(home),
        b'KEY_SEND': keep_selection(end),
        b'KEY_SPREVIOUS': keep_selection(page_up),
        b'KEY_SNEXT': keep_selection(page_down),
        b'kRIT6': keep_selection(ctrl_right),
        b'kLFT6': keep_selection(ctrl_left),
        b'kHOM6': keep_selection(ctrl_home),
        b'kEND6': keep_selection(ctrl_end),
        b'kUP4': keep_selection(alt_up),
        b'kDN4': keep_selection(alt_down),
    }

    @edit_action('text', final=False)
    @clear_selection
    def c(self, wch: str, dim: Dim) -> None:
        s = self.buf[self.buf.y]
        self.buf[self.buf.y] = s[:self.buf.x] + wch + s[self.buf.x:]
        self.buf.x += len(wch)
        self.buf.restore_eof_invariant()

    def finalize_previous_action(self) -> None:
        assert not self._in_edit_action, 'nested edit/movement'
        self.selection.clear()
        if self.undo_stack:
            self.undo_stack[-1].final = True

    def _continue_last_action(self, name: str) -> bool:
        return (
            bool(self.undo_stack) and
            self.undo_stack[-1].name == name and
            not self.undo_stack[-1].final
        )

    @contextlib.contextmanager
    def edit_action_context(
            self, name: str,
            *,
            final: bool,
    ) -> Generator[None]:
        continue_last = self._continue_last_action(name)
        if not continue_last and self.undo_stack:
            self.undo_stack[-1].final = True

        before_x, before_line = self.buf.x, self.buf.y
        before_modified = self.modified
        assert not self._in_edit_action, f'recursive action? {name}'
        self._in_edit_action = True
        try:
            with self.buf.record() as modifications:
                yield
        finally:
            self._in_edit_action = False
            self.redo_stack.clear()
            if continue_last:
                self.undo_stack[-1].end_x = self.buf.x
                self.undo_stack[-1].end_y = self.buf.y
                self.undo_stack[-1].modifications.extend(modifications)
            elif modifications:
                self.modified = True
                action = Action(
                    name=name, modifications=modifications,
                    start_x=before_x, start_y=before_line,
                    start_modified=before_modified,
                    end_x=self.buf.x, end_y=self.buf.y,
                    end_modified=True,
                    final=final,
                )
                self.undo_stack.append(action)

    @contextlib.contextmanager
    def select(self) -> Generator[None]:
        if self.selection.start is None:
            start = (self.buf.y, self.buf.x)
        else:
            start = self.selection.start
        try:
            yield
        finally:
            self.selection.set(*start, self.buf.y, self.buf.x)

    # positioning

    def move_cursor(
            self,
            stdscr: curses._CursesWindow,
            dim: Dim,
    ) -> None:
        stdscr.move(*self.buf.cursor_position(dim))

    def draw(self, stdscr: curses._CursesWindow, dim: Dim) -> None:
        to_display = min(self.buf.displayable_count, dim.height)

        for file_hl in self._file_hls:
            file_hl.highlight_until(self.buf, self.buf.file_y + to_display)

        for i in range(to_display):
            draw_y = i + dim.y
            l_y = self.buf.file_y + i
            stdscr.insstr(draw_y, 0, self.buf.rendered_line(l_y, dim))

            l_x = self.buf.line_x(dim) if l_y == self.buf.y else 0
            l_x_max = l_x + dim.width
            for file_hl in self._file_hls:
                for region in file_hl.regions[l_y]:
                    l_positions = self.buf.line_positions(l_y)
                    r_x = l_positions[region.x]
                    # the selection highlight intentionally extends one past
                    # the end of the line, which won't have a position
                    if region.end == len(l_positions):
                        r_end = l_positions[-1] + 1
                    else:
                        r_end = l_positions[region.end]

                    if r_x >= l_x_max:
                        break
                    elif r_end <= l_x:
                        continue

                    if l_x and r_x <= l_x:
                        if file_hl.include_edge:
                            h_s_x = 0
                        else:
                            h_s_x = 1
                    else:
                        h_s_x = r_x - l_x

                    if r_end >= l_x_max and l_x_max < l_positions[-1]:
                        if file_hl.include_edge:
                            h_e_x = dim.width
                        else:
                            h_e_x = dim.width - 1
                    else:
                        h_e_x = r_end - l_x

                    stdscr.chgat(draw_y, h_s_x, h_e_x - h_s_x, region.attr)

        for i in range(to_display, dim.height):
            stdscr.move(i + dim.y, 0)
            stdscr.clrtoeol()
