#!/usr/bin/env python3
"""Dependency-free forensic extractor for the DeepSeek Chrome HAR capture.

The script is intentionally conservative:

* it performs no network I/O;
* it never replays captured requests;
* it redacts credential values from generated indexes;
* it preserves embedded response bodies as content-addressed local blobs;
* it reconstructs DeepSeek's stateful SSE patch stream into readable turns.

Run from any directory with:

    python3 analyze_har.py
"""

from __future__ import annotations

import base64
import copy
import hashlib
import json
import math
import mimetypes
import os
import re
import statistics
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.parse import parse_qsl, urlsplit, urlunsplit


SCRIPT_DIR = Path(__file__).resolve().parent
BUNDLE_DIR = SCRIPT_DIR.parent
HAR_PATH = BUNDLE_DIR / "chat.deepseek.com.har"
DATA_DIR = SCRIPT_DIR / "data"
BLOB_DIR = SCRIPT_DIR / "blobs"

ANALYZER_VERSION = "1.0.0"
SENSITIVE_HEADER_NAMES = {
    "authorization",
    "cookie",
    "proxy-authorization",
    "x-api-key",
    "x-ds-pow-response",
    "x-hif-dliq",
    "x-hif-leim",
    "x-settings-token",
}

MIME_EXTENSIONS = {
    "application/json": ".json",
    "application/octet-stream": ".bin",
    "binary/octet-stream": ".bin",
    "text/event-stream": ".sse",
    "text/javascript": ".js",
    "application/javascript": ".js",
    "text/css": ".css",
    "text/html": ".html",
    "image/png": ".png",
    "image/webp": ".webp",
    "image/jpeg": ".jpg",
    "image/svg+xml": ".svg",
    "font/woff2": ".woff2",
    "font/woff": ".woff",
}

HOST_ROLES = {
    "chat.deepseek.com": "First-party chat, settings, file, and proof-of-work API",
    "fe-static.deepseek.com": "Versioned frontend JavaScript, WebAssembly, and fonts",
    "cdn.deepseek.com": "Site icons shown beside search results",
    "files.deepseeksvc.com": "Uploaded-file derivatives and previews",
    "gator.volces.com": "Volcengine collection/telemetry endpoint",
    "hif-leim.deepseek.com": "Opaque integrity/fingerprint token service (role inferred)",
    "hif-dliq.deepseek.com": "Opaque integrity/fingerprint token service (role inferred)",
}


def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def iso_from_timestamp(timestamp: float) -> str:
    return datetime.fromtimestamp(timestamp, timezone.utc).astimezone().isoformat(timespec="seconds")


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


def percentile(values: list[float], percent: float) -> float:
    if not values:
        return 0.0
    ordered = sorted(values)
    rank = (len(ordered) - 1) * percent
    low = math.floor(rank)
    high = math.ceil(rank)
    if low == high:
        return ordered[low]
    return ordered[low] * (high - rank) + ordered[high] * (rank - low)


def truncate_identifier(value: str | None) -> str | None:
    if not value:
        return value
    if len(value) <= 16:
        return value
    return f"{value[:8]}…{value[-4:]}"


def json_from_text(value: str | None) -> Any:
    if value is None:
        return None
    try:
        return json.loads(value)
    except (json.JSONDecodeError, TypeError):
        return None


def sanitized_url(url: str) -> tuple[str, list[str]]:
    parts = urlsplit(url)
    query_names = sorted({name for name, _ in parse_qsl(parts.query, keep_blank_values=True)})
    clean = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
    return clean, query_names


def response_bytes(content: dict[str, Any]) -> bytes | None:
    if "text" not in content:
        return None
    text = content.get("text")
    if text is None:
        return None
    if content.get("encoding") == "base64":
        try:
            return base64.b64decode(text, validate=False)
        except (ValueError, TypeError):
            return None
    return str(text).encode("utf-8")


def extension_for(mime: str, url: str) -> str:
    normalized = mime.split(";", 1)[0].strip().lower()
    if normalized in MIME_EXTENSIONS:
        return MIME_EXTENSIONS[normalized]
    path_suffix = Path(urlsplit(url).path).suffix
    if path_suffix and re.fullmatch(r"\.[A-Za-z0-9]{1,8}", path_suffix):
        return path_suffix.lower()
    guessed = mimetypes.guess_extension(normalized) if normalized else None
    return guessed or ".bin"


def parse_sse(text: str) -> list[dict[str, Any]]:
    frames: list[dict[str, Any]] = []
    normalized = text.replace("\r\n", "\n").replace("\r", "\n")
    for block in normalized.split("\n\n"):
        if not block.strip():
            continue
        event_name = "message"
        data_lines: list[str] = []
        for line in block.splitlines():
            if line.startswith(":"):
                continue
            if line.startswith("event:"):
                event_name = line[6:].strip()
            elif line.startswith("data:"):
                data_lines.append(line[5:].lstrip())
        if not data_lines:
            continue
        raw_data = "\n".join(data_lines)
        parsed = json_from_text(raw_data)
        frames.append({"event": event_name, "raw": raw_data, "data": parsed})
    return frames


def resolve_parent(root: Any, segments: list[str], create: bool = True) -> tuple[Any, str]:
    if not segments:
        return root, ""
    cursor = root
    for segment in segments[:-1]:
        if isinstance(cursor, list):
            index = len(cursor) - 1 if segment == "-1" else int(segment)
            cursor = cursor[index]
            continue
        if segment not in cursor and create:
            cursor[segment] = {}
        cursor = cursor[segment]
    return cursor, segments[-1]


def set_at_path(root: Any, path: str, operation: str | None, value: Any) -> None:
    segments = [segment for segment in path.split("/") if segment]
    if not segments:
        return
    parent, leaf = resolve_parent(root, segments)
    op = operation or "SET"

    if isinstance(parent, list):
        index = len(parent) - 1 if leaf == "-1" else int(leaf)
        if op == "APPEND":
            target = parent[index]
            if isinstance(target, str):
                parent[index] = target + str(value)
            elif isinstance(target, list):
                target.extend(value if isinstance(value, list) else [value])
            else:
                parent[index] = value
        else:
            parent[index] = value
        return

    if op == "APPEND":
        current = parent.get(leaf)
        if isinstance(current, str):
            parent[leaf] = current + str(value)
        elif isinstance(current, list):
            current.extend(value if isinstance(value, list) else [value])
        elif current is None:
            parent[leaf] = value if isinstance(value, (str, list)) else [value]
        else:
            parent[leaf] = value
    else:
        parent[leaf] = value


def apply_stream_patch(state: dict[str, Any], path: str, operation: str | None, value: Any) -> None:
    op = operation or "SET"
    if op != "BATCH":
        set_at_path(state, path, op, value)
        return

    base_segments = [segment for segment in path.split("/") if segment]
    for child in value if isinstance(value, list) else []:
        if not isinstance(child, dict) or "p" not in child:
            continue
        child_segments = [segment for segment in str(child["p"]).split("/") if segment]
        combined_path = "/".join(base_segments + child_segments)
        set_at_path(state, combined_path, child.get("o", "SET"), child.get("v"))


def reconstruct_completion(entry: dict[str, Any], turn_number: int) -> dict[str, Any]:
    request_payload = json_from_text(entry.get("request", {}).get("postData", {}).get("text")) or {}
    text = entry.get("response", {}).get("content", {}).get("text") or ""
    frames = parse_sse(text)
    state: dict[str, Any] = {}
    carried_path: str | None = None
    carried_operation: str | None = None
    named_events = Counter()
    ready_payload: dict[str, Any] = {}

    for frame in frames:
        event_name = frame["event"]
        named_events[event_name] += 1
        payload = frame["data"]
        if event_name == "ready" and isinstance(payload, dict):
            ready_payload = payload
        if event_name != "message" or not isinstance(payload, dict) or "v" not in payload:
            continue

        value = payload.get("v")
        if isinstance(value, dict) and "response" in value:
            state.update(copy.deepcopy(value))
            carried_path = None
            carried_operation = None
            continue

        if "p" in payload:
            carried_path = str(payload["p"])
            if "o" not in payload:
                carried_operation = "SET"
        if "o" in payload:
            carried_operation = str(payload["o"])
        if carried_path:
            apply_stream_patch(state, carried_path, carried_operation, copy.deepcopy(value))

    response = state.get("response") or {}
    fragments = response.get("fragments") or []
    response_fragments = [fragment for fragment in fragments if fragment.get("type") == "RESPONSE"]
    search_fragments = [fragment for fragment in fragments if fragment.get("type") == "SEARCH"]
    assistant_text = "\n\n".join(str(fragment.get("content") or "") for fragment in response_fragments).strip()
    searches: list[dict[str, Any]] = []
    for fragment in search_fragments:
        query_items = fragment.get("queries") or []
        queries = [item.get("query") if isinstance(item, dict) else str(item) for item in query_items]
        results = []
        for result in fragment.get("results") or []:
            if not isinstance(result, dict):
                continue
            result_url = result.get("url") or ""
            results.append(
                {
                    "url": result_url,
                    "domain": urlsplit(result_url).netloc,
                    "title": result.get("title"),
                    "snippet": result.get("snippet"),
                    "cite_index": result.get("cite_index"),
                    "published_at": result.get("published_at"),
                }
            )
        searches.append({"queries": queries, "results": results, "status": fragment.get("status")})

    if turn_number <= 2:
        phase = "Search-mechanics probe"
        sensitivity = "low"
    elif turn_number == 3:
        phase = "Uploaded-image interpretation"
        sensitivity = "high"
    elif turn_number <= 9:
        phase = "People and operating-scenario synthesis"
        sensitivity = "high"
    else:
        phase = "Family, investor, and research-network graph"
        sensitivity = "high"

    return {
        "turn": turn_number,
        "started_at": entry.get("startedDateTime"),
        "duration_ms": round(float(entry.get("time") or 0), 3),
        "phase": phase,
        "sensitivity": sensitivity,
        "session_id": truncate_identifier(request_payload.get("chat_session_id")),
        "session_fingerprint": sha256_bytes(str(request_payload.get("chat_session_id", "")).encode())[:12],
        "parent_message_id": request_payload.get("parent_message_id"),
        "request_message_id": ready_payload.get("request_message_id"),
        "response_message_id": ready_payload.get("response_message_id"),
        "model_type": ready_payload.get("model_type"),
        "prompt": request_payload.get("prompt") or "",
        "prompt_chars": len(request_payload.get("prompt") or ""),
        "assistant": assistant_text,
        "assistant_chars": len(assistant_text),
        "ref_file_count": len(request_payload.get("ref_file_ids") or []),
        "thinking_enabled": bool(request_payload.get("thinking_enabled")),
        "search_enabled": bool(request_payload.get("search_enabled")),
        "search_triggered": bool(search_fragments) or bool(response.get("search_triggered")),
        "searches": searches,
        "search_query_count": sum(len(item["queries"]) for item in searches),
        "search_result_count": sum(len(item["results"]) for item in searches),
        "response_status": response.get("status"),
        "quasi_status": response.get("quasi_status"),
        "accumulated_token_usage": response.get("accumulated_token_usage"),
        "response_fragment_count": len(response_fragments),
        "sse_frame_count": len(frames),
        "sse_event_counts": dict(sorted(named_events.items())),
    }


def file_role(path: Path) -> str:
    relative = path.relative_to(BUNDLE_DIR)
    name = path.name
    if name.endswith(".har"):
        return "Primary Chrome HAR source"
    if name == "curl-config.txt":
        return "Reproducible configuration used to fetch cited pages"
    if name.endswith(".headers.txt"):
        return "Captured HTTP response headers for a cited page"
    if relative.parts and relative.parts[0] == "translations_en":
        return "English translation derived from a fetched community article"
    if relative.parts and relative.parts[0] == "html_docs":
        return "Previously generated local reading interface"
    if name.endswith(".html"):
        return "Raw fetched HTML from a cited community article"
    if name == ".DS_Store":
        return "macOS Finder metadata; not evidentiary source content"
    return "Bundle file"


def build_source_manifest() -> tuple[list[dict[str, Any]], dict[str, Any]]:
    sources: list[dict[str, Any]] = []
    for path in sorted(BUNDLE_DIR.rglob("*")):
        if not path.is_file():
            continue
        relative = path.relative_to(BUNDLE_DIR)
        if relative.parts[0] in {"har_analysis", "report"}:
            continue
        stat = path.stat()
        sources.append(
            {
                "path": relative.as_posix(),
                "size": stat.st_size,
                "sha256": sha256_file(path),
                "modified_at": iso_from_timestamp(stat.st_mtime),
                "role": file_role(path),
            }
        )

    scaffold_dir = BUNDLE_DIR / "report"
    scaffold_summary = {"present": scaffold_dir.exists(), "file_count": 0, "size": 0}
    if scaffold_dir.exists():
        for path in scaffold_dir.rglob("*"):
            if path.is_file():
                scaffold_summary["file_count"] += 1
                try:
                    scaffold_summary["size"] += path.stat().st_size
                except OSError:
                    pass
    return sources, scaffold_summary


def decode_base64_json(value: str) -> Any:
    try:
        return json.loads(base64.b64decode(value + "===").decode("utf-8"))
    except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
        return None


def token_analysis(entries: list[dict[str, Any]]) -> dict[str, Any]:
    headers: dict[str, list[str]] = defaultdict(list)
    response_headers: dict[str, list[str]] = defaultdict(list)
    for entry in entries:
        for header in entry.get("request", {}).get("headers", []):
            name = str(header.get("name", "")).lower()
            if name in SENSITIVE_HEADER_NAMES:
                headers[name].append(str(header.get("value", "")))
        for header in entry.get("response", {}).get("headers", []):
            name = str(header.get("name", "")).lower()
            if name in {"x-ds-sse-heartbeat-timeout-secs", "x-hif-ttl", "x-fetch-after-sec"}:
                response_headers[name].append(str(header.get("value", "")))

    pow_solutions = [decoded for value in headers.get("x-ds-pow-response", []) if (decoded := decode_base64_json(value))]
    pow_answers = [solution.get("answer") for solution in pow_solutions if isinstance(solution.get("answer"), int)]
    pow_targets = Counter(solution.get("target_path") for solution in pow_solutions)

    challenge_items: list[dict[str, Any]] = []
    for entry in entries:
        if "/create_pow_challenge" not in entry.get("request", {}).get("url", ""):
            continue
        decoded = json_from_text(entry.get("response", {}).get("content", {}).get("text"))
        try:
            challenge = decoded["data"]["biz_data"]["challenge"]
        except (TypeError, KeyError):
            continue
        challenge_items.append(challenge)

    settings_values = headers.get("x-settings-token", [])
    settings_value = settings_values[0] if settings_values else ""
    settings_segments = settings_value.split(".") if settings_value else []
    settings_header = None
    if settings_segments:
        try:
            padded = settings_segments[0] + "=" * (-len(settings_segments[0]) % 4)
            settings_header = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
        except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
            settings_header = None

    hif_summary = {}
    for name in ("x-hif-leim", "x-hif-dliq"):
        values = headers.get(name, [])
        decoded_lengths = []
        for value in values:
            try:
                decoded_lengths.append(len(base64.b64decode(value + "===")))
            except ValueError:
                pass
        hif_summary[name] = {
            "count": len(values),
            "unique": len(set(values)),
            "encoded_lengths": sorted(set(map(len, values))),
            "decoded_lengths": sorted(set(decoded_lengths)),
            "value_fingerprints": sorted({sha256_bytes(value.encode())[:12] for value in values}),
        }

    return {
        "request_cookie_header_count": len(headers.get("cookie", [])),
        "authorization_header_count": len(headers.get("authorization", [])),
        "pow": {
            "challenge_count": len(challenge_items),
            "solution_count": len(pow_solutions),
            "algorithms": sorted({item.get("algorithm") for item in challenge_items if item.get("algorithm")}),
            "difficulties": sorted({item.get("difficulty") for item in challenge_items if item.get("difficulty") is not None}),
            "expiry_windows_ms": sorted({item.get("expire_after") for item in challenge_items if item.get("expire_after") is not None}),
            "targets_challenged": dict(sorted(Counter(item.get("target_path") for item in challenge_items).items())),
            "targets_solved": dict(sorted(pow_targets.items())),
            "answer_min": min(pow_answers) if pow_answers else None,
            "answer_mean": round(statistics.mean(pow_answers), 3) if pow_answers else None,
            "answer_median": statistics.median(pow_answers) if pow_answers else None,
            "answer_max": max(pow_answers) if pow_answers else None,
            "answers_within_declared_difficulty": bool(
                pow_answers
                and challenge_items
                and all(0 <= answer < int(challenge_items[0].get("difficulty") or 0) for answer in pow_answers)
            ),
            "normalized_answer_mean": round(
                statistics.mean(pow_answers) / int(challenge_items[0].get("difficulty") or 1), 6
            ) if pow_answers and challenge_items else None,
            "decoded_example": {
                "algorithm": pow_solutions[0].get("algorithm"),
                "challenge": truncate_identifier(pow_solutions[0].get("challenge")),
                "salt": truncate_identifier(pow_solutions[0].get("salt")),
                "answer": pow_solutions[0].get("answer"),
                "signature": truncate_identifier(pow_solutions[0].get("signature")),
                "target_path": pow_solutions[0].get("target_path"),
            } if pow_solutions else None,
        },
        "settings_token": {
            "count": len(settings_values),
            "unique": len(set(settings_values)),
            "compact_segments": len(settings_segments),
            "protected_header": settings_header,
            "decryptable_from_har": False,
            "reason": "The compact JWE uses direct symmetric A256GCM encryption; no content-encryption key is present in the HAR.",
        },
        "hif": hif_summary,
        "server_hints": {
            name: sorted(set(values))
            for name, values in sorted(response_headers.items())
        },
    }


def analyze() -> dict[str, Any]:
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    BLOB_DIR.mkdir(parents=True, exist_ok=True)

    with HAR_PATH.open("r", encoding="utf-8") as handle:
        har = json.load(handle)
    log = har.get("log", {})
    entries = log.get("entries", [])
    if not entries:
        raise SystemExit("HAR has no entries")

    source_manifest, scaffold_summary = build_source_manifest()
    har_sha256 = sha256_file(HAR_PATH)
    first_dt = parse_iso(entries[0]["startedDateTime"])
    last_dt = max(
        parse_iso(entry["startedDateTime"]) + (datetime.fromtimestamp(float(entry.get("time") or 0) / 1000, timezone.utc) - datetime.fromtimestamp(0, timezone.utc))
        for entry in entries
    )

    hosts: dict[str, dict[str, Any]] = {}
    endpoints: dict[str, dict[str, Any]] = {}
    mime_counts: dict[str, dict[str, int]] = {}
    status_counts = Counter()
    methods = Counter()
    timeline: list[dict[str, Any]] = []
    sensitive_header_counts = Counter()
    query_parameter_counts = Counter()
    total_declared_bytes = 0
    entries_with_body = 0
    entries_without_body = 0
    declared_bytes_with_body = 0
    declared_bytes_without_body = 0
    embedded_actual_bytes = 0
    missing_body_mimes = Counter()

    blob_records: dict[str, dict[str, Any]] = {}
    blob_references = 0
    image_blobs: list[dict[str, Any]] = []

    for index, entry in enumerate(entries, start=1):
        request = entry.get("request", {})
        response = entry.get("response", {})
        content = response.get("content", {})
        raw_url = request.get("url", "")
        clean_url, query_names = sanitized_url(raw_url)
        split = urlsplit(raw_url)
        host = split.netloc
        method = request.get("method", "")
        status = int(response.get("status") or 0)
        mime = str(content.get("mimeType") or "unknown").split(";", 1)[0].lower()
        declared_size = max(0, int(content.get("size") or 0))
        duration_ms = float(entry.get("time") or 0)
        total_declared_bytes += declared_size
        status_counts[str(status)] += 1
        methods[method] += 1
        for name in query_names:
            query_parameter_counts[name] += 1
        for header in request.get("headers", []):
            header_name = str(header.get("name", "")).lower()
            if header_name in SENSITIVE_HEADER_NAMES:
                sensitive_header_counts[header_name] += 1

        host_item = hosts.setdefault(
            host,
            {
                "host": host,
                "role": HOST_ROLES.get(host, "Third-party or ancillary host"),
                "request_count": 0,
                "declared_response_bytes": 0,
                "durations_ms": [],
                "status_counts": Counter(),
                "methods": Counter(),
            },
        )
        host_item["request_count"] += 1
        host_item["declared_response_bytes"] += declared_size
        host_item["durations_ms"].append(duration_ms)
        host_item["status_counts"][str(status)] += 1
        host_item["methods"][method] += 1

        endpoint_key = f"{method} {clean_url}"
        endpoint_item = endpoints.setdefault(
            endpoint_key,
            {
                "method": method,
                "url": clean_url,
                "host": host,
                "path": split.path,
                "query_parameter_names": set(),
                "request_count": 0,
                "declared_response_bytes": 0,
                "durations_ms": [],
                "status_counts": Counter(),
                "mimes": Counter(),
            },
        )
        endpoint_item["query_parameter_names"].update(query_names)
        endpoint_item["request_count"] += 1
        endpoint_item["declared_response_bytes"] += declared_size
        endpoint_item["durations_ms"].append(duration_ms)
        endpoint_item["status_counts"][str(status)] += 1
        endpoint_item["mimes"][mime] += 1

        mime_item = mime_counts.setdefault(mime, {"request_count": 0, "declared_response_bytes": 0, "body_count": 0})
        mime_item["request_count"] += 1
        mime_item["declared_response_bytes"] += declared_size

        body = response_bytes(content)
        blob_sha = None
        if body is None:
            entries_without_body += 1
            declared_bytes_without_body += declared_size
            missing_body_mimes[mime] += declared_size
        else:
            entries_with_body += 1
            declared_bytes_with_body += declared_size
            embedded_actual_bytes += len(body)
            mime_item["body_count"] += 1
            blob_references += 1
            blob_sha = sha256_bytes(body)
            extension = extension_for(mime, raw_url)
            blob_path = BLOB_DIR / f"{blob_sha}{extension}"
            if not blob_path.exists():
                blob_path.write_bytes(body)
            record = blob_records.setdefault(
                blob_sha,
                {
                    "sha256": blob_sha,
                    "size": len(body),
                    "path": f"blobs/{blob_path.name}",
                    "mimes": set(),
                    "encodings": set(),
                    "reference_count": 0,
                    "sources": [],
                },
            )
            record["mimes"].add(mime)
            record["encodings"].add(content.get("encoding") or "plain")
            record["reference_count"] += 1
            if len(record["sources"]) < 8:
                record["sources"].append({"entry": index, "method": method, "url": clean_url})
            if mime.startswith("image/"):
                image_blobs.append(
                    {
                        "sha256": blob_sha,
                        "size": len(body),
                        "mime": mime,
                        "path": f"blobs/{blob_path.name}",
                        "source_url": clean_url,
                    }
                )

        timeline.append(
            {
                "entry": index,
                "started_at": entry.get("startedDateTime"),
                "offset_ms": round((parse_iso(entry["startedDateTime"]) - first_dt).total_seconds() * 1000, 3),
                "method": method,
                "url": clean_url,
                "query_parameter_names": query_names,
                "host": host,
                "status": status,
                "mime": mime,
                "declared_size": declared_size,
                "duration_ms": round(duration_ms, 3),
                "body_sha256": blob_sha,
            }
        )

    host_rows = []
    for item in hosts.values():
        durations = item.pop("durations_ms")
        item["status_counts"] = dict(sorted(item["status_counts"].items()))
        item["methods"] = dict(sorted(item["methods"].items()))
        item["median_ms"] = round(statistics.median(durations), 3)
        item["p95_ms"] = round(percentile(durations, 0.95), 3)
        host_rows.append(item)
    host_rows.sort(key=lambda item: (-item["request_count"], item["host"]))

    endpoint_rows = []
    for item in endpoints.values():
        durations = item.pop("durations_ms")
        item["query_parameter_names"] = sorted(item["query_parameter_names"])
        item["status_counts"] = dict(sorted(item["status_counts"].items()))
        item["mimes"] = dict(sorted(item["mimes"].items()))
        item["median_ms"] = round(statistics.median(durations), 3)
        item["p95_ms"] = round(percentile(durations, 0.95), 3)
        endpoint_rows.append(item)
    endpoint_rows.sort(key=lambda item: (-item["request_count"], item["url"]))

    blob_rows = []
    for record in blob_records.values():
        record["mimes"] = sorted(record["mimes"])
        record["encodings"] = sorted(record["encodings"])
        blob_rows.append(record)
    blob_rows.sort(key=lambda item: (-item["size"], item["sha256"]))

    unique_images = {item["sha256"]: item for item in image_blobs}
    completion_entries = [entry for entry in entries if "/api/v0/chat/completion" in entry.get("request", {}).get("url", "")]
    turns = [reconstruct_completion(entry, index) for index, entry in enumerate(completion_entries, start=1)]
    search_turns = [turn for turn in turns if turn["search_triggered"]]
    all_search_results = [result for turn in turns for search in turn["searches"] for result in search["results"]]
    search_domains = Counter(result["domain"] for result in all_search_results if result.get("domain"))

    def request_header_values(name: str) -> list[str]:
        return sorted({
            str(header.get("value", ""))
            for entry in entries
            for header in entry.get("request", {}).get("headers", [])
            if str(header.get("name", "")).lower() == name
        })

    client_profile = {
        "version": request_header_values("x-client-version"),
        "bundle_id": request_header_values("x-client-bundle-id"),
        "platform": request_header_values("x-client-platform"),
        "locale": request_header_values("x-client-locale"),
        "timezone_offset_seconds": request_header_values("x-client-timezone-offset"),
        "user_agent": request_header_values("user-agent"),
    }

    settings_by_scope: dict[str, dict[str, Any]] = {}
    for entry in entries:
        request_url = entry.get("request", {}).get("url", "")
        if "/api/v0/client/settings" not in request_url:
            continue
        scope = dict(parse_qsl(urlsplit(request_url).query, keep_blank_values=True)).get("scope", "unknown")
        if scope in settings_by_scope:
            continue
        body = response_bytes(entry.get("response", {}).get("content", {}))
        if body is None:
            continue
        try:
            parsed_settings = json.loads(body.decode("utf-8"))["data"]["biz_data"]
        except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError):
            continue
        settings_by_scope[scope] = parsed_settings

    main_settings = settings_by_scope.get("main", {}).get("settings", {})
    model_settings = settings_by_scope.get("model", {}).get("settings", {})

    def setting_value(settings: dict[str, Any], name: str) -> Any:
        item = settings.get(name)
        return item.get("value") if isinstance(item, dict) else None

    model_configs = []
    for model in setting_value(model_settings, "model_configs") or []:
        file_feature = model.get("file_feature") or {}
        model_configs.append({
            "model_type": model.get("model_type"),
            "name": model.get("name"),
            "description": model.get("description"),
            "is_default": model.get("is_default"),
            "enabled": model.get("enabled"),
            "switchable": model.get("switchable"),
            "search_available": model.get("search_feature") is not None,
            "file_available": model.get("file_feature") is not None,
            "vision": file_feature.get("vision"),
            "input_character_limit": model.get("input_character_limit"),
            "file_token_limit": file_feature.get("token_limit"),
            "max_input_file_count": file_feature.get("max_input_file_count"),
            "max_upload_file_size": file_feature.get("max_upload_file_size"),
        })

    settings_snapshot = {
        "version": settings_by_scope.get("main", {}).get("version"),
        "models": model_configs,
        "flags": {
            "search_state_on_launch": setting_value(main_settings, "search_state_on_launch"),
            "search_state_on_login": setting_value(main_settings, "search_state_on_login"),
            "allow_file_with_search": setting_value(main_settings, "allow_file_with_search"),
            "conversation_search_enabled": setting_value(main_settings, "conversation_search_enabled"),
            "volcengine_enabled": setting_value(main_settings, "volcengine_enabled"),
            "pow_prefetch": setting_value(main_settings, "pow_prefetch"),
            "pow_prefetch_count": setting_value(main_settings, "pow_prefetch_count"),
            "picture_compress_format": setting_value(main_settings, "picture_compress_format"),
            "photo_picker_compress_ratio": setting_value(main_settings, "photo_picker_compress_ratio"),
            "normal_history_and_file_token_limit": setting_value(main_settings, "normal_history_and_file_token_limit"),
        },
    }

    upload_metadata = []
    file_state: dict[str, Any] = {}

    def merge_file_state(candidate: Any) -> None:
        nonlocal file_state
        if not isinstance(candidate, dict):
            return
        allowed = {
            "file_name",
            "file_size",
            "status",
            "is_image",
            "audit_result",
            "width",
            "height",
            "token_usage",
            "model_kind",
        }
        for key in allowed:
            if candidate.get(key) is not None:
                file_state[key] = candidate[key]
        if candidate.get("id"):
            file_state["id"] = truncate_identifier(candidate["id"])

    for entry in entries:
        request_url = entry.get("request", {}).get("url", "")
        if "/api/v0/file/" in request_url:
            api_json = json_from_text(entry.get("response", {}).get("content", {}).get("text"))
            try:
                api_data = api_json["data"]["biz_data"]
            except (TypeError, KeyError):
                api_data = None
            if isinstance(api_data, dict):
                merge_file_state(api_data)
                for item in api_data.get("files") or []:
                    merge_file_state(item)

        if "/api/v0/chat/completion" in request_url:
            for frame in parse_sse(entry.get("response", {}).get("content", {}).get("text") or ""):
                if frame.get("event") == "update_file":
                    merge_file_state(frame.get("data"))

        if "/api/v0/file/upload_file" not in request_url:
            continue
        request_text = entry.get("request", {}).get("postData", {}).get("text") or ""
        filename_match = re.search(r'filename="([^"]+)"', request_text)
        response_json = json_from_text(entry.get("response", {}).get("content", {}).get("text"))
        data = None
        try:
            data = response_json["data"]["biz_data"]
        except (TypeError, KeyError):
            pass
        if data:
            upload_metadata.append({"filename": filename_match.group(1) if filename_match else data.get("file_name")})

    if upload_metadata:
        upload_metadata = [
            {
                **upload_metadata[0],
                "reported_size": file_state.get("file_size"),
                "final_status": file_state.get("status"),
                "is_image": file_state.get("is_image"),
                "audit_result": file_state.get("audit_result"),
                "width": file_state.get("width"),
                "height": file_state.get("height"),
                "token_usage": file_state.get("token_usage"),
                "model_kind": file_state.get("model_kind"),
                "file_id": file_state.get("id"),
                "exact_request_blob_recoverable": False,
                "reason": "Chrome serialized the multipart request body as lossy Unicode text; exact original PNG bytes cannot be proven from this HAR. Exact service-returned WebP derivatives are preserved separately.",
            }
        ]

    report = {
        "meta": {
            "title": "DeepSeek Session / HAR Forensic Atlas",
            "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
            "analyzer_version": ANALYZER_VERSION,
            "network_used": False,
            "dependencies": "Python standard library only",
            "privacy": "Local-only report. Contains sensitive personal material recovered from the captured chat.",
        },
        "source": {
            "har_path": HAR_PATH.name,
            "har_size": HAR_PATH.stat().st_size,
            "har_sha256": har_sha256,
            "har_version": log.get("version"),
            "creator": log.get("creator"),
            "page_count": len(log.get("pages") or []),
            "entry_count": len(entries),
            "capture_started_at": entries[0].get("startedDateTime"),
            "capture_ended_at": last_dt.isoformat().replace("+00:00", "Z"),
            "capture_duration_seconds": round((last_dt - first_dt).total_seconds(), 3),
            "bundle_files": source_manifest,
            "abandoned_scaffold": scaffold_summary,
        },
        "coverage": {
            "entries_with_embedded_response_body": entries_with_body,
            "entries_without_embedded_response_body": entries_without_body,
            "declared_response_bytes": total_declared_bytes,
            "declared_bytes_with_body": declared_bytes_with_body,
            "declared_bytes_without_body": declared_bytes_without_body,
            "embedded_actual_bytes": embedded_actual_bytes,
            "unique_blob_bytes": sum(item["size"] for item in blob_rows),
            "blob_references": blob_references,
            "unique_blobs": len(blob_rows),
            "missing_body_bytes_by_mime": dict(sorted(missing_body_mimes.items(), key=lambda item: -item[1])),
        },
        "network": {
            "client_profile": client_profile,
            "settings_snapshot": settings_snapshot,
            "status_counts": dict(sorted(status_counts.items())),
            "method_counts": dict(sorted(methods.items())),
            "hosts": host_rows,
            "endpoints": endpoint_rows,
            "mimes": [
                {"mime": mime, **values}
                for mime, values in sorted(mime_counts.items(), key=lambda item: -item[1]["request_count"])
            ],
            "query_parameter_name_counts": dict(sorted(query_parameter_counts.items(), key=lambda item: (-item[1], item[0]))),
            "sensitive_header_name_counts": dict(sorted(sensitive_header_counts.items())),
            "timeline": timeline,
        },
        "session": {
            "completion_count": len(turns),
            "unique_session_fingerprints": sorted({turn["session_fingerprint"] for turn in turns}),
            "starts_mid_conversation": bool(turns and (turns[0].get("parent_message_id") or 0) > 0),
            "first_parent_message_id": turns[0].get("parent_message_id") if turns else None,
            "thinking_enabled_count": sum(1 for turn in turns if turn["thinking_enabled"]),
            "search_enabled_count": sum(1 for turn in turns if turn["search_enabled"]),
            "search_triggered_count": len(search_turns),
            "search_query_count": sum(turn["search_query_count"] for turn in turns),
            "search_result_count": sum(turn["search_result_count"] for turn in turns),
            "search_result_domains": dict(sorted(search_domains.items(), key=lambda item: (-item[1], item[0]))),
            "turns": turns,
        },
        "tokens": token_analysis(entries),
        "files": {
            "uploads": upload_metadata,
            "image_blobs": sorted(unique_images.values(), key=lambda item: -item["size"]),
        },
        "claim_audit": [
            {
                "claim": "DeepSeek uses a native tool named web_search_20250305",
                "verdict": "unsupported",
                "evidence": "The HAR exposes SEARCH fragments but no internal tool name. The cited GitHub results describe a third-party MCP wrapper, not DeepSeek's proprietary backend.",
            },
            {
                "claim": "Full pages are encrypted, decrypted server-side, and injected into model context",
                "verdict": "unobservable",
                "evidence": "Only result URLs, titles, snippets, and cite indices cross the browser boundary. Backend fetches and model-context construction are absent from the recording.",
            },
            {
                "claim": "The retriever uses BERT/bge-large-en, BM25, HNSW, expansion_ratio=0.3, and rewrite_attempts=3",
                "verdict": "unsupported",
                "evidence": "Those exact internals and parameters do not occur in request or response metadata. They are synthesized from community search results with no first-party corroboration in the bundle.",
            },
            {
                "claim": "Search is exposed through MCP / JSON-RPC over stdio",
                "verdict": "conflated",
                "evidence": "The captured product uses HTTPS plus SSE. MCP appears in third-party result titles and is presented by the answer as if it were DeepSeek's own browser protocol.",
            },
            {
                "claim": "The selected search models are deepseek-v4-flash and deepseek-v4-pro",
                "verdict": "unsupported",
                "evidence": "Completion requests carry model_type=null and ready events report model_type=default; reconstructed response objects leave model empty.",
            },
            {
                "claim": "The browser receives generated queries and ranked source cards inside the completion stream",
                "verdict": "supported",
                "evidence": "Two SSE streams contain SEARCH fragments with 9 generated queries and 27 ordered result records before answer-token APPEND patches.",
            },
        ],
        "findings": [
            {
                "level": "observed",
                "title": "The conversation payload is not encrypted in the HAR",
                "body": "User prompts are ordinary JSON. Assistant output is ordinary text/event-stream data using compact state patches. The 16 captured assistant messages can be reconstructed without keys.",
            },
            {
                "level": "observed",
                "title": "Search orchestration is server-side from the browser's point of view",
                "body": "Search queries, ranked result cards, snippets, and citation indices arrive inside the same completion SSE stream. The browser never contacts Baidu or another search engine for those results.",
            },
            {
                "level": "limit",
                "title": "The HAR cannot reveal DeepSeek's internal retriever or token-level grounding",
                "body": "The capture shows the browser/API boundary only. Backend fetches, indexes, rerankers, prompt construction, and model attention are outside the recording.",
            },
            {
                "level": "observed",
                "title": "Proof-of-work is transparent, not encrypted",
                "body": "The x-ds-pow-response value is base64-encoded JSON containing the algorithm, challenge, salt, solved integer answer, signature, and target path.",
            },
            {
                "level": "observed",
                "title": "The settings token is genuinely encrypted",
                "body": "Its compact JWE protected header decodes to alg=dir and enc=A256GCM. The symmetric key is absent, so the ciphertext cannot be decrypted from this capture alone.",
            },
            {
                "level": "caution",
                "title": "The fetched architecture articles are weak evidence",
                "body": "They are Baidu Cloud community posts, not official DeepSeek documentation. Their confident architecture claims and precise metrics should be treated as unverified secondary material.",
            },
            {
                "level": "caution",
                "title": "DeepSeek's self-description conflates retrieved projects with its own internals",
                "body": "Turn 2 promotes a third-party MCP wrapper and community configuration examples into claims about DeepSeek's native tool name, encryption flow, retriever, model variants, and protocol. The HAR supports the outer search-card flow, not those internal claims.",
            },
            {
                "level": "limit",
                "title": "This is a mid-session API capture, not a complete page archive",
                "body": "The HAR has zero page records, no document navigation, and begins with parent_message_id=6. Earlier conversation state, initial authentication, and page bootstrap traffic are outside the file.",
            },
            {
                "level": "inferred",
                "title": "The screenshot took a text-extraction path, not the Vision model",
                "body": "Turn 3 references a parsed file ID; the file service reports 228 tokens, while the active Instant configuration advertises vision=false. Search queries mirror names and topics in the screenshot. That strongly indicates OCR/text extraction before search and generation.",
            },
            {
                "level": "inferred",
                "title": "DeepSeekHashV1 appears to scan a fixed 144,000-candidate work range",
                "body": "All 17 solved answers fall between 0 and 143,999, with mean 71,033—49.3% of the declared difficulty. That is consistent with locating a target or extremum inside a fixed range. The exact SHA3 input and acceptance rule remain unavailable because the JS/WASM bodies are omitted.",
            },
        ],
    }

    blob_manifest = {
        "source_har_sha256": har_sha256,
        "generated_at": report["meta"]["generated_at"],
        "note": "Each file is the exact decoded response.content.text payload when Chrome embedded one. base64 is decoded before hashing.",
        "blobs": blob_rows,
    }
    source_output = {
        "generated_at": report["meta"]["generated_at"],
        "bundle_root": BUNDLE_DIR.name,
        "excluded": ["har_analysis/ (generated report)", "report/ (abandoned scaffold)"],
        "files": source_manifest,
    }

    (DATA_DIR / "report.json").write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    (DATA_DIR / "report-data.js").write_text(
        "window.__HAR_REPORT__ = " + json.dumps(report, ensure_ascii=False).replace("</", "<\\/") + ";\n",
        encoding="utf-8",
    )
    (DATA_DIR / "blob-manifest.json").write_text(json.dumps(blob_manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    (DATA_DIR / "blob-data.js").write_text(
        "window.__BLOB_MANIFEST__ = " + json.dumps(blob_manifest, ensure_ascii=False).replace("</", "<\\/") + ";\n",
        encoding="utf-8",
    )
    (DATA_DIR / "source-manifest.json").write_text(json.dumps(source_output, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    return report


def main() -> None:
    report = analyze()
    summary = {
        "har_sha256": report["source"]["har_sha256"],
        "entries": report["source"]["entry_count"],
        "completions": report["session"]["completion_count"],
        "search_triggered": report["session"]["search_triggered_count"],
        "unique_blobs": report["coverage"]["unique_blobs"],
        "output": str(SCRIPT_DIR / "index.html"),
    }
    print(json.dumps(summary, indent=2))


if __name__ == "__main__":
    main()
