"""Secret detection and sanitization for Lattice logs.

All logs are scrubbed for API keys, passwords, tokens before writing to disk.
Rules files must NEVER contain secrets.
"""

from __future__ import annotations

import re

import deal

from lattice.core.types.evidence import SecretSpan


@deal.pre(lambda pattern: isinstance(pattern, str))
@deal.post(lambda result: isinstance(result, bool))
def _is_valid_regex(pattern: str) -> bool:
    """Check if a string is a valid regex pattern.

    >>> _is_valid_regex(r"sk-[a-z]+")
    True
    >>> _is_valid_regex(r"[invalid")
    False
    >>> _is_valid_regex("")
    True
    """
    try:
        re.compile(pattern)
        return True
    except re.error:
        return False


@deal.pre(
    lambda content, patterns: isinstance(content, str) and isinstance(patterns, list)
)
@deal.pre(lambda content, patterns: all(isinstance(p, str) for p in patterns))
@deal.pre(lambda content, patterns: all(_is_valid_regex(p) for p in patterns))
@deal.post(lambda result: isinstance(result, list))
@deal.post(lambda result: all(isinstance(s, SecretSpan) for s in result))
@deal.post(lambda result: all(s.start >= 0 for s in result))
@deal.post(lambda result: all(s.end > s.start for s in result))
def detect_secrets(
    content: str,
    patterns: list[str],
) -> list[SecretSpan]:
    """Detect secret spans in content using regex patterns.

    Args:
        content: Text to scan.
        patterns: List of regex pattern strings.

    Returns:
        List of SecretSpan indicating match positions. May overlap.

    >>> detect_secrets("", [])
    []
    >>> detect_secrets("no secrets here", [r"api_key"])
    []
    >>> spans = detect_secrets("key=sk-abc123 token=xyz", [r"sk-[a-z0-9]+"])
    >>> len(spans)
    1
    >>> spans[0].pattern
    'sk-[a-z0-9]+'
    """
    spans: list[SecretSpan] = []
    for pattern in patterns:
        compiled = re.compile(pattern)
        for match in compiled.finditer(content):
            # Skip zero-length matches (e.g., patterns like "a*" can match empty)
            if match.end() > match.start():
                spans.append(
                    SecretSpan(start=match.start(), end=match.end(), pattern=pattern)
                )
    return spans


@deal.pre(
    lambda content, patterns, replacement="[REDACTED]": (
        isinstance(content, str)
        and isinstance(patterns, list)
        and isinstance(replacement, str)
    )
)
@deal.pre(
    lambda content, patterns, replacement="[REDACTED]": all(
        isinstance(p, str) for p in patterns
    )
)
@deal.pre(
    lambda content, patterns, replacement="[REDACTED]": all(
        _is_valid_regex(p) for p in patterns
    )
)
# NOTE: Length contract (len(result) <= len(content)) expressed via doctest below;
# @post cannot access parameters per deal's design.
@deal.post(lambda result: isinstance(result, str))
def sanitize(
    content: str,
    patterns: list[str],
    replacement: str = "[REDACTED]",
) -> str:
    """Remove secrets from content by replacing matched spans.

    Uses detect_secrets internally. Overlapping spans are merged.

    Note: Output length may exceed input length if replacement string is
    longer than matched text. For guaranteed length reduction, caller must
    ensure len(replacement) <= len(matched_text) for all patterns.

    Args:
        content: Text to sanitize.
        patterns: List of regex pattern strings.
        replacement: String to replace secrets with.

    Returns:
        Sanitized content string.

    >>> sanitize("", [])
    ''
    >>> sanitize("no secrets", [])
    'no secrets'
    >>> sanitize("api_key=sk-123", [r"sk-[a-z0-9]+"])
    'api_key=[REDACTED]'
    >>> sanitize("key=sk-abc token=xyz789", [r"sk-[a-z]+", r"xyz[0-9]+"])
    'key=[REDACTED] token=[REDACTED]'
    >>> # Verify output length <= input when replacement <= matched text
    >>> result = sanitize("key=sk-abcdefghijklmnop", [r"sk-[a-z]+"])
    >>> len(result) <= len("key=sk-abcdefghijklmnop")
    True
    """
    if not content or not patterns:
        return content

    spans = detect_secrets(content, patterns)
    if not spans:
        return content

    merged = _merge_overlapping_spans(spans)

    # Replace from end to start to preserve indices
    result = content
    for span in reversed(merged):
        result = result[: span.start] + replacement + result[span.end :]

    return result


@deal.pre(lambda spans: isinstance(spans, list))
@deal.pre(lambda spans: all(isinstance(s, SecretSpan) for s in spans))
@deal.pre(
    lambda spans: all(0 <= s.start < s.end for s in spans)
)  # Must have non-zero length and non-negative start
@deal.post(lambda result: isinstance(result, list))
@deal.post(lambda result: all(isinstance(s, SecretSpan) for s in result))
@deal.post(lambda result: all(0 <= s.start < s.end for s in result) if result else True)
def _merge_overlapping_spans(spans: list[SecretSpan]) -> list[SecretSpan]:
    """Merge overlapping or adjacent secret spans.

    Args:
        spans: List of SecretSpan objects, possibly overlapping.

    Returns:
        List of non-overlapping SecretSpan objects, sorted by start position.

    >>> from lattice.core.types.evidence import SecretSpan
    >>> _merge_overlapping_spans([])
    []
    >>> spans = [SecretSpan(0, 10, "p1"), SecretSpan(5, 15, "p2")]
    >>> merged = _merge_overlapping_spans(spans)
    >>> len(merged)
    1
    >>> merged[0].start
    0
    >>> merged[0].end
    15
    """
    if not spans:
        return []

    # Sort by start position
    sorted_spans = sorted(spans, key=lambda s: s.start)

    merged: list[SecretSpan] = []
    current = sorted_spans[0]

    for span in sorted_spans[1:]:
        # Check if overlapping or adjacent (current.end >= span.start)
        if current.end >= span.start:
            # Merge: extend current to cover both
            current = SecretSpan(
                start=current.start,
                end=max(current.end, span.end),
                pattern=current.pattern,  # Keep first pattern
            )
        else:
            # No overlap, save current and start new
            merged.append(current)
            current = span

    merged.append(current)
    return merged
