import collections
import contextlib
import dataclasses
import datetime
import os
import time
from collections import defaultdict, Counter
from enum import IntEnum
from functools import lru_cache
from typing import (
    List,
    Optional,
    Callable,
    TYPE_CHECKING,
    Mapping,
    Sequence,
    cast,
    FrozenSet,
    Self,
    Set,
    Dict,
    Iterable,
)

import debputy.l10n as l10n
from debputy.commands.debputy_cmd.output import OutputStylingBase
from debputy.dh.dh_assistant import (
    extract_dh_addons_from_control,
    DhSequencerData,
    parse_drules_for_addons,
)
from debputy.exceptions import PureVirtualPathError
from debputy.filesystem_scan import VirtualPathBase
from debputy.integration_detection import determine_debputy_integration_mode
from debputy.l10n import Translations
from debputy.lsp.diagnostics import (
    LintSeverity,
    LINT_SEVERITY2LSP_SEVERITY,
    DiagnosticData,
    NATIVELY_LSP_SUPPORTED_SEVERITIES,
)
from debputy.lsp.spellchecking import Spellchecker, default_spellchecker
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.plugin.api.spec import DebputyIntegrationMode
from debputy.util import _warn

if TYPE_CHECKING:
    import lsprotocol.types as types
    from debputy.lsp.text_util import LintCapablePositionCodec
    from debputy.lsp.maint_prefs import (
        MaintainerPreferenceTable,
        EffectiveFormattingPreference,
    )
    from debputy.lsp.vendoring._deb822_repro.locatable import (
        Range as TERange,
        Position as TEPosition,
    )
else:
    import debputy.lsprotocol.types as types


LinterImpl = Callable[["LintState"], None]
FormatterImpl = Callable[["LintState"], Optional[Sequence[types.TextEdit]]]


# If you add a new one to this set, remember to mention it in the docs of `LintState.emit_diagnostic`
DIAG_SOURCE_WITHOUT_SECTIONS: FrozenSet[str] = frozenset(
    {
        "debputy",
        "dpkg",
    }
)

# If you add a new one to this set, remember to mention it in the docs of `LintState.emit_diagnostic`
DIAG_SOURCE_WITH_SECTIONS: FrozenSet[str] = frozenset(
    {
        "Policy",
        "DevRef",
    }
)


def te_position_to_lsp(te_position: "TEPosition") -> types.Position:
    return types.Position(
        te_position.line_position,
        te_position.cursor_position,
    )


def te_range_to_lsp(te_range: "TERange") -> types.Range:
    return types.Range(
        te_position_to_lsp(te_range.start_pos),
        te_position_to_lsp(te_range.end_pos),
    )


@lru_cache
def _check_diagnostic_source(source: str) -> None:
    if source in DIAG_SOURCE_WITHOUT_SECTIONS:
        return
    parts = source.split(" ")
    s = parts[0]
    if s not in DIAG_SOURCE_WITH_SECTIONS:
        raise ValueError(
            f'Unknown diagnostic source: "{source}". If you are adding a new source, update lint_util.py'
        )
    if len(parts) != 2:
        raise ValueError(
            f'The diagnostic source "{source}" should have exactly one section associated with it.'
        )


@dataclasses.dataclass(slots=True)
class DebputyMetadata:
    debputy_integration_mode: Optional[DebputyIntegrationMode]

    @classmethod
    def from_data(
        cls,
        source_fields: Mapping[str, str],
        dh_sequencer_data: DhSequencerData,
    ) -> Self:
        integration_mode = determine_debputy_integration_mode(
            source_fields,
            dh_sequencer_data.sequences,
        )
        return cls(integration_mode)


class LintState:

    @property
    def plugin_feature_set(self) -> PluginProvidedFeatureSet:
        """The plugin features known to the running instance of `debputy`

        This is mostly only relevant when working with `debputy.manifest
        """
        raise NotImplementedError

    @property
    def doc_uri(self) -> str:
        """The URI for the document being scanned.

        This can be useful for providing related location ranges.
        """
        raise NotImplementedError

    @property
    def source_root(self) -> Optional[VirtualPathBase]:
        """The path to the unpacked source root directory if available

        This is the directory that would contain the `debian/` directory. Note, if you need the
        `debian/` directory, please use `debian_dir` instead. There may be cases where the source
        root is unavailable but the `debian/` directory is not.
        """
        raise NotImplementedError

    @property
    def debian_dir(self) -> Optional[VirtualPathBase]:
        """The path to the `debian/` directory if available"""
        raise NotImplementedError

    @property
    def path(self) -> str:
        """The filename or path of the file being scanned.

        Note this path may or may not be accessible to the running `debputy` instance. Nor is it guaranteed
        that the file on the file system (even if accessible) has correct contents. When doing diagnostics
        for an editor, the editor often requests diagnostics for unsaved changes.
        """
        raise NotImplementedError

    @property
    def content(self) -> str:
        """The full contents of the file being checked"""
        raise NotImplementedError

    @property
    def lines(self) -> List[str]:
        # FIXME: Replace with `Sequence[str]` if possible
        """The contents of the file being checked as a list of lines

        Do **not** change the contents of this list as it may be cached.
        """
        raise NotImplementedError

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

    @property
    def parsed_deb822_file_content(self) -> Optional[Deb822FileElement]:
        """The contents of the file being checked as a parsed deb822 file

        This can sometimes use a cached version of the parsed file and is therefore preferable to
        parsing the file manually from `content` or `lines`.

        Do **not** change the contents of this as it may be cached.
        """
        raise NotImplementedError

    @property
    def source_package(self) -> Optional[SourcePackage]:
        """The source package (source stanza of `debian/control`).

        Will be `None` if the `debian/control` file cannot be parsed as a deb822 file, or if the
        source stanza is not available.
        """
        raise NotImplementedError

    @property
    def binary_packages(self) -> Optional[Mapping[str, BinaryPackage]]:
        """The binary packages (the Package stanzas of `debian/control`).

        Will be `None` if the `debian/control` file cannot be parsed, or if no Package stanzas are
        available.
        """
        raise NotImplementedError

    @property
    def maint_preference_table(self) -> "MaintainerPreferenceTable":
        # TODO: Remove (unused)
        raise NotImplementedError

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

    @property
    def debputy_metadata(self) -> DebputyMetadata:
        """Information about `debputy` usage such as which integration mode is being used."""
        src_pkg = self.source_package
        src_fields = src_pkg.fields if src_pkg else {}
        return DebputyMetadata.from_data(
            src_fields,
            self.dh_sequencer_data,
        )

    @property
    def dh_sequencer_data(self) -> DhSequencerData:
        """Information about the use of the `dh` sequencer

        This includes which sequences are being used and whether the `dh` sequencer is used at all.
        """
        raise NotImplementedError

    def spellchecker(self) -> "Spellchecker":
        checker = default_spellchecker()
        ignored_words = set()
        source_package = self.source_package
        binary_packages = self.binary_packages
        if source_package and source_package.fields.get("Source") is not None:
            ignored_words.add(source_package.fields.get("Source"))
        if binary_packages:
            ignored_words.update(binary_packages)
        return checker.context_ignored_words(ignored_words)

    def translation(self, domain: str) -> Translations:
        return l10n.translation(
            domain,
        )

    def emit_diagnostic(
        self,
        text_range: "TERange",
        diagnostic_msg: str,
        severity: LintSeverity,
        authority_reference: str,
        *,
        quickfixes: Optional[List[dict]] = None,
        tags: Optional[List[types.DiagnosticTag]] = None,
        related_information: Optional[List[types.DiagnosticRelatedInformation]] = None,
        diagnostic_applies_to_another_file: Optional[str] = None,
        enable_non_interactive_auto_fix: bool = True,
    ) -> None:
        """Emit a diagnostic for an issue detected in the current file.

        :param text_range: The text range to highlight in the file.
        :param diagnostic_msg: The message to show to the user for this diagnostic
        :param severity: The severity to associate with the diagnostic.
        :param authority_reference: A reference to the authority / guide that this diagnostic is a violation of.

            Use:
              * "Policy 3.4.1" for Debian Policy Manual section 3.4.1
              * "DevRef 6.2.2" for the Debian Developer Reference section 6.2.2
              * "debputy" for diagnostics without a reference or where `debputy` is the authority.
                (This is also used for cases where `debputy` filters the result. Like with spellchecking
                 via hunspell, where `debputy` provides its own ignore list on top)

            If you need a new reference, feel free to add it to this list.
        :param quickfixes: If provided, this is a list of possible fixes for this problem.
          Use the quickfixes provided in `debputy.lsp.quickfixes` such as `propose_correct_text_quick_fix`.
        :param tags: TODO: Not yet specified (currently uses LSP format).
        :param related_information: TODO: Not yet specified (currently uses LSP format).
        :param enable_non_interactive_auto_fix: Allow non-interactive auto-fixing (such as via
          `debputy lint --auto-fix`) of this issue. Set to `False` if the check is likely to have false
          positives.
        :param diagnostic_applies_to_another_file: Special-case parameter for flagging invalid file names.
          Leave this one at `None`, unless you know you need it.

          It has non-obvious semantics and is primarily useful for reporting typos of filenames such as
          `debian/install`, etc.
        """
        _check_diagnostic_source(authority_reference)
        lsp_severity = LINT_SEVERITY2LSP_SEVERITY[severity]
        diag_data: DiagnosticData = {
            "enable_non_interactive_auto_fix": enable_non_interactive_auto_fix,
        }

        if severity not in NATIVELY_LSP_SUPPORTED_SEVERITIES:
            diag_data["lint_severity"] = severity
        if quickfixes:
            diag_data["quickfixes"] = quickfixes
        if diagnostic_applies_to_another_file is not None:
            diag_data["report_for_related_file"] = diagnostic_applies_to_another_file

        lsp_range_client_units = self.position_codec.range_to_client_units(
            self.lines,
            te_range_to_lsp(text_range),
        )

        diag = types.Diagnostic(
            lsp_range_client_units,
            diagnostic_msg,
            severity=lsp_severity,
            source=authority_reference,
            data=diag_data if diag_data else None,
            tags=tags,
            related_information=related_information,
        )
        self._emit_diagnostic(diag)

    def _emit_diagnostic(self, diagnostic: types.Diagnostic) -> None:
        raise NotImplementedError


@dataclasses.dataclass(slots=True)
class LintStateImpl(LintState):
    plugin_feature_set: PluginProvidedFeatureSet = dataclasses.field(repr=False)
    maint_preference_table: "MaintainerPreferenceTable" = 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["EffectiveFormattingPreference"] = None
    lint_implementation: Optional["LinterImpl"] = None
    _parsed_cache: Optional[Deb822FileElement] = None
    _dh_sequencer_cache: Optional[DhSequencerData] = None
    _diagnostics: Optional[List[types.Diagnostic]] = 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

    @property
    def dh_sequencer_data(self) -> DhSequencerData:
        dh_sequencer_cache = self._dh_sequencer_cache
        if dh_sequencer_cache is None:
            debian_dir = self.debian_dir
            dh_sequences: Set[str] = set()
            saw_dh = False
            src_pkg = self.source_package
            drules = debian_dir.get("rules") if debian_dir is not None else None
            if drules and drules.is_file:
                try:
                    with drules.open() as fd:
                        saw_dh = parse_drules_for_addons(fd, dh_sequences)
                except PureVirtualPathError:
                    pass
            if src_pkg:
                extract_dh_addons_from_control(src_pkg.fields, dh_sequences)

            dh_sequencer_cache = DhSequencerData(
                frozenset(dh_sequences),
                saw_dh,
            )
            self._dh_sequencer_cache = dh_sequencer_cache
        return dh_sequencer_cache

    def gather_diagnostics(self) -> List[types.Diagnostic]:
        if self._diagnostics is not None:
            raise RuntimeError(
                "run_diagnostics cannot be run while it is already running"
            )
        linter = self.lint_implementation
        if linter is None:
            raise TypeError(
                "run_diagnostics cannot be run:"
                " LintState was created without a lint implementation (such as for reformat-only)"
            )
        self._diagnostics = diagnostics = []

        linter(self)

        self._diagnostics = None
        return diagnostics

    def clear_cache(self) -> None:
        self._parsed_cache = None
        self._dh_sequencer_cache = None

    def _emit_diagnostic(self, diagnostic: types.Diagnostic) -> None:
        diagnostics = self._diagnostics
        if diagnostics is None:
            raise TypeError("Cannot run emit_diagnostic outside of gather_diagnostics")
        diagnostics.append(diagnostic)


class LintDiagnosticResultState(IntEnum):
    REPORTED = 1
    MANUAL_FIXABLE = 2
    AUTO_FIXABLE = 3
    FIXED = 4


@dataclasses.dataclass(slots=True, frozen=True)
class LintDiagnosticResult:
    diagnostic: types.Diagnostic
    result_state: LintDiagnosticResultState
    invalid_marker: Optional[RuntimeError]
    is_file_level_diagnostic: bool
    has_broken_range: bool
    missing_severity: bool
    discovered_in: str
    report_for_related_file: Optional[str]


class LintReport:

    def __init__(self) -> None:
        self.diagnostics_count: Counter[types.DiagnosticSeverity] = Counter()
        self.diagnostics_by_file: Mapping[str, List[LintDiagnosticResult]] = (
            defaultdict(list)
        )
        self.number_of_invalid_diagnostics: int = 0
        self.number_of_broken_diagnostics: int = 0
        self.lint_state: Optional[LintState] = None
        self.start_timestamp = datetime.datetime.now()
        self.durations: Dict[str, float] = collections.defaultdict(lambda: 0.0)
        self._timer = time.perf_counter()

    @contextlib.contextmanager
    def line_state(self, lint_state: LintState) -> Iterable[None]:
        previous = self.lint_state
        if previous is not None:
            path = previous.path
            duration = time.perf_counter() - self._timer
            self.durations[path] += duration

        self.lint_state = lint_state

        try:
            self._timer = time.perf_counter()
            yield
        finally:
            now = time.perf_counter()
            duration = now - self._timer
            self.durations[lint_state.path] += duration
            self._timer = now
            self.lint_state = previous

    def report_diagnostic(
        self,
        diagnostic: types.Diagnostic,
        *,
        result_state: LintDiagnosticResultState = LintDiagnosticResultState.REPORTED,
        in_file: Optional[str] = None,
    ) -> None:
        lint_state = self.lint_state
        assert lint_state is not None
        if in_file is None:
            in_file = lint_state.path
        discovered_in_file = in_file
        severity = diagnostic.severity
        missing_severity = False
        error_marker: Optional[RuntimeError] = None
        if severity is None:
            self.number_of_invalid_diagnostics += 1
            severity = types.DiagnosticSeverity.Warning
            diagnostic.severity = severity
            missing_severity = True

        lines = lint_state.lines
        diag_range = diagnostic.range
        start_pos = diag_range.start
        end_pos = diag_range.end
        diag_data = diagnostic.data
        if isinstance(diag_data, dict):
            report_for_related_file = diag_data.get("report_for_related_file")
            if report_for_related_file is None or not isinstance(
                report_for_related_file, str
            ):
                report_for_related_file = None
            else:
                in_file = report_for_related_file
                # Force it to exist in self.durations, since subclasses can use .items() or "foo" in self.durations.
                if in_file not in self.durations:
                    self.durations[in_file] = 0
        else:
            report_for_related_file = None
        if report_for_related_file is not None:
            is_file_level_diagnostic = True
        else:
            is_file_level_diagnostic = _is_file_level_diagnostic(
                lines,
                start_pos.line,
                start_pos.character,
                end_pos.line,
                end_pos.character,
            )
        has_broken_range = not is_file_level_diagnostic and (
            end_pos.line > len(lines) or start_pos.line < 0
        )

        if has_broken_range or missing_severity:
            error_marker = RuntimeError("Registration Marker for invalid diagnostic")

        diagnostic_result = LintDiagnosticResult(
            diagnostic,
            result_state,
            error_marker,
            is_file_level_diagnostic,
            has_broken_range,
            missing_severity,
            report_for_related_file=report_for_related_file,
            discovered_in=discovered_in_file,
        )

        self.diagnostics_by_file[in_file].append(diagnostic_result)
        self.diagnostics_count[severity] += 1
        self.process_diagnostic(in_file, lint_state, diagnostic_result)

    def process_diagnostic(
        self,
        filename: str,
        lint_state: LintState,
        diagnostic_result: LintDiagnosticResult,
    ) -> None:
        # Subclass hook
        pass

    def finish_report(self) -> None:
        # Subclass hook
        pass


_LS2DEBPUTY_SEVERITY: Mapping[types.DiagnosticSeverity, LintSeverity] = {
    types.DiagnosticSeverity.Error: "error",
    types.DiagnosticSeverity.Warning: "warning",
    types.DiagnosticSeverity.Information: "informational",
    types.DiagnosticSeverity.Hint: "pedantic",
}


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


def debputy_severity(diagnostic: types.Diagnostic) -> LintSeverity:
    lint_tag: Optional[LintSeverity] = None
    if isinstance(diagnostic.data, dict):
        lint_tag = cast("LintSeverity", diagnostic.data.get("lint_severity"))

    if lint_tag is not None:
        return lint_tag
    severity = diagnostic.severity
    if severity is None:
        return "warning"
    return _LS2DEBPUTY_SEVERITY.get(severity, "warning")


class TermLintReport(LintReport):

    def __init__(self, fo: OutputStylingBase) -> None:
        super().__init__()
        self.fo = fo

    def finish_report(self) -> None:
        # Nothing to do for now
        pass

    def process_diagnostic(
        self,
        filename: str,
        lint_state: LintState,
        diagnostic_result: LintDiagnosticResult,
    ) -> None:
        diagnostic = diagnostic_result.diagnostic
        fo = self.fo
        severity = diagnostic.severity
        assert severity is not None
        if diagnostic_result.result_state != LintDiagnosticResultState.FIXED:
            tag_unresolved = _TERM_SEVERITY2TAG[severity]
            lint_tag: Optional[LintSeverity] = debputy_severity(diagnostic)
            tag = tag_unresolved(fo, lint_tag)
        else:
            tag = fo.colored(
                "auto-fixing",
                fg="magenta",
                bg="black",
                style="bold",
            )

        if diagnostic_result.is_file_level_diagnostic:
            start_line = 0
            start_position = 0
            end_line = 0
            end_position = 0
        else:
            start_line = diagnostic.range.start.line
            start_position = diagnostic.range.start.character
            end_line = diagnostic.range.end.line
            end_position = diagnostic.range.end.character

        authority = diagnostic.source
        assert authority is not None
        diag_tags = f" [{authority}]"
        lines = lint_state.lines
        line_no_width = len(str(len(lines)))

        if diagnostic_result.result_state == LintDiagnosticResultState.AUTO_FIXABLE:
            diag_tags += "[Correctable via --auto-fix]"
        elif diagnostic_result.result_state == LintDiagnosticResultState.MANUAL_FIXABLE:
            diag_tags += "[LSP interactive quickfix]"

        code = f"[{diagnostic.code}]: " if diagnostic.code else ""
        msg = f"{code}{diagnostic.message}"

        print(
            f"{tag}: File: {filename}:{start_line+1}:{start_position}:{end_line+1}:{end_position}: {msg}{diag_tags}",
        )
        if diagnostic_result.missing_severity:
            _warn(
                "  This warning did not have an explicit severity; Used Warning as a fallback!"
            )
        if diagnostic_result.result_state == LintDiagnosticResultState.FIXED:
            # If it is fixed, there is no reason to show additional context.
            return
        if diagnostic_result.is_file_level_diagnostic:
            print("    File-level diagnostic")
            return
        if diagnostic_result.has_broken_range:
            _warn(
                "Bug in the underlying linter: The line numbers of the warning does not fit in the file..."
            )
            return
        lines_to_print = _lines_to_print(diagnostic.range)
        for line_no in range(start_line, start_line + lines_to_print):
            line = _highlight_range(fo, lines[line_no], line_no, diagnostic.range)
            print(f"    {line_no+1:{line_no_width}}: {line}")


class LinterPositionCodec:

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

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

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

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

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

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


LINTER_POSITION_CODEC = LinterPositionCodec()


def _lines_to_print(range_: types.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_: types.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 _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])
