import re
from typing import (
    Union,
    Sequence,
    Tuple,
    Optional,
    List,
    Dict,
)

from debputy.linting.lint_util import LintState
from debputy.lsp.debputy_ls import DebputyLanguageServer
from debputy.lsp.lsp_debian_control_reference_data import (
    Deb822KnownField,
    Dep5FileMetadata,
    StanzaMetadata,
)
from debputy.lsp.lsp_features import (
    lint_diagnostics,
    lsp_completer,
    lsp_hover,
    lsp_standard_handler,
    lsp_folding_ranges,
    lsp_semantic_tokens_full,
    lsp_will_save_wait_until,
    lsp_format_document,
    SecondaryLanguage,
    LanguageDispatchRule,
)
from debputy.lsp.lsp_generic_deb822 import (
    deb822_completer,
    deb822_hover,
    deb822_folding_ranges,
    deb822_semantic_tokens_full,
    deb822_token_iter,
    deb822_format_file,
)
from debputy.lsp.quickfixes import (
    propose_correct_text_quick_fix,
)
from debputy.lsp.spellchecking import default_spellchecker
from debputy.lsp.vendoring._deb822_repro import (
    Deb822FileElement,
    Deb822ParagraphElement,
)
from debputy.lsp.vendoring._deb822_repro.parsing import (
    Deb822KeyValuePairElement,
)
from debputy.lsprotocol.types import (
    Range,
    Diagnostic,
    CompletionItem,
    CompletionList,
    CompletionParams,
    DiagnosticRelatedInformation,
    Location,
    HoverParams,
    Hover,
    TEXT_DOCUMENT_CODE_ACTION,
    SemanticTokens,
    SemanticTokensParams,
    FoldingRangeParams,
    FoldingRange,
    WillSaveTextDocumentParams,
    TextEdit,
    DocumentFormattingParams,
)
from debputy.util import detect_possible_typo

try:
    from debputy.lsp.vendoring._deb822_repro.locatable import (
        Position as TEPosition,
        Range as TERange,
    )

    from pygls.server import LanguageServer
    from pygls.workspace import TextDocument
except ImportError:
    pass


_CONTAINS_SPACE_OR_COLON = re.compile(r"[\s:]")

_DISPATCH_RULE = LanguageDispatchRule.new_rule(
    "debian/copyright",
    "debian/copyright",
    [
        # emacs's name
        SecondaryLanguage("debian-copyright"),
        # vim's name
        SecondaryLanguage("debcopyright"),
    ],
)

_DEP5_FILE_METADATA = Dep5FileMetadata()

lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_CODE_ACTION)


@lsp_hover(_DISPATCH_RULE)
def _debian_copyright_hover(
    ls: "DebputyLanguageServer",
    params: HoverParams,
) -> Optional[Hover]:
    return deb822_hover(ls, params, _DEP5_FILE_METADATA)


@lsp_completer(_DISPATCH_RULE)
def _debian_copyright_completions(
    ls: "DebputyLanguageServer",
    params: CompletionParams,
) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]:
    return deb822_completer(ls, params, _DEP5_FILE_METADATA)


@lsp_folding_ranges(_DISPATCH_RULE)
def _debian_copyright_folding_ranges(
    ls: "DebputyLanguageServer",
    params: FoldingRangeParams,
) -> Optional[Sequence[FoldingRange]]:
    return deb822_folding_ranges(ls, params, _DEP5_FILE_METADATA)


def _diagnostics_for_stanza(
    deb822_file: Deb822FileElement,
    stanza: Deb822ParagraphElement,
    stanza_position: "TEPosition",
    stanza_metadata: StanzaMetadata[Deb822KnownField],
    other_stanza_metadata: StanzaMetadata[Deb822KnownField],
    is_files_or_license_paragraph: bool,
    doc_reference: str,
    lint_state: LintState,
) -> None:
    _, representation_field_range = stanza_metadata.stanza_representation(
        stanza,
        stanza_position,
    )
    known_fields = stanza_metadata.stanza_fields
    other_known_fields = other_stanza_metadata.stanza_fields
    stanza_metadata.omitted_field_diagnostics(
        lint_state,
        deb822_file,
        stanza,
        stanza_position,
        representation_field_range=representation_field_range,
    )
    for known_field in known_fields.values():
        if known_field.name in stanza:
            continue

        known_field.field_omitted_diagnostics(
            deb822_file,
            representation_field_range,
            stanza,
            stanza_position,
            None,
            lint_state,
        )

    seen_fields: Dict[str, Tuple[str, str, Range, List[Range]]] = {}

    for kvpair in stanza.iter_parts_of_type(Deb822KeyValuePairElement):
        field_name_token = kvpair.field_token
        field_name = field_name_token.text
        field_name_lc = field_name.lower()
        normalized_field_name_lc = stanza_metadata.normalize_field_name(field_name_lc)
        known_field = known_fields.get(normalized_field_name_lc)
        field_value = stanza[field_name]
        kvpair_range_te = kvpair.range_in_parent().relative_to(stanza_position)
        field_range = kvpair.field_token.range_in_parent().relative_to(
            kvpair_range_te.start_pos
        )
        field_position_te = field_range.start_pos
        field_name_typo_detected = False
        existing_field_range = seen_fields.get(normalized_field_name_lc)
        if existing_field_range is not None:
            existing_field_range[3].append(field_range)
        else:
            normalized_field_name = stanza_metadata.normalize_field_name(field_name)
            seen_fields[field_name_lc] = (
                field_name,
                normalized_field_name,
                field_range,
                [],
            )

        if known_field is None:
            candidates = detect_possible_typo(normalized_field_name_lc, known_fields)
            if candidates:
                known_field = known_fields[candidates[0]]

                field_range = TERange.from_position_and_size(
                    field_position_te, kvpair.field_token.size()
                )
                field_name_typo_detected = True
                lint_state.emit_diagnostic(
                    field_range,
                    f'The "{field_name}" looks like a typo of "{known_field.name}".',
                    "warning",
                    "debputy",
                    quickfixes=[
                        propose_correct_text_quick_fix(known_fields[m].name)
                        for m in candidates
                    ],
                )
        if known_field is None:
            known_else_where = other_known_fields.get(normalized_field_name_lc)
            if known_else_where is not None:
                intended_usage = (
                    "Header" if is_files_or_license_paragraph else "Files/License"
                )
                lint_state.emit_diagnostic(
                    field_range,
                    f'The {field_name} is defined for use in the "{intended_usage}" stanza.'
                    f" Please move it to the right place or remove it",
                    "error",
                    "debputy",
                )
            continue

        if field_value.strip() == "":
            lint_state.emit_diagnostic(
                field_range,
                f"The {field_name} has no value. Either provide a value or remove it.",
                "error",
                "debputy",
            )
            continue
        known_field.field_diagnostics(
            deb822_file,
            kvpair,
            stanza,
            stanza_position,
            kvpair_range_te,
            lint_state,
            field_name_typo_reported=field_name_typo_detected,
        )
    for (
        field_name,
        normalized_field_name,
        field_range,
        duplicates,
    ) in seen_fields.values():
        if not duplicates:
            continue
        related_information = [
            DiagnosticRelatedInformation(
                location=Location(doc_reference, field_range),
                message=f"First definition of {field_name}",
            )
        ]
        related_information.extend(
            DiagnosticRelatedInformation(
                location=Location(doc_reference, r),
                message=f"Duplicate of {field_name}",
            )
            for r in duplicates
        )
        for dup_range in duplicates:
            lint_state.emit_diagnostic(
                dup_range,
                f"The {normalized_field_name} field name was used multiple times in this stanza.",
                "error",
                "debputy",
                related_information=related_information,
            )


def _scan_for_syntax_errors_and_token_level_diagnostics(
    deb822_file: Deb822FileElement,
    lint_state: LintState,
) -> int:
    first_error = len(lint_state.lines) + 1
    spell_checker = default_spellchecker()
    for (
        token,
        start_line,
        start_offset,
        end_line,
        end_offset,
    ) in deb822_token_iter(deb822_file.iter_tokens()):
        if token.is_error:
            first_error = min(first_error, start_line)
            start_pos = TEPosition(
                start_line,
                start_offset,
            )
            end_pos = TEPosition(
                end_line,
                end_offset,
            )
            token_range = TERange.between(start_pos, end_pos)
            lint_state.emit_diagnostic(
                token_range,
                "Syntax error",
                "error",
                "debputy",
            )
        elif token.is_comment:
            for word, col_pos, end_col_pos in spell_checker.iter_words(token.text):
                corrections = spell_checker.provide_corrections_for(word)
                if not corrections:
                    continue
                start_pos = TEPosition(
                    start_line,
                    col_pos,
                )
                end_pos = TEPosition(
                    start_line,
                    end_col_pos,
                )
                word_range = TERange.between(start_pos, end_pos)
                lint_state.emit_diagnostic(
                    word_range,
                    f'Spelling "{word}"',
                    "spelling",
                    "debputy",
                    quickfixes=[propose_correct_text_quick_fix(c) for c in corrections],
                )
    return first_error


def _looks_like_a_dep5_file(
    deb822_file: Deb822FileElement,
    stanzas: List[Deb822ParagraphElement],
) -> bool:
    if not stanzas or "Format" not in stanzas[0]:
        # No parseable stanzas or the first one did not have a Format, which is necessary.
        return False

    for part in deb822_file.iter_parts():
        if part.is_error:
            # Error first, then it might just be a "Format:" in the middle of a free-text file.
            return False
        if isinstance(part, Deb822ParagraphElement):
            break
    return True


@lint_diagnostics(_DISPATCH_RULE)
def _lint_debian_copyright(lint_state: LintState) -> None:
    doc_reference = lint_state.doc_uri
    deb822_file = lint_state.parsed_deb822_file_content
    stanzas = list(deb822_file)

    if not _looks_like_a_dep5_file(deb822_file, stanzas):
        return

    first_error = _scan_for_syntax_errors_and_token_level_diagnostics(
        deb822_file,
        lint_state,
    )

    is_dep5 = False
    header_stanza, files_stanza, _ = _DEP5_FILE_METADATA.stanza_types()

    for paragraph_no, paragraph in enumerate(stanzas, start=1):
        paragraph_pos = paragraph.position_in_file()
        if paragraph_pos.line_position >= first_error:
            break
        is_files_or_license_paragraph = paragraph_no != 1
        if is_files_or_license_paragraph:
            stanza_metadata = _DEP5_FILE_METADATA.classify_stanza(
                paragraph,
                paragraph_no,
            )
            other_stanza_metadata = header_stanza
        elif "Format" in paragraph:
            is_dep5 = True
            stanza_metadata = header_stanza
            other_stanza_metadata = files_stanza
        else:
            break
        _diagnostics_for_stanza(
            deb822_file,
            paragraph,
            paragraph_pos,
            stanza_metadata,
            other_stanza_metadata,
            is_files_or_license_paragraph,
            doc_reference,
            lint_state,
        )
    # FIXME
    if not is_dep5:
        return None


@lsp_will_save_wait_until(_DISPATCH_RULE)
def _debian_copyright_on_save_formatting(
    ls: "DebputyLanguageServer",
    params: WillSaveTextDocumentParams,
) -> Optional[Sequence[TextEdit]]:
    doc = ls.workspace.get_text_document(params.text_document.uri)
    lint_state = ls.lint_state(doc)
    return deb822_format_file(lint_state, _DEP5_FILE_METADATA)


def _reformat_debian_copyright(
    lint_state: LintState,
) -> Optional[Sequence[TextEdit]]:
    return deb822_format_file(lint_state, _DEP5_FILE_METADATA)


@lsp_format_document(_DISPATCH_RULE)
def _debian_copyright_on_save_formatting(
    ls: "DebputyLanguageServer",
    params: DocumentFormattingParams,
) -> Optional[Sequence[TextEdit]]:
    doc = ls.workspace.get_text_document(params.text_document.uri)
    lint_state = ls.lint_state(doc)
    return deb822_format_file(lint_state, _DEP5_FILE_METADATA)


@lsp_semantic_tokens_full(_DISPATCH_RULE)
def _debian_copyright_semantic_tokens_full(
    ls: "DebputyLanguageServer",
    request: SemanticTokensParams,
) -> Optional[SemanticTokens]:
    return deb822_semantic_tokens_full(
        ls,
        request,
        _DEP5_FILE_METADATA,
    )
