"""Template verification, suspicious-activity detection, and time aggregation."""

from __future__ import annotations

import difflib
from datetime import datetime
from pathlib import Path
import re
from typing import Any

from ..timeutil import parse_timestamp
from .build import SuspiciousOffsetEncodingError, _raise_for_unknown_offset_encoding
from .document import normalize_path_string
from .load import filter_edit_events


MIN_RAPID_PASTE_CHARS = 5
MIN_EXTERNAL_PASTE_LINES = 8
MIN_EXTERNAL_PASTE_CHARS = 250
MIN_AUTOCOMPLETE_CHARS = 10
MAX_AUTOCOMPLETE_CHARS = 300
MAX_TRANSIENT_INSERT_SECONDS = 0.05
MAX_DUPLICATE_REPLACEMENT_SECONDS = 0.5
COMPLETE_STATEMENT_INDICATORS = (
    ":",
    "return",
    "def ",
    "class ",
    "if ",
    "for ",
    "while ",
    "try:",
    "except",
    "import ",
    "=",
)
AUTOCOMPLETE_STRUCTURE_INDICATORS = (
    "def ",
    "class ",
)


def _normalize_newlines(text: str) -> str:
    return text.replace("\r\n", "\n")


def _collapse_ws(text: str) -> str:
    return "".join(text.split())


def _normalize_reuse_fragment(text: str) -> str | None:
    normalized = _collapse_ws(_normalize_newlines(text))
    if len(normalized) < 10:
        return None
    return normalized


def _ordered_edit_events(events: tuple[dict[str, Any], ...]) -> list[dict[str, Any]]:
    decorated: list[tuple[int, object, dict[str, Any]]] = []
    for index, event in enumerate(filter_edit_events(events)):
        timestamp = event.get("timestamp")
        if timestamp:
            try:
                decorated.append((0, parse_timestamp(str(timestamp)), event))
                continue
            except ValueError:
                pass
        decorated.append((1, index, event))

    decorated.sort(key=lambda item: (item[0], item[1]))
    return [event for _, _, event in decorated]


def _safe_offset(event: dict[str, Any]) -> int:
    try:
        return int(event.get("offset", 0) or 0)
    except (TypeError, ValueError):
        return 0


def _crlf_units_to_index(text: str, units: int) -> int:
    if units <= 0:
        return 0

    consumed = 0
    index = 0
    for char in text:
        if consumed >= units:
            break
        consumed += 2 if char == "\n" else 1
        index += 1
    return index


def _uses_crlf_offsets(events: tuple[dict[str, Any], ...] | list[dict[str, Any]]) -> bool:
    return any(
        "\r\n" in str(event.get("oldFragment", ""))
        or "\r\n" in str(event.get("newFragment", ""))
        for event in events
    )


def _is_snapshot(event: dict[str, Any]) -> bool:
    old_fragment = str(event.get("oldFragment", ""))
    new_fragment = str(event.get("newFragment", ""))
    return _safe_offset(event) == 0 and old_fragment == new_fragment


def _advance_snapshot_state(
    current_state: str,
    snapshot_text: str,
    *,
    allow_seed: bool,
) -> str:
    del current_state, allow_seed
    return snapshot_text


def _snapshot_conflict_diff(current_state: str, snapshot_text: str) -> str:
    return "".join(
        difflib.unified_diff(
            current_state.splitlines(keepends=True),
            snapshot_text.splitlines(keepends=True),
            fromfile="current state",
            tofile="snapshot",
        )
    )


def _resolve_offset(document: str, old_fragment: str, offset: int, window: int = 120) -> int:
    if old_fragment == "":
        return max(0, min(offset, len(document)))

    offset = max(0, min(offset, len(document)))
    if document[offset : offset + len(old_fragment)] == old_fragment:
        return offset

    start = max(0, offset - window)
    end = min(len(document), offset + window + len(old_fragment))
    best_match: tuple[int, int] | None = None
    search_at = start
    while True:
        found = document.find(old_fragment, search_at, end)
        if found == -1:
            break
        distance = abs(found - offset)
        candidate = (distance, found)
        if best_match is None or candidate < best_match:
            best_match = candidate
        search_at = found + 1

    if best_match is None:
        raise ValueError("old fragment not found")

    return best_match[1]


def _apply_edit(
    current_state: str,
    event: dict[str, Any],
    *,
    crlf_mode: bool = False,
    allow_snapshot_seed: bool = False,
) -> str:
    old_fragment = _normalize_newlines(str(event.get("oldFragment", "")))
    new_fragment = _normalize_newlines(str(event.get("newFragment", "")))
    offset = _safe_offset(event)
    _raise_for_unknown_offset_encoding(current_state, offset, crlf_mode=crlf_mode)
    text_offset = _crlf_units_to_index(current_state, offset) if crlf_mode else offset

    if _is_snapshot(event):
        del allow_snapshot_seed
        return old_fragment

    if old_fragment == new_fragment:
        return current_state

    resolved_offset = _resolve_offset(current_state, old_fragment, text_offset)
    return (
        current_state[:resolved_offset]
        + new_fragment
        + current_state[resolved_offset + len(old_fragment) :]
    )


def get_recorded_initial_state(events: tuple[dict[str, Any], ...]) -> str | None:
    """Return the initial document text captured by the recording, if available."""
    for event in _ordered_edit_events(events):
        old_fragment = _normalize_newlines(str(event.get("oldFragment", "")))
        if _is_snapshot(event):
            return old_fragment
        if _safe_offset(event) == 0 and old_fragment:
            return old_fragment
        break
    return None


def is_only_whitespace_differences(template: str, actual: str) -> bool:
    """Return True when the only differences are whitespace."""
    return _collapse_ws(_normalize_newlines(template)) == _collapse_ws(
        _normalize_newlines(actual)
    )


def verify_template(template: str, json_data: tuple[dict[str, Any], ...]) -> str:
    """Verify that the template matches the initial recorder snapshot."""
    if not json_data:
        raise ValueError("Recording contains no events")

    template_text = _normalize_newlines(template)
    recorded_initial = get_recorded_initial_state(json_data)
    if recorded_initial is None:
        return template_text

    if not template_text:
        return recorded_initial

    if recorded_initial == template_text or is_only_whitespace_differences(
        template_text, recorded_initial
    ):
        return recorded_initial

    raise ValueError("Template does not match recording initial state")


def template_diff(template: str, json_data: tuple[dict[str, Any], ...]) -> str:
    """Return a unified diff between the provided template and recorded initial state."""
    recorded_initial = get_recorded_initial_state(json_data)
    if recorded_initial is None:
        return ""

    template_text = _normalize_newlines(template)
    if is_only_whitespace_differences(template_text, recorded_initial):
        return ""

    return "".join(
        difflib.unified_diff(
            template_text.splitlines(keepends=True),
            recorded_initial.splitlines(keepends=True),
            fromfile="template",
            tofile="recording",
        )
    )


def _fragment_reused(fragment: str, current_state: str, old_fragment: str) -> bool:
    normalized_fragment = _normalize_reuse_fragment(fragment)
    if normalized_fragment is None:
        return False

    if normalized_fragment in _collapse_ws(current_state):
        return True

    normalized_old = _normalize_reuse_fragment(old_fragment)
    if normalized_old and (
        normalized_fragment in normalized_old or normalized_old in normalized_fragment
    ):
        return True

    return False


def _collect_reused_event_indices(
    json_data_by_scope: dict[object, tuple[dict[str, Any], ...]],
) -> dict[object, set[int]]:
    reused_indices: dict[object, set[int]] = {
        scope_id: set() for scope_id in json_data_by_scope
    }
    current_states = {
        scope_id: get_recorded_initial_state(json_data) or ""
        for scope_id, json_data in json_data_by_scope.items()
    }
    ordered_events_by_scope = {
        scope_id: _ordered_edit_events(json_data)
        for scope_id, json_data in json_data_by_scope.items()
    }
    crlf_mode_by_scope = {
        scope_id: _uses_crlf_offsets(ordered_events)
        for scope_id, ordered_events in ordered_events_by_scope.items()
    }
    removed_fragments: list[str] = []

    global_events: list[tuple[tuple[object, ...], object, int, dict[str, Any]]] = []
    for scope_order, (scope_id, ordered_events) in enumerate(ordered_events_by_scope.items()):
        for local_index, event in enumerate(ordered_events):
            timestamp_text = event.get("timestamp")
            if timestamp_text:
                try:
                    sort_key: tuple[object, ...] = (
                        0,
                        parse_timestamp(str(timestamp_text)),
                        scope_order,
                        local_index,
                    )
                except ValueError:
                    sort_key = (1, scope_order, local_index)
            else:
                sort_key = (1, scope_order, local_index)
            global_events.append((sort_key, scope_id, local_index, event))

    global_events.sort(key=lambda item: item[0])

    for _, scope_id, local_index, event in global_events:
        current_state = current_states[scope_id]
        old_fragment = _normalize_newlines(str(event.get("oldFragment", "")))
        new_fragment = _normalize_newlines(str(event.get("newFragment", "")))

        if _fragment_reused(new_fragment, current_state, old_fragment):
            reused_indices[scope_id].add(local_index)
        else:
            normalized_fragment = _normalize_reuse_fragment(new_fragment)
            if normalized_fragment is not None:
                other_current_states = [
                    _collapse_ws(state)
                    for other_scope_id, state in current_states.items()
                    if other_scope_id != scope_id and state
                ]
                if any(
                    normalized_fragment in other_state
                    for other_state in other_current_states
                ) or any(
                    normalized_fragment in removed_fragment
                    or removed_fragment in normalized_fragment
                    for removed_fragment in removed_fragments
                ):
                    reused_indices[scope_id].add(local_index)

        try:
            current_states[scope_id] = _apply_edit(
                current_state,
                event,
                crlf_mode=crlf_mode_by_scope[scope_id],
                allow_snapshot_seed=local_index == 0,
            )
        except ValueError as exc:
            if isinstance(exc, SuspiciousOffsetEncodingError):
                raise
            current_states[scope_id] = current_state

        normalized_old = _normalize_reuse_fragment(old_fragment)
        if normalized_old and old_fragment != new_fragment:
            removed_fragments.append(normalized_old)

    return reused_indices


def _detect_external_pastes(
    json_data: tuple[dict[str, Any], ...],
    reused_indices: set[int] | None = None,
) -> list[dict[str, Any]]:
    suspicious_events: list[dict[str, Any]] = []
    current_state = get_recorded_initial_state(json_data) or ""
    ordered_events = _ordered_edit_events(json_data)
    crlf_mode = _uses_crlf_offsets(ordered_events)
    reused_indices = reused_indices or set()

    for index, event in enumerate(ordered_events):
        old_fragment = _normalize_newlines(str(event.get("oldFragment", "")))
        new_fragment = _normalize_newlines(str(event.get("newFragment", "")))

        if _is_snapshot(event):
            current_state = _advance_snapshot_state(
                current_state,
                old_fragment,
                allow_seed=index == 0,
            )
            continue

        if old_fragment == new_fragment or not new_fragment.strip():
            current_state = _apply_edit(current_state, event, crlf_mode=crlf_mode)
            continue

        line_count = len(new_fragment.splitlines())
        looks_large = (
            line_count >= MIN_EXTERNAL_PASTE_LINES
            or (line_count >= 6 and len(new_fragment) >= MIN_EXTERNAL_PASTE_CHARS)
            or (line_count >= 3 and len(new_fragment) >= 350)
        )
        if looks_large and index not in reused_indices and not _fragment_reused(
            new_fragment,
            current_state,
            old_fragment,
        ):
            suspicious_events.append(
                {
                    "event_index": index,
                    "line_count": max(line_count, 1),
                    "char_count": len(new_fragment),
                    "reason": "external paste",
                    "newFragment": new_fragment,
                }
            )

        current_state = _apply_edit(current_state, event, crlf_mode=crlf_mode)

    return suspicious_events


def _detect_rapid_paste_sequences(
    json_data: tuple[dict[str, Any], ...],
) -> list[dict[str, Any]]:
    ordered_events = _ordered_edit_events(json_data)
    transient_insert_indices: set[int] = set()
    for index, event in enumerate(ordered_events[:-1]):
        old_fragment = _normalize_newlines(str(event.get("oldFragment", "")))
        new_fragment = _normalize_newlines(str(event.get("newFragment", "")))
        timestamp_text = event.get("timestamp")
        if (
            old_fragment
            or not new_fragment.strip()
            or "\n" not in new_fragment
            or len(new_fragment.strip()) < MIN_RAPID_PASTE_CHARS
            or not timestamp_text
        ):
            continue

        next_event = ordered_events[index + 1]
        next_old_fragment = _normalize_newlines(str(next_event.get("oldFragment", "")))
        next_new_fragment = _normalize_newlines(str(next_event.get("newFragment", "")))
        next_timestamp_text = next_event.get("timestamp")
        if (
            next_new_fragment
            or next_old_fragment != new_fragment
            or not next_timestamp_text
        ):
            continue

        try:
            timestamp = parse_timestamp(str(timestamp_text))
            next_timestamp = parse_timestamp(str(next_timestamp_text))
        except ValueError:
            continue

        # JetBrains can emit multiline suggestion previews as insert/delete pairs a few
        # milliseconds apart. Treat those transient inserts as editor churn, not paste events.
        if (next_timestamp - timestamp).total_seconds() <= MAX_TRANSIENT_INSERT_SECONDS:
            transient_insert_indices.add(index)

    candidates: list[dict[str, Any]] = []
    for index, event in enumerate(ordered_events):
        old_fragment = _normalize_newlines(str(event.get("oldFragment", "")))
        new_fragment = _normalize_newlines(str(event.get("newFragment", "")))
        timestamp_text = event.get("timestamp")

        if (
            index in transient_insert_indices
            or not timestamp_text
            or old_fragment == new_fragment
            or not new_fragment.strip()
            or "\n" not in new_fragment
            or len(new_fragment.strip()) < MIN_RAPID_PASTE_CHARS
        ):
            continue

        try:
            timestamp = parse_timestamp(str(timestamp_text))
        except ValueError:
            continue

        if candidates and old_fragment:
            previous = candidates[-1]
            if (
                previous["old_fragment"] == old_fragment
                and previous["content"] == new_fragment
                and abs(_safe_offset(event) - previous["offset"]) <= 1
                and (timestamp - previous["timestamp"]).total_seconds()
                <= MAX_DUPLICATE_REPLACEMENT_SECONDS
            ):
                continue

        candidates.append(
            {
                "event_index": index,
                "timestamp": timestamp,
                "content": new_fragment,
                "old_fragment": old_fragment,
                "offset": _safe_offset(event),
            }
        )

    suspicious_events: list[dict[str, Any]] = []
    start = 0
    while start < len(candidates):
        cluster = [candidates[start]]
        end = start + 1
        while end < len(candidates):
            if (
                candidates[end]["timestamp"] - cluster[0]["timestamp"]
            ).total_seconds() <= 1.0:
                cluster.append(candidates[end])
                end += 1
            else:
                break

        if len(cluster) >= 3:
            event_indices = [candidate["event_index"] for candidate in cluster]
            suspicious_events.append(
                {
                    "event_index": event_indices[0],
                    "event_indices": event_indices,
                    "line_count": len(cluster),
                    "char_count": sum(len(candidate["content"]) for candidate in cluster),
                    "reason": "AI rapid paste",
                    "newFragment": f"{len(cluster)} pasted lines in under one second",
                    "detailed_events": [
                        {
                            "event_index": candidate["event_index"],
                            "line_count": 1,
                            "char_count": len(candidate["content"]),
                            "newFragment": candidate["content"],
                        }
                        for candidate in cluster
                    ],
                }
            )
            start = end
        else:
            start += 1

    return suspicious_events


def _detect_complete_statement_autocomplete(
    json_data: tuple[dict[str, Any], ...],
    excluded_indices: set[int],
    reused_indices: set[int] | None = None,
) -> list[dict[str, Any]]:
    suspicious_events: list[dict[str, Any]] = []
    current_state = get_recorded_initial_state(json_data) or ""
    ordered_events = _ordered_edit_events(json_data)
    crlf_mode = _uses_crlf_offsets(ordered_events)
    reused_indices = reused_indices or set()

    for index, event in enumerate(ordered_events):
        old_fragment = _normalize_newlines(str(event.get("oldFragment", "")))
        new_fragment = _normalize_newlines(str(event.get("newFragment", "")))

        if _is_snapshot(event):
            current_state = _advance_snapshot_state(
                current_state,
                old_fragment,
                allow_seed=index == 0,
            )
            continue

        if index in excluded_indices:
            current_state = _apply_edit(current_state, event, crlf_mode=crlf_mode)
            continue

        if index in reused_indices:
            current_state = _apply_edit(current_state, event, crlf_mode=crlf_mode)
            continue

        if old_fragment == new_fragment or not new_fragment.strip():
            current_state = _apply_edit(current_state, event, crlf_mode=crlf_mode)
            continue

        non_empty_lines = [line for line in new_fragment.split("\n") if line.strip()]
        if len(non_empty_lines) < 2:
            current_state = _apply_edit(current_state, event, crlf_mode=crlf_mode)
            continue

        if len(old_fragment) > 3:
            current_state = _apply_edit(current_state, event, crlf_mode=crlf_mode)
            continue

        if len(new_fragment) < MIN_AUTOCOMPLETE_CHARS or len(new_fragment) > MAX_AUTOCOMPLETE_CHARS:
            current_state = _apply_edit(current_state, event, crlf_mode=crlf_mode)
            continue

        if not any(indicator in new_fragment for indicator in COMPLETE_STATEMENT_INDICATORS):
            current_state = _apply_edit(current_state, event, crlf_mode=crlf_mode)
            continue

        if not any(indicator in new_fragment for indicator in AUTOCOMPLETE_STRUCTURE_INDICATORS):
            current_state = _apply_edit(current_state, event, crlf_mode=crlf_mode)
            continue

        if _fragment_reused(new_fragment, current_state, old_fragment):
            current_state = _apply_edit(current_state, event, crlf_mode=crlf_mode)
            continue

        suspicious_events.append(
            {
                "event_index": index,
                "line_count": len(non_empty_lines),
                "char_count": len(new_fragment),
                "reason": "complete statement auto-complete (AI assistance)",
                "newFragment": new_fragment,
            }
        )

        current_state = _apply_edit(current_state, event, crlf_mode=crlf_mode)

    return suspicious_events


def _detect_function_generation(
    json_data: tuple[dict[str, Any], ...],
    excluded_indices: set[int],
) -> list[dict[str, Any]]:
    function_generation_events: list[dict[str, Any]] = []
    current_state = get_recorded_initial_state(json_data) or ""
    ordered_events = _ordered_edit_events(json_data)
    crlf_mode = _uses_crlf_offsets(ordered_events)
    func_def_pattern = re.compile(r"^\s*(async\s+)?def\s+(\w+)\s*\(", re.MULTILINE)

    for index, event in enumerate(ordered_events):
        old_fragment = _normalize_newlines(str(event.get("oldFragment", "")))
        new_fragment = _normalize_newlines(str(event.get("newFragment", "")))

        if _is_snapshot(event):
            current_state = _advance_snapshot_state(
                current_state,
                old_fragment,
                allow_seed=index == 0,
            )
            continue

        if index in excluded_indices:
            current_state = _apply_edit(current_state, event, crlf_mode=crlf_mode)
            continue

        if old_fragment == new_fragment or not new_fragment.strip():
            current_state = _apply_edit(current_state, event, crlf_mode=crlf_mode)
            continue

        func_matches = list(func_def_pattern.finditer(new_fragment))
        if not func_matches:
            current_state = _apply_edit(current_state, event, crlf_mode=crlf_mode)
            continue

        if len(old_fragment) > 5:
            current_state = _apply_edit(current_state, event, crlf_mode=crlf_mode)
            continue

        function_names: list[str] = []
        is_boilerplate = True
        for match in func_matches:
            function_name = match.group(2)
            function_names.append(function_name)
            if not re.search(r"\b" + re.escape(function_name) + r"\b", current_state):
                is_boilerplate = False
                break

        if is_boilerplate:
            body_lines: list[str] = []
            for line in new_fragment.split("\n"):
                if func_def_pattern.search(line):
                    continue
                body_lines.append(line)

            body_content = "\n".join(body_lines)
            body_stripped = body_content.strip()
            body_stripped = body_stripped.replace(":", "").strip()
            body_stripped = body_stripped.replace("pass", "").strip()
            body_stripped = re.sub(
                r'["\'][\'"]{0,2}.*?["\'][\'"]{0,2}',
                "",
                body_stripped,
                flags=re.DOTALL,
            ).strip()
            if body_stripped and len(body_stripped) > 5:
                is_boilerplate = False

        if is_boilerplate:
            function_generation_events.append(
                {
                    "event_index": index,
                    "line_count": len([line for line in new_fragment.split("\n") if line.strip()]),
                    "char_count": len(new_fragment),
                    "reason": "boilerplate function generation (IDE stub)",
                    "newFragment": new_fragment,
                    "function_names": function_names,
                }
            )

        current_state = _apply_edit(current_state, event, crlf_mode=crlf_mode)

    return function_generation_events


def _detect_conflicting_snapshots(
    json_data: tuple[dict[str, Any], ...],
) -> list[dict[str, Any]]:
    conflicting_snapshots: list[dict[str, Any]] = []
    current_state = ""
    ordered_events = _ordered_edit_events(json_data)
    crlf_mode = _uses_crlf_offsets(ordered_events)

    for index, event in enumerate(ordered_events):
        old_fragment = _normalize_newlines(str(event.get("oldFragment", "")))

        if _is_snapshot(event):
            if index > 0 and old_fragment != current_state:
                diff_text = _snapshot_conflict_diff(current_state, old_fragment)
                conflicting_snapshots.append(
                    {
                        "event_index": index,
                        "line_count": max(len(diff_text.splitlines()), 1),
                        "char_count": len(diff_text),
                        "reason": "conflicting snapshot",
                        "diff": diff_text,
                    }
                )
            current_state = _advance_snapshot_state(
                current_state,
                old_fragment,
                allow_seed=index == 0,
            )
            continue

        try:
            current_state = _apply_edit(current_state, event, crlf_mode=crlf_mode)
        except ValueError as exc:
            if isinstance(exc, SuspiciousOffsetEncodingError):
                raise
            continue

    return conflicting_snapshots


def _detect_suspicious_events(
    json_data: tuple[dict[str, Any], ...],
    *,
    filter_function_generation: bool = False,
    reused_indices: set[int] | None = None,
) -> list[dict[str, Any]]:
    snapshot_conflict_events = _detect_conflicting_snapshots(json_data)
    external_paste_events = _detect_external_pastes(json_data, reused_indices)
    excluded_indices = {event["event_index"] for event in external_paste_events}
    for event in external_paste_events:
        excluded_indices.update(event.get("event_indices", []))

    autocomplete_events = _detect_complete_statement_autocomplete(
        json_data,
        excluded_indices,
        reused_indices,
    )
    rapid_paste_events = _detect_rapid_paste_sequences(json_data)

    if filter_function_generation:
        function_generation_events = _detect_function_generation(
            json_data,
            excluded_indices | {event["event_index"] for event in rapid_paste_events},
        )
        filtered_indices = {event["event_index"] for event in function_generation_events}
        autocomplete_events = [
            event
            for event in autocomplete_events
            if event["event_index"] not in filtered_indices
        ]

    suspicious_events = [
        *snapshot_conflict_events,
        *external_paste_events,
        *autocomplete_events,
        *rapid_paste_events,
    ]

    def _sort_key(event: dict[str, Any]) -> int:
        if "event_indices" in event:
            return int(event["event_indices"][0])
        return int(event.get("event_index", 0))

    return sorted(suspicious_events, key=_sort_key)


def detect_external_copypaste(
    json_data: tuple[dict[str, Any], ...],
    filter_function_generation: bool = False,
) -> list[dict[str, Any]]:
    """Detect suspicious pastes and autocomplete events."""
    return detect_external_copypaste_batch(
        {"__single_recording__": json_data},
        filter_function_generation=filter_function_generation,
    )["__single_recording__"]


def detect_external_copypaste_batch(
    json_data_by_scope: dict[object, tuple[dict[str, Any], ...]],
    filter_function_generation: bool = False,
) -> dict[object, list[dict[str, Any]]]:
    """Detect suspicious events with reuse awareness across a batch of recordings."""
    reused_indices_by_scope = _collect_reused_event_indices(json_data_by_scope)
    return {
        scope_id: _detect_suspicious_events(
            json_data,
            filter_function_generation=filter_function_generation,
            reused_indices=reused_indices_by_scope.get(scope_id),
        )
        for scope_id, json_data in json_data_by_scope.items()
    }


def verify(
    template: str,
    json_data: tuple[dict[str, Any], ...],
    filter_function_generation: bool = False,
) -> tuple[str, list[dict[str, Any]]]:
    """Verify template compatibility and return suspicious events."""
    verified_template = verify_template(template, json_data)
    suspicious_events = detect_external_copypaste(
        json_data,
        filter_function_generation=filter_function_generation,
    )
    return verified_template, suspicious_events


def _interval_minutes(interval: tuple[datetime, datetime]) -> float:
    return (interval[1] - interval[0]).total_seconds() / 60.0


def _merge_intervals(
    intervals: list[tuple[datetime, datetime]],
) -> list[tuple[datetime, datetime]]:
    """Merge overlapping or adjacent time intervals."""
    if not intervals:
        return []

    ordered = sorted(intervals, key=lambda interval: interval[0])
    merged = [ordered[0]]
    for start, end in ordered[1:]:
        last_start, last_end = merged[-1]
        if start <= last_end:
            merged[-1] = (last_start, max(last_end, end))
        else:
            merged.append((start, end))
    return merged


def check_time_limit(
    events: tuple[dict[str, Any], ...],
    time_limit_minutes: int | None,
) -> dict[str, Any] | None:
    """Compute active editing time for a single recording."""
    timestamped_events: list[tuple[dict[str, Any], str, datetime]] = []
    for event in _ordered_edit_events(events):
        timestamp_text = event.get("timestamp")
        if not timestamp_text:
            continue
        try:
            timestamped_events.append(
                (event, str(timestamp_text), parse_timestamp(str(timestamp_text)))
            )
        except ValueError:
            continue

    if not timestamped_events:
        return None

    first_timestamp = timestamped_events[0][1]
    last_timestamp = timestamped_events[-1][1]

    intervals: list[tuple[datetime, datetime]] = []
    previous_time: datetime | None = None
    for event, _, timestamp in timestamped_events:
        if _is_snapshot(event):
            previous_time = timestamp
            continue
        if previous_time is None:
            previous_time = timestamp
            continue
        if timestamp >= previous_time:
            intervals.append((previous_time, timestamp))
        previous_time = timestamp

    minutes_elapsed = round(sum(_interval_minutes(interval) for interval in intervals), 3)
    exceeds_limit = (
        time_limit_minutes is not None and minutes_elapsed > time_limit_minutes
    )
    return {
        "minutes_elapsed": minutes_elapsed,
        "first_timestamp": first_timestamp,
        "last_timestamp": last_timestamp,
        "exceeds_limit": exceeds_limit,
        "time_limit_minutes": time_limit_minutes,
        "active_intervals": intervals,
    }


def combine_time_info(
    time_infos: list[dict[str, Any] | None],
    time_limit_minutes: int | None,
) -> dict[str, Any] | None:
    """Combine time info across recordings without double-counting overlaps."""
    valid_infos = [info for info in time_infos if info is not None]
    if not valid_infos:
        return None

    merged_intervals = _merge_intervals(
        [
            interval
            for info in valid_infos
            for interval in info.get("active_intervals", [])
        ]
    )
    minutes_elapsed = round(
        sum(_interval_minutes(interval) for interval in merged_intervals),
        3,
    )

    first_timestamp = min(
        valid_infos,
        key=lambda info: parse_timestamp(str(info["first_timestamp"])),
    )["first_timestamp"]
    last_timestamp = max(
        valid_infos,
        key=lambda info: parse_timestamp(str(info["last_timestamp"])),
    )["last_timestamp"]

    overall_span = round(
        (
            parse_timestamp(str(last_timestamp))
            - parse_timestamp(str(first_timestamp))
        ).total_seconds()
        / 60.0,
        3,
    )

    exceeds_limit = (
        time_limit_minutes is not None and minutes_elapsed > time_limit_minutes
    )
    return {
        "minutes_elapsed": minutes_elapsed,
        "first_timestamp": first_timestamp,
        "last_timestamp": last_timestamp,
        "overall_span_minutes": overall_span,
        "exceeds_limit": exceeds_limit,
        "time_limit_minutes": time_limit_minutes,
        "active_intervals": merged_intervals,
        "file_count": len(valid_infos),
    }


def compare_submitted_file(
    reconstructed_code: str,
    submitted_file_path: Path,
) -> dict[str, Any]:
    """Compare reconstructed code against the submitted file."""
    try:
        submitted_code = submitted_file_path.read_text()
    except OSError as exc:
        return {
            "submitted_file": normalize_path_string(str(submitted_file_path)),
            "error": str(exc),
        }

    reconstructed_text = _normalize_newlines(reconstructed_code)
    submitted_text = _normalize_newlines(submitted_code)
    matches = reconstructed_text == submitted_text
    whitespace_only = False
    diff = ""

    if not matches:
        whitespace_only = is_only_whitespace_differences(
            reconstructed_text,
            submitted_text,
        )
        diff = "".join(
            difflib.unified_diff(
                reconstructed_text.splitlines(keepends=True),
                submitted_text.splitlines(keepends=True),
                fromfile="reconstructed",
                tofile="submitted",
            )
        )

    return {
        "submitted_file": normalize_path_string(str(submitted_file_path)),
        "matches": matches,
        "whitespace_only": whitespace_only,
        "diff": diff,
    }
