#!/usr/bin/env python3
#
# Copyright: 2016, Maximiliano Curia <maxy@debian.org>
#
# License: ISC
#  Permission to use, copy, modify, and/or distribute this software for any
#  purpose with or without fee is hereby granted, provided that the above
#  copyright notice and this permission notice appear in all copies.
#  .
#  THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH
#  REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
#  AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT,
#  INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
#  LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
#  OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
#  PERFORMANCE OF THIS SOFTWARE.

"""Copyright-holder extraction: parse copyright notices into CopyrightHolders."""

from __future__ import annotations

import collections.abc as abc

# Local modules
from ._regex import rex
from .core_types import CopyrightHolder, YearRange
from .license_subs import (
    COPYRIGHT_INDICATOR_RE,
    COPYRIGHT_POST_IGNORE_RES,
    COPYRIGHT_PRE_IGNORE_RES,
    COPYRIGHT_PRE_INDICATOR_RE,
    CRUFT_SUBS,
    EMAIL_SUBS,
    NAME_CRUFT_SUBS,
)

# Copyright holders handling

# We are discarding parts that are shorter than MIN_PART_LEN
MIN_PART_LEN: int = 3

# Year parsing. An open-ended range end ("present"/"current", as in
# "2002-present" or "2009, present") is not a year; consume it here so it does
# not leak into the holder name. The range stays the start year.
YEAR_RE: rex.Pattern[str] = rex.compile(
    r"\s*(?:[\s:([]*)?(?P<lo>\d{2,4})[]:\s]*"
    r"(?:(?:[-~=–—]|to)[\s:[]*(?:(?P<hi>\d{1,4})|present\b|current\b)[]:\s]*)?"  # noqa: RUF001
    r"[,/)]*(?:\s*(?:present|current)\b[\s,]*)?",
    rex.IGNORECASE,
)


def _get_year(text: str, ref: int = 0) -> int | None:
    year = int(text)
    if year < 10 and ref:  # noqa: PLR2004
        # Handle: 1990-2
        fix = 0 if (ref % 10) < year else 1
        year += ((ref // 10) + fix) * 10
    if year < 50:  # noqa: PLR2004
        year += 2000
    if year < 100:  # noqa: PLR2004
        year += 1900
    if year < 1000:  # noqa: PLR2004
        return None
    return year


def _year_post_ignore(text: str, match: rex.Match[str]) -> bool:
    "Make sure we got a year"

    start = max(0, match.start("lo") - 1)
    end = min(len(text), match.end("lo") + 1)
    return bool(
        rex.match(
            r"[a-zA-Z]{0}|.{0}[a-zA-Z@]".format(match.group("lo")),
            text[start:end],
        ),
    )


def _split_by_years(
    text: str,
    years: YearRange | None = None,
) -> tuple[list[YearRange], list[str]]:
    "Split string by years found"
    years_list: list[YearRange] = [] if years is None else [years]
    parts: list[str] = [] if years is None else [""]
    last_end = 0
    for year_match in YEAR_RE.finditer(text):
        if _year_post_ignore(text, year_match):
            continue

        match_dict = year_match.groupdict()

        low = _get_year(match_dict["lo"])
        if low is None:
            continue

        high: int | None = low
        if match_dict.get("hi", None):
            high = _get_year(match_dict["hi"], low)

        part_len = year_match.start(0) - last_end

        if years is None or part_len >= MIN_PART_LEN:
            parts.append(text[last_end : year_match.start(0)])
            years = YearRange(low=low, high=high)
            years_list.append(years)
        else:
            years.add(low)
            years.add(high)

        last_end = year_match.end(0)

    parts.append(text[last_end:])
    return years_list, parts


# Email parsing
EMAIL_RE: rex.Pattern[str] = rex.compile(
    r"(?:^|[,;:\s<>/\\([])\s*(?P<email>[^]),;:\s<>/\\([]+?@[^]),;:\s<>@/\\([]+?)\s*(?:[]),;:\s/\\<>]|$)",
)


# "X, and Y" is two co-holders, not one. Split only on a comma-led "and" whose
# right side starts a capitalised name, so lowercase tails ("and affiliates"),
# already-trimmed prose (", and were"), and bare " and " (no comma) are left
# alone.
_COHOLDER_SPLIT_RE: rex.Pattern[str] = rex.compile(r",\s+and\s+(?=[A-Z])")


def _split_coholders(name: str) -> list[str]:
    return [part for part in _COHOLDER_SPLIT_RE.split(name) if part.strip()]


def _expand_coholders(pairs: list[tuple[str, str]]) -> list[tuple[str, str]]:
    """Split any emailless name that holds several "X, and Y" co-holders."""
    expanded: list[tuple[str, str]] = []
    for name, email in pairs:
        parts = [] if email else _split_coholders(name)
        if len(parts) > 1:
            expanded.extend((part, "") for part in parts)
        else:
            expanded.append((name, email))
    return expanded


def _get_holders_emails(text: str) -> list[tuple[str, str]]:
    holders_emails: list[tuple[str, str]] = []

    # De-cruft email
    for sub in EMAIL_SUBS:
        text = sub.pattern.sub(sub.repl, text)

    last_end = 0
    for email_match in EMAIL_RE.finditer(text):
        email = email_match.group("email")
        name = text[last_end : email_match.start()]
        last_end = email_match.end()
        holders_emails.append((name, email))
    if len(text) - last_end >= MIN_PART_LEN:
        holders_emails.append((text[last_end:], ""))

    return _expand_coholders(holders_emails)


# A holder name carrying code punctuation is a source fragment slurped after the
# word "copyright", not a real name (emails are already split off, so <> is code).
# Note: case is NOT a signal here - lowercase nicknames are valid holders; junk
# lines that merely look lowercase are dropped via COPYRIGHT_*_IGNORE_REGEXES.
_CODE_PUNCT_RE: rex.Pattern[str] = rex.compile(r"""[<>{}\[\]=;#"*|`\\]""")

# A copyright line that runs into prose ends the name in a dangling clause like
# ", and were [contributed by ...]". Trim a trailing comma-led clause whose verb
# marks it as a sentence fragment - lowercase only, so org tails like ", Inc",
# "and affiliates" or ", and Bar Corp" (a co-holder) are left intact.
_TRAILING_PROSE_RE: rex.Pattern[str] = rex.compile(
    r",\s+(?:and\s+|or\s+)?(?:were|was|is|are|be|been|being)\b.*$",
)


def _strip_trailing_prose(name: str) -> str:
    return _TRAILING_PROSE_RE.sub("", name).strip()


def _looks_like_code_holder(name: str) -> bool:
    return bool(_CODE_PUNCT_RE.search(name))


def _process_name_email_pairs(
    name_email_pairs: list[tuple[str, str]],
    holders: list[CopyrightHolder],
    years: YearRange | None,
) -> YearRange | None:
    years_used = False
    for name_, email in name_email_pairs:
        name = name_
        # Fixup name
        if name is None:
            name = ""
        name = name.strip(""",.;*'"@-–—[]{} \t""")  # noqa: RUF001
        for sub in NAME_CRUFT_SUBS:
            name = sub.pattern.sub(sub.repl, name)
        name = _strip_trailing_prose(name)
        if _looks_like_code_holder(name):
            name = ""
        if len(name) < MIN_PART_LEN:
            name = ""
        if not name and not email:
            if years and len(holders) and not holders[-1].years:
                holders[-1].years.merge(years)
                years = None
            continue
        if (not name and not years) and (
            email and len(holders) and not holders[-1].email
        ):
            holders[-1].email = email
            continue
        if (not email and not years) and (
            name and len(holders) and not holders[-1].name
        ):
            holders[-1].name = name
            continue
        holders.append(CopyrightHolder(name, email, years))
        years_used = True
    return None if years_used else years


def _get_holders_years(
    text: str,
    years: YearRange | None = None,
) -> abc.Iterator[tuple[YearRange, str]]:
    "Get pairs [(years, text)]"
    years_list, parts = _split_by_years(text, years)
    if len(parts[0]) < MIN_PART_LEN:
        return zip(years_list, parts[1:], strict=False)
    if len(parts[-1]) < MIN_PART_LEN:
        return zip(years_list, parts, strict=False)
    years_list = [YearRange(), *years_list]
    return zip(years_list, parts, strict=False)


HOLDER_RE: rex.Pattern[str] = rex.compile(
    r'\s*(?:by\s*)?(?P<holder>\S.*?\S)[\s"\*,;/]*$',
    rex.IGNORECASE,
)


# Copyright holders factory
def get_copyright_holders(
    copyright_: str,
    years: YearRange | None = None,
) -> tuple[list[CopyrightHolder], YearRange | None]:
    years_text_pairs = _get_holders_years(copyright_, years)

    holders: list[CopyrightHolder] = []
    # To be merged or leftover to be returned
    pending_years: YearRange | None = None

    for pair_years, text in years_text_pairs:
        if pair_years and pending_years:
            pending_years.merge(pair_years)
        elif pending_years is None or pair_years:
            pending_years = pair_years
        match = HOLDER_RE.match(text)
        if not match:
            # Discard current text, but keep what we found so far
            if pending_years and holders and not holders[-1].years:
                holders[-1].years.merge(pending_years)
                pending_years = None
            continue

        stripped_text = match.group("holder")
        name_email_pairs = _get_holders_emails(stripped_text)

        pending_years = _process_name_email_pairs(
            name_email_pairs,
            holders,
            pending_years,
        )

    return holders, pending_years


def _parse_copyright_continuation(
    text: str,
    continuation: rex.Match[str] | None,
    years: YearRange | None = None,
) -> tuple[str, rex.Match[str] | None]:
    if not continuation:
        return "", None
    lastindex = continuation.lastindex if continuation.lastindex is not None else 0
    prefix = continuation.string[: continuation.start(lastindex)]
    # Special case for C style multiline comments
    alt_prefix = prefix.replace("/*", " *")
    # normalize spaces
    alt_prefix = alt_prefix.replace("\t", "  ")
    alt_text = text.replace("\t", "  ")
    if not text.startswith(prefix) and not alt_text.startswith(alt_prefix):
        return "", None
    cont_match = rex.match(r"\s+", text[len(prefix) :]) or rex.match(
        r"\s+",
        alt_text[len(alt_prefix) :],
    )
    if not cont_match and years:
        rest = text[len(prefix) :]
    elif not cont_match:
        return "", None
    else:
        rest = cont_match.string[cont_match.end() :]
    # If there is nothing else but empty text, drop the continuation
    if rex.match(r"\s*$", rest):
        return "", None
    match = continuation
    return rest, match


def _re_split_iter(
    _pattern: rex.Pattern[str],
    text: str,
) -> abc.Iterator[str]:
    end = 0
    for indicator_match in COPYRIGHT_INDICATOR_RE.finditer(text):
        part = text[end : indicator_match.start()]
        end = indicator_match.end()
        yield part
    yield text[end:]


def parse_copyright(
    text: str,
    continuation: rex.Match[str] | None = None,
    years: YearRange | None = None,
) -> tuple[list[str] | None, rex.Match[str] | None]:
    if len(text) < 3 or len(text) > 4096:  # noqa: PLR2004
        return None, None

    match: rex.Match[str] | None = COPYRIGHT_INDICATOR_RE.search(text)
    if match:
        rest = match.string[match.end() :]
    else:
        # Process continuations
        rest, match = _parse_copyright_continuation(text, continuation, years)
        if not match:
            return None, None

    # if COPYRIGHT_PRE_IGNORE_RE.search(text) or
    #         COPYRIGHT_POST_IGNORE_RE.search(rest)
    if any(
        ignore_pattern.search(text) for ignore_pattern in COPYRIGHT_PRE_IGNORE_RES
    ) or any(
        ignore_pattern.search(rest) for ignore_pattern in COPYRIGHT_POST_IGNORE_RES
    ):
        return None, None

    copyrights: list[str] = []

    for part_ in _re_split_iter(COPYRIGHT_INDICATOR_RE, rest):
        part = part_
        if not part:
            continue
        for sub in CRUFT_SUBS:
            part = sub.pattern.sub(sub.repl, part)
        if part and len(part) > 2:  # noqa: PLR2004
            copyrights.append(part)

    return copyrights, match


def _add_new_holders(
    holders: list[CopyrightHolder],
    copyrights: list[str],
    years: YearRange | None,
) -> YearRange | None:
    # copyrights is a list of strings, that were split by copyright
    # indicators.
    for copyright_ in copyrights:
        new_holders, years = get_copyright_holders(copyright_, years)
        if new_holders:
            holders.extend(new_holders)
        elif years:  # noqa: SIM102
            # If we found a year, but not a holder, maybe the previous
            # holder is missing the years
            # Example: Copyright Maximiliano Curia (c) 2018
            if len(holders) and not holders[-1].years:
                holders[-1].years.merge(years)
                years = None
    # The years might still be relevant for the next copyright holder.
    return years


def iter_lines(
    text: str,
    start: int = 0,
) -> abc.Iterator[tuple[str, int, int]]:
    """Line iterator"""

    end = start - 1
    while True:
        start = end + 1
        end = text.find("\n", start)
        if end < 0:
            break
        yield text[start:end], start, end
    if text[start:]:
        yield text[start:], start, len(text)


def parse_holders(content: str) -> list[CopyrightHolder]:
    """Find the copyright holders specified in content"""

    # list containing the copyrightHolders found
    holders: list[CopyrightHolder] = []

    # last position checked
    last_seen = -1

    # Iterate only for lines that might have a copyright indicator.
    for copyright_match in COPYRIGHT_PRE_INDICATOR_RE.finditer(content):
        # Already seen
        if copyright_match.start() < last_seen:
            continue

        match_start = copyright_match.start()
        line_start = content.rfind("\n", 0, match_start) + 1

        # Initialize the parsing state (continuation and years)
        # continuation is set to the last copyright indicator found, that way we
        # can check the following lines, and correctly detect multiple copyright
        # holders after a single copyright indicator. Example:
        # Copyright:
        #  Maximiliano Curia
        #  Margarita Manterola
        continuation: rex.Match[str] | None = None
        # Keep years, to parse things like:
        #  Copyright 2018
        #    Maximiliano Curia
        years: YearRange | None = None

        for line, _, last_seen_ in iter_lines(content, line_start):
            last_seen = last_seen_
            copyrights, continuation = parse_copyright(line, continuation, years)
            # An empty continuation means that the copyright block ended.
            if not continuation:
                # remain calm and carry on in the outside iteration
                break
            if not copyrights:
                # A line without indicators, drop the cached years
                years = None
                continue

            # parse copyrights (list of strings) and create CopyrightHolders
            # with that information. Keep the years for the next iteration.
            years = _add_new_holders(holders, copyrights, years)

    return holders


# Copyright holders aggregation


_LEGAL_SUFFIX_RE = rex.compile(
    r",?\s+(?:Inc\.?|LLC|Ltd\.?|Corp\.?|GmbH|B\.V\.?|S\.A\.?|Co\.)$",
    rex.IGNORECASE,
)


def _normalize_holder_name(name: str) -> str:
    """Return name lowercased with trailing legal-entity suffix stripped."""
    return _LEGAL_SUFFIX_RE.sub("", name).strip().lower()


class CopyrightHolders:
    def __init__(self) -> None:
        self.by_normalized: dict[str, CopyrightHolder] = {}
        self.by_email: dict[str, CopyrightHolder] = {}

    def __len__(self) -> int:
        return len(self.by_normalized)

    def __iter__(self) -> abc.Iterator[CopyrightHolder]:
        return iter(self.by_normalized.values())

    def __repr__(self) -> str:
        return f"CopyrightHolders({list(self.sorted_members())!s})"

    def add(self, copyright_: CopyrightHolder) -> CopyrightHolder:
        # Fast path: same email -> merge
        if copyright_.email and copyright_.email in self.by_email:
            previous = self.by_email[copyright_.email]
            return previous.merge(copyright_)

        normalized = _normalize_holder_name(copyright_.name)
        if in_normalized := (normalized in self.by_normalized):
            previous = self.by_normalized[normalized]
            # Merge unless both sides carry *different* emails (distinct entities)
            if not (
                copyright_.email
                and previous.email
                and copyright_.email != previous.email
            ):
                if copyright_.email and not previous.email:
                    self.by_email[copyright_.email] = previous
                return previous.merge(copyright_)
            # Different emails, same normalised name -> store separately below

        key = normalized if not in_normalized else copyright_.person.lower()

        if copyright_.email:
            self.by_email[copyright_.email] = copyright_
        self.by_normalized[key] = copyright_
        return copyright_

    def extend(self, copyrights: abc.Iterable[CopyrightHolder]) -> None:
        for copyright_ in copyrights:
            self.add(copyright_)

    def key(self) -> tuple[str, ...]:
        return tuple(
            sorted(
                copyright_.email or copyright_.person
                for copyright_ in self.by_normalized.values()
            ),
        )

    def merge(self, other: CopyrightHolders) -> CopyrightHolders:
        for value in other.by_normalized.values():
            self.add(value)
        return self

    def sorted_members(self) -> abc.Iterator[str]:
        return (
            str(self.by_normalized[key]) for key in sorted(self.by_normalized.keys())
        )
