import dataclasses
import os
from typing import (
    List,
    Optional,
    Callable,
    Counter,
    TYPE_CHECKING,
    Mapping,
    Sequence,
    cast,
)

from debputy.commands.debputy_cmd.output import OutputStylingBase
from debputy.filesystem_scan import VirtualPathBase
from debputy.lsp.diagnostics import LintSeverity
from debputy.lsp.vendoring._deb822_repro import Deb822FileElement, parse_deb822_file
from debputy.packages import SourcePackage, BinaryPackage
from debputy.plugin.api.feature_set import PluginProvidedFeatureSet
from debputy.util import _DEFAULT_LOGGER, _warn
from lsprotocol.types import Position, Range, Diagnostic, DiagnosticSeverity, TextEdit

if TYPE_CHECKING:
    from debputy.lsp.text_util import LintCapablePositionCodec
    from debputy.lsp.style_prefs import (
        StylePreferenceTable,
        EffectivePreference,
    )


LinterImpl = Callable[["LintState"], Optional[List[Diagnostic]]]
FormatterImpl = Callable[["LintState"], Optional[Sequence[TextEdit]]]


class LintState:

    @property
    def plugin_feature_set(self) -> PluginProvidedFeatureSet:
        raise NotImplementedError

    @property
    def doc_uri(self) -> str:
        raise NotImplementedError

    @property
    def source_root(self) -> Optional[VirtualPathBase]:
        raise NotImplementedError

    @property
    def debian_dir(self) -> Optional[VirtualPathBase]:
        raise NotImplementedError

    @property
    def path(self) -> str:
        raise NotImplementedError

    @property
    def content(self) -> str:
        raise NotImplementedError

    @property
    def lines(self) -> List[str]:
        raise NotImplementedError

    @property
    def position_codec(self) -> "LintCapablePositionCodec":
        raise NotImplementedError

    @property
    def parsed_deb822_file_content(self) -> Optional[Deb822FileElement]:
        raise NotImplementedError

    @property
    def source_package(self) -> Optional[SourcePackage]:
        raise NotImplementedError

    @property
    def binary_packages(self) -> Optional[Mapping[str, BinaryPackage]]:
        raise NotImplementedError

    @property
    def style_preference_table(self) -> "StylePreferenceTable":
        raise NotImplementedError

    @property
    def effective_preference(self) -> Optional["EffectivePreference"]:
        raise NotImplementedError


@dataclasses.dataclass(slots=True)
class LintStateImpl(LintState):
    plugin_feature_set: PluginProvidedFeatureSet = dataclasses.field(repr=False)
    style_preference_table: "StylePreferenceTable" = dataclasses.field(repr=False)
    source_root: Optional[VirtualPathBase]
    debian_dir: Optional[VirtualPathBase]
    path: str
    content: str
    lines: List[str]
    source_package: Optional[SourcePackage] = None
    binary_packages: Optional[Mapping[str, BinaryPackage]] = None
    effective_preference: Optional["EffectivePreference"] = None
    _parsed_cache: Optional[Deb822FileElement] = None

    @property
    def doc_uri(self) -> str:
        path = self.path
        abs_path = os.path.join(os.path.curdir, path)
        return f"file://{abs_path}"

    @property
    def position_codec(self) -> "LintCapablePositionCodec":
        return LINTER_POSITION_CODEC

    @property
    def parsed_deb822_file_content(self) -> Optional[Deb822FileElement]:
        cache = self._parsed_cache
        if cache is None:
            cache = parse_deb822_file(
                self.lines,
                accept_files_with_error_tokens=True,
                accept_files_with_duplicated_fields=True,
            )
            self._parsed_cache = cache
        return cache


@dataclasses.dataclass(slots=True)
class LintReport:
    diagnostics_count: Counter[DiagnosticSeverity] = dataclasses.field(
        default_factory=Counter
    )
    diagnostics_without_severity: int = 0
    diagnostic_errors: int = 0
    fixed: int = 0
    fixable: int = 0


class LinterPositionCodec:

    def client_num_units(self, chars: str):
        return len(chars)

    def position_from_client_units(
        self, lines: List[str], position: Position
    ) -> Position:

        if len(lines) == 0:
            return Position(0, 0)
        if position.line >= len(lines):
            return Position(len(lines) - 1, self.client_num_units(lines[-1]))
        return position

    def position_to_client_units(
        self, _lines: List[str], position: Position
    ) -> Position:
        return position

    def range_from_client_units(self, _lines: List[str], range: Range) -> Range:
        return range

    def range_to_client_units(self, _lines: List[str], range: Range) -> Range:
        return range


LINTER_POSITION_CODEC = LinterPositionCodec()


_SEVERITY2TAG = {
    DiagnosticSeverity.Error: lambda fo, lint_tag=None: fo.colored(
        lint_tag if lint_tag else "error",
        fg="red",
        bg="black",
        style="bold",
    ),
    DiagnosticSeverity.Warning: lambda fo, lint_tag=None: fo.colored(
        lint_tag if lint_tag else "warning",
        fg="yellow",
        bg="black",
        style="bold",
    ),
    DiagnosticSeverity.Information: lambda fo, lint_tag=None: fo.colored(
        lint_tag if lint_tag else "informational",
        fg="blue",
        bg="black",
        style="bold",
    ),
    DiagnosticSeverity.Hint: lambda fo, lint_tag=None: fo.colored(
        lint_tag if lint_tag else "pedantic",
        fg="green",
        bg="black",
        style="bold",
    ),
}


def _lines_to_print(range_: Range) -> int:
    count = range_.end.line - range_.start.line
    if range_.end.character > 0:
        count += 1
    return count


def _highlight_range(
    fo: OutputStylingBase, line: str, line_no: int, range_: Range
) -> str:
    line_wo_nl = line.rstrip("\r\n")
    start_pos = 0
    prefix = ""
    suffix = ""
    if line_no == range_.start.line:
        start_pos = range_.start.character
        prefix = line_wo_nl[0:start_pos]
    if line_no == range_.end.line:
        end_pos = range_.end.character
        suffix = line_wo_nl[end_pos:]
    else:
        end_pos = len(line_wo_nl)

    marked_part = fo.colored(line_wo_nl[start_pos:end_pos], fg="red", style="bold")

    return prefix + marked_part + suffix


def report_diagnostic(
    fo: OutputStylingBase,
    filename: str,
    diagnostic: Diagnostic,
    lines: List[str],
    auto_fixable: bool,
    auto_fixed: bool,
    lint_report: LintReport,
) -> None:
    logger = _DEFAULT_LOGGER
    assert logger is not None
    severity = diagnostic.severity
    missing_severity = False
    if severity is None:
        severity = DiagnosticSeverity.Warning
        missing_severity = True
    if not auto_fixed:
        tag_unresolved = _SEVERITY2TAG.get(severity)
        lint_tag: Optional[LintSeverity] = None
        if isinstance(diagnostic.data, dict):
            lint_tag = cast("LintSeverity", diagnostic.data.get("lint_severity"))
        if tag_unresolved is None:
            tag_unresolved = _SEVERITY2TAG[DiagnosticSeverity.Warning]
            lint_report.diagnostics_without_severity += 1
        else:
            lint_report.diagnostics_count[severity] += 1
        tag = tag_unresolved(fo, lint_tag)
    else:
        tag = fo.colored(
            "auto-fixing",
            fg="magenta",
            bg="black",
            style="bold",
        )
    start_line = diagnostic.range.start.line
    start_position = diagnostic.range.start.character
    end_line = diagnostic.range.end.line
    end_position = diagnostic.range.end.character
    has_fixit = ""
    line_no_width = len(str(len(lines)))
    if not auto_fixed and auto_fixable:
        has_fixit = " [Correctable via --auto-fix]"
        lint_report.fixable += 1
    print(
        f"{tag}: File: {filename}:{start_line+1}:{start_position}:{end_line+1}:{end_position}: {diagnostic.message}{has_fixit}",
    )
    if missing_severity:
        _warn(
            "  This warning did not have an explicit severity; Used Warning as a fallback!"
        )
    if auto_fixed:
        # If it is fixed, there is no reason to show additional context.
        lint_report.fixed += 1
        return
    if _is_file_level_diagnostic(
        lines, start_line, start_position, end_line, end_position
    ):
        print(f"    File-level diagnostic")
        return
    lines_to_print = _lines_to_print(diagnostic.range)
    if end_line > len(lines) or start_line < 0:
        lint_report.diagnostic_errors += 1
        _warn(
            "Bug in the underlying linter: The line numbers of the warning does not fit in the file..."
        )
        return
    if lines_to_print == 1:
        line = _highlight_range(fo, lines[start_line], start_line, diagnostic.range)
        print(f"    {start_line+1:{line_no_width}}: {line}")
    else:
        for line_no in range(start_line, end_line):
            line = _highlight_range(fo, lines[line_no], line_no, diagnostic.range)
            print(f"    {line_no+1:{line_no_width}}: {line}")


def _is_file_level_diagnostic(
    lines: List[str],
    start_line: int,
    start_position: int,
    end_line: int,
    end_position: int,
) -> bool:
    if start_line != 0 or start_position != 0:
        return False
    line_count = len(lines)
    if end_line + 1 == line_count and end_position == 0:
        return True
    return end_line == line_count and line_count and end_position == len(lines[-1])
