from typing import Any
from datetime import datetime
import difflib


def _normalize_newlines(text: str) -> str:
    """Normalize CRLF to LF to avoid offset and diff noise."""
    return text.replace("\r\n", "\n")


def is_only_whitespace_differences(template: str, actual: str) -> bool:
    """
    Return True if `actual` can be derived from `template` by changing only
    whitespace (spaces, tabs, newlines). All non-whitespace characters must
    appear in the same order with no additions, deletions, or substitutions.
    """
    t = _normalize_newlines(template)
    a = _normalize_newlines(actual)

    lt, la = len(t), len(a)
    i = j = 0

    while True:
        # Skip any whitespace on both sides
        while i < lt and t[i].isspace():
            i += 1
        while j < la and a[j].isspace():
            j += 1

        if i >= lt or j >= la:
            break

        if t[i] != a[j]:
            return False

        i += 1
        j += 1

    # Ensure no remaining non-whitespace characters on either side
    while i < lt:
        if not t[i].isspace():
            return False
        i += 1

    while j < la:
        if not a[j].isspace():
            return False
        j += 1

    return True


def verify_template(template: str, jsonData: tuple[dict[str, Any], ...]) -> str:
    """
    Verify the initial event is a faithful snapshot of the template,
    allowing only whitespace differences in the event's newFragment.

    Also validates that the recorder wrote an initial snapshot
    (oldFragment == newFragment on the first record).

    Returns the verified fragment text.
    Raises ValueError if verification fails.
    """
    if not jsonData:
        raise ValueError("jsonData is empty")

    first = jsonData[0]
    new_frag = _normalize_newlines(first["newFragment"])
    old_frag = _normalize_newlines(first["oldFragment"])
    temp_norm = _normalize_newlines(template)

    # Recorder must have written an initial full snapshot:
    if new_frag != old_frag:
        raise ValueError("oldFragment does not match newFragment (no initial snapshot)")

    # Accept exact match OR match that differs only by whitespace
    if new_frag == temp_norm:
        return new_frag

    if is_only_whitespace_differences(temp_norm, new_frag):
        return new_frag

    raise ValueError("newFragment does not match template (differs by more than whitespace)")


def template_diff(template: str, jsonData: tuple[dict[str, Any], ...]) -> str:
    """
    Produce a unified diff between the given template and the first event's newFragment.
    If the only differences are whitespace, return ''.
    """
    if not jsonData:
        return ""

    template_norm = _normalize_newlines(template)
    actual_norm = _normalize_newlines(jsonData[0]["newFragment"])

    # Suppress diff if only whitespace differences
    if is_only_whitespace_differences(template_norm, actual_norm):
        return ""

    # Generate a proper unified diff (headers on separate lines)
    t_lines = template_norm.splitlines(keepends=True)
    a_lines = actual_norm.splitlines(keepends=True)

    diff_iter = difflib.unified_diff(
        t_lines,
        a_lines,
        fromfile="template",
        tofile="actual",
        n=3,
        lineterm="\n",
    )
    return "".join(diff_iter)


def _detect_multiline_external_pastes(jsonData: tuple[dict[str, Any], ...]) -> list[dict[str, Any]]:
    """
    Detect multi-line copy-paste events from external sources.

    Flags newFragments that are significant in length (more than one line)
    and do not appear to be copied from within the document itself.

    Returns a list of suspicious multi-line paste events.
    """
    suspicious_events = []

    # Build a history of all document content seen so far
    document_history = set()

    for idx, event in enumerate(jsonData):
        old_frag = _normalize_newlines(event.get("oldFragment", ""))
        new_frag = _normalize_newlines(event.get("newFragment", ""))

        # Skip if no actual change
        if new_frag == old_frag or new_frag.strip() == "":
            continue

        # Only check multi-line content (more than 2 lines means at least 2 actual lines)
        new_lines = new_frag.split("\n")
        if len(new_lines) <= 2:  # Single line or line + empty
            continue

        # Check if the new content appears to be from within the document
        is_internal_copy = False

        # Check if new_frag content was present in any previous fragments
        for hist_content in document_history:
            # Ignore tiny fragments; they appear everywhere and cause false positives
            if len(hist_content) < 20:
                continue

            # Require substantial overlap in size to count as an internal copy
            similar_length = (
                len(hist_content) >= 0.8 * len(new_frag)
                and len(hist_content) <= 1.25 * len(new_frag)
            )

            if new_frag == hist_content:
                is_internal_copy = True
                break

            if new_frag in hist_content and similar_length:
                is_internal_copy = True
                break

            if hist_content in new_frag and similar_length:
                is_internal_copy = True
                break

        # Also check if it's in the old fragment (internal move/copy)
        if not is_internal_copy and old_frag and (new_frag in old_frag or old_frag in new_frag):
            is_internal_copy = True

        if not is_internal_copy:
            suspicious_events.append({
                "event_index": idx,
                "line_count": len(new_lines),
                "char_count": len(new_frag),
                "reason": "multi-line external paste",
                "newFragment": new_frag[:100] + ("..." if len(new_frag) > 100 else ""),
            })

        # Update history after analysis so the current fragment cannot mask itself
        if len(old_frag) > 1:
            document_history.add(old_frag)
        if len(new_frag) > 1:
            document_history.add(new_frag)

    return suspicious_events


def _detect_rapid_paste_sequences(jsonData: tuple[dict[str, Any], ...]) -> list[dict[str, Any]]:
    """
    Detect rapid sequences of one-line pastes (AI assistance indicator).

    Identifies clusters of 3+ one-line paste events occurring within 1 second,
    which may indicate AI-assisted code generation.

    Returns a list of suspicious rapid-paste events.
    """
    suspicious_events = []

    # Track one-line paste events for rapid-paste detection
    one_line_pastes = []

    for idx, event in enumerate(jsonData):
        new_frag = _normalize_newlines(event.get("newFragment", ""))
        old_frag = _normalize_newlines(event.get("oldFragment", ""))
        timestamp = event.get("timestamp")

        # Skip if no timestamp or no change
        if not timestamp or new_frag == old_frag or new_frag.strip() == "":
            continue

        # Check if newFragment is a single line (2 elements = 1 line + trailing \n)
        new_lines = new_frag.split("\n")
        if len(new_lines) == 2:
            # Heuristic: if it's more than a few characters, it might be pasted
            if len(new_frag.strip()) > 5:
                one_line_pastes.append({
                    "event_index": idx,
                    "timestamp": timestamp,
                    "content": new_frag
                })

    # Analyze one-line pastes for rapid clusters
    if not one_line_pastes:
        return suspicious_events

    def parse_ts(ts_str: str) -> datetime:
        return datetime.fromisoformat(ts_str.replace("Z", "+00:00"))

    i = 0
    while i < len(one_line_pastes):
        cluster = [one_line_pastes[i]]
        cluster_start = parse_ts(one_line_pastes[i]["timestamp"])

        # Look ahead for more pastes within 1 second
        j = i + 1
        while j < len(one_line_pastes):
            current_time = parse_ts(one_line_pastes[j]["timestamp"])
            if (current_time - cluster_start).total_seconds() <= 1.0:
                cluster.append(one_line_pastes[j])
                j += 1
            else:
                break

        # If we found 3+ one-line pastes within 1 second, flag it
        if len(cluster) >= 3:
            event_indices = [p["event_index"] for p in cluster]
            suspicious_events.append({
                "event_index": event_indices[0],
                "event_indices": event_indices,
                "line_count": len(cluster),
                "char_count": sum(len(p["content"]) for p in cluster),
                "reason": "rapid one-line pastes (AI indicator)",
                "newFragment": f"{len(cluster)} one-line pastes in 1 second",
            })

        i = j if j > i + 1 else i + 1

    return suspicious_events


def detect_external_copypaste(jsonData: tuple[dict[str, Any], ...]) -> list[dict[str, Any]]:
    """
    Detect copy-paste events from external sources and AI-assisted coding patterns.

    Combines detection of:
    1. Multi-line external paste events (content not from within document)
    2. Rapid one-line paste sequences (potential AI assistance indicator)

    Returns a list of all suspicious events with metadata.
    """
    suspicious_events = []

    # Detect multi-line external pastes
    suspicious_events.extend(_detect_multiline_external_pastes(jsonData))

    # Detect rapid one-line paste sequences (AI indicator)
    suspicious_events.extend(_detect_rapid_paste_sequences(jsonData))

    return suspicious_events


def check_time_limit(jsonData: tuple[dict[str, Any], ...], time_limit_minutes: int | None) -> dict[str, Any] | None:
    """
    Check if the time between first and last edit exceeds the specified time limit.

    Tracks elapsed editing time across sessions by summing actual editing time within
    each session (excluding gaps between sessions). For the time limit check, compares
    the span from the first timestamp to the last timestamp overall.

    Parameters
    ----------
    jsonData : tuple[dict[str, Any], ...]
        The event data from the JSONL file
    time_limit_minutes : int | None
        Maximum allowed time in minutes between first and last overall edit.
        If None, no time limit is enforced.

    Returns
    -------
    dict[str, Any] | None
        A dictionary with time limit and elapsed time info.
        Contains 'exceeds_limit' flag and always includes 'minutes_elapsed'.
    """
    if not jsonData:
        return None

    def parse_ts(ts_str: str) -> datetime:
        return datetime.fromisoformat(ts_str.replace("Z", "+00:00"))

    # Identify session boundaries: sessions start at indices where oldFragment == newFragment (non-empty)
    session_starts = [0]  # First session always starts at index 0
    for idx in range(1, len(jsonData)):
        old_frag = jsonData[idx].get("oldFragment", "")
        new_frag = jsonData[idx].get("newFragment", "")
        if old_frag == new_frag and old_frag.strip() != "":
            session_starts.append(idx)

    # Add sentinel to mark end of last session
    session_starts.append(len(jsonData))

    # Find first and last timestamps overall
    first_timestamp_overall = None
    last_timestamp_overall = None

    for event in jsonData:
        if event.get("timestamp"):
            if first_timestamp_overall is None:
                first_timestamp_overall = event["timestamp"]
            last_timestamp_overall = event["timestamp"]

    if first_timestamp_overall is None or last_timestamp_overall is None:
        # Not enough events with timestamps
        return None

    # Calculate elapsed time by summing editing time within each session
    total_minutes_elapsed = 0.0

    for i in range(len(session_starts) - 1):
        session_start = session_starts[i]
        session_end = session_starts[i + 1]

        # Find first and last events with timestamps in this session
        first_event = None
        last_event = None

        for event in jsonData[session_start:session_end]:
            if event.get("timestamp"):
                if first_event is None:
                    first_event = event
                last_event = event

        # If this session has timestamped events, add its elapsed time
        if first_event is not None and last_event is not None:
            try:
                first_time = parse_ts(first_event["timestamp"])
                last_time = parse_ts(last_event["timestamp"])
                session_diff = last_time - first_time
                total_minutes_elapsed += session_diff.total_seconds() / 60
            except (ValueError, KeyError):
                # Timestamp parsing failed for this session, skip it
                continue

    # For time limit check, use the span from first to last timestamp overall
    try:
        first_time_overall = parse_ts(first_timestamp_overall)
        last_time_overall = parse_ts(last_timestamp_overall)
        overall_span_minutes = (last_time_overall - first_time_overall).total_seconds() / 60
    except (ValueError, KeyError):
        # Timestamp parsing failed
        return None

    result = {
        "time_limit_minutes": time_limit_minutes,
        "minutes_elapsed": round(total_minutes_elapsed, 2),
        "first_timestamp": first_timestamp_overall,
        "last_timestamp": last_timestamp_overall,
    }

    # For time limit check, compare the overall span (first to last timestamp) against the limit
    if time_limit_minutes is not None:
        result["exceeds_limit"] = overall_span_minutes > time_limit_minutes
    else:
        result["exceeds_limit"] = False

    return result


def verify(template: str, jsonData: tuple[dict[str, Any], ...]) -> tuple[str, list[dict[str, Any]]]:
    """
    Comprehensive verification of recorded code events.

    Performs:
    1. Template verification (initial snapshot matches template)
    2. External copy-paste detection

    Returns:
        tuple: (verified_template_text, list_of_suspicious_copypaste_events)

    Raises:
        ValueError: If template verification fails
    """
    # Verify template
    verified_template = verify_template(template, jsonData)

    # Detect external copy-paste events
    suspicious_events = detect_external_copypaste(jsonData)

    return verified_template, suspicious_events
