#!/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 decopy.common._regex import rex
from decopy.common.core_types import CopyrightHolder, YearRange
from decopy.licensing.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

# A copyright notice longer than this is not scanned (runaway/binary blob guard).
MAX_NOTICE_LEN: int = 4096

# Year-normalisation pivots used by _get_year.
_SINGLE_DIGIT_YEAR: int = 10  # a lone digit ("1990-2") borrows the ref's decade
_TWO_DIGIT_PIVOT: int = 50  # 2-digit years < 50 -> 2000s, >= 50 -> 1900s
_TWO_DIGIT_YEAR: int = 100  # still < 100 after the pivot bump -> add the 1900s
_MIN_FULL_YEAR: int = 1000  # below a plausible 4-digit year -> not a year
_MODERN_CENTURY: int = 2000
_LEGACY_CENTURY: int = 1900

# 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 < _SINGLE_DIGIT_YEAR and ref:
        # Handle: 1990-2
        fix = 0 if (ref % 10) < year else 1
        year += ((ref // 10) + fix) * 10
    if year < _TWO_DIGIT_PIVOT:
        year += _MODERN_CENTURY
    if year < _TWO_DIGIT_YEAR:
        year += _LEGACY_CENTURY
    if year < _MIN_FULL_YEAR:
        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 _clean_name(name: str | None) -> str:
    """Strip cruft and trailing prose from a raw holder name.

    Returns "" when nothing usable remains (a code fragment, or too short).
    """
    if name is None:
        return ""
    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):
        return ""
    if len(name) < MIN_PART_LEN:
        return ""
    return name


def _is_email_only(name: str, email: str, years: YearRange | None) -> bool:
    """The pair supplies only an email (no name, no years)."""
    return bool(not name and not years and email)


def _is_name_only(name: str, email: str, years: YearRange | None) -> bool:
    """The pair supplies only a name (no email, no years)."""
    return bool(not email and not years and name)


def _backfill_years(
    holders: list[CopyrightHolder], years: YearRange | None
) -> YearRange | None:
    """Fold a yearless pair's years into the previous holder if it has none.

    Returns the years to keep pending: None once merged, else unchanged.
    """
    if years and holders and not holders[-1].years:
        holders[-1].years.merge(years)
        return None
    return years


def _absorb_into_previous(
    last: CopyrightHolder, name: str, email: str, years: YearRange | None
) -> bool:
    """Complete the previous holder in place when this pair supplies only the
    piece it was missing (an email for a name, or a name for an email).

    Returns True when it did, so the caller does not start a new holder.
    """
    if not last.email and _is_email_only(name, email, years):
        last.email = email
        return True
    if not last.name and _is_name_only(name, email, years):
        last.name = name
        return True
    return False


def _process_name_email_pairs(
    name_email_pairs: list[tuple[str, str]],
    holders: list[CopyrightHolder],
    years: YearRange | None,
) -> YearRange | None:
    years_used = False
    for raw_name, email in name_email_pairs:
        name = _clean_name(raw_name)
        last = holders[-1] if holders else None

        # An empty pair only carries years: back-fill them onto the previous
        # holder rather than record a nameless, emailless entry.
        if not name and not email:
            years = _backfill_years(holders, years)
            continue
        # A continuation line that supplies only the piece the previous holder
        # was missing completes it in place instead of starting a new holder.
        if last is not None and _absorb_into_previous(last, name, email, years):
            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,
)


def _advance_pending_years(
    pending: YearRange | None, pair_years: YearRange | None
) -> YearRange | None:
    """Fold this text-part's years into the years carried between parts.

    Merge when both are present; otherwise adopt the part's years when there are
    any (or nothing is pending yet); else leave the pending years unchanged.
    """
    if pair_years and pending:
        pending.merge(pair_years)
        return pending
    if pending is None or pair_years:
        return pair_years
    return pending


def _backfill_unmatched(
    holders: list[CopyrightHolder], pending: YearRange | None
) -> YearRange | None:
    """On a text part that yielded no holder, fold pending years into the
    previous holder when it lacks any; return the years still pending."""
    if pending and holders and not holders[-1].years:
        holders[-1].years.merge(pending)
        return None
    return pending


# 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:
        pending_years = _advance_pending_years(pending_years, pair_years)
        match = HOLDER_RE.match(text)
        if not match:
            # Discard current text, but keep what we found so far
            pending_years = _backfill_unmatched(holders, pending_years)
            continue

        name_email_pairs = _get_holders_emails(match.group("holder"))
        pending_years = _process_name_email_pairs(
            name_email_pairs,
            holders,
            pending_years,
        )

    return holders, pending_years


def _continuation_rest(
    text: str, prefix: str, alt_prefix: str, alt_text: str, years: YearRange | None
) -> str | None:
    """The holder text following a continuation prefix, or None to stop.

    Whitespace after the prefix marks a real continuation line and is consumed.
    With no such whitespace, only carry on when years are pending (a bare year
    wrapped onto its own line); otherwise there is nothing to continue.
    """
    cont_match = rex.match(r"\s+", text[len(prefix) :]) or rex.match(
        r"\s+",
        alt_text[len(alt_prefix) :],
    )
    if cont_match:
        return cont_match.string[cont_match.end() :]
    if years:
        return text[len(prefix) :]
    return None


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; normalize tabs to spaces.
    alt_prefix = prefix.replace("/*", " *").replace("\t", "  ")
    alt_text = text.replace("\t", "  ")
    if not text.startswith(prefix) and not alt_text.startswith(alt_prefix):
        return "", None
    rest = _continuation_rest(text, prefix, alt_prefix, alt_text, years)
    # No continuation, or nothing but empty text left -> drop the continuation.
    if rest is None or rex.match(r"\s*$", rest):
        return "", None
    return rest, continuation


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


def _is_ignorable_notice(text: str, rest: str) -> bool:
    """Whether this line is not a real copyright notice.

    A pre-ignore pattern in the whole line (e.g. a #define, a po-file line) or a
    post-ignore pattern in the part after the indicator (mostly GPL boilerplate)
    disqualifies it.
    """
    return 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
    )


def _split_notice_parts(rest: str) -> list[str]:
    """Split the post-indicator text on any further indicators, de-cruft each
    part, and keep those still long enough to be a holder."""
    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)
        # len >= MIN_PART_LEN already implies a non-empty part.
        if len(part) >= MIN_PART_LEN:
            copyrights.append(part)
    return copyrights


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) < MIN_PART_LEN or len(text) > MAX_NOTICE_LEN:
        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 _is_ignorable_notice(text, rest):
        return None, None

    return _split_notice_parts(rest), 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()


def _emails_allow_merge(new: CopyrightHolder, previous: CopyrightHolder) -> bool:
    """Whether two same-named holders are the same entity.

    They merge unless both carry an email and the emails differ (two distinct
    people who happen to share a normalised name).
    """
    return not (new.email and previous.email and new.email != previous.email)


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:
        # Same email -> the same entity, merge onto the existing holder.
        if copyright_.email and copyright_.email in self.by_email:
            return self.by_email[copyright_.email].merge(copyright_)

        normalized = _normalize_holder_name(copyright_.name)
        previous = self.by_normalized.get(normalized)
        if previous is not None and _emails_allow_merge(copyright_, previous):
            # Same name, compatible emails: merge, adopting the new email when
            # the existing holder had none.
            if copyright_.email and not previous.email:
                self.by_email[copyright_.email] = previous
            return previous.merge(copyright_)

        # A new entry. A free name keys by its normalised form; a name that
        # collides with a different-email entity keys by the full person, so
        # both survive side by side.
        key = copyright_.person.lower() if previous is not None else normalized
        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())
        )
