from __future__ import annotations

import json
import unittest
from datetime import datetime, timedelta, timezone

from arr_web.job_trace import build_job_trace


class JobTraceTests(unittest.TestCase):
    def setUp(self) -> None:
        self.started = datetime(2026, 7, 29, 11, 0, tzinfo=timezone.utc)

    def test_programmatic_success_exposes_persisted_safe_trace(self) -> None:
        finished = self.started + timedelta(seconds=8)
        trace = build_job_trace(
            {
                "job_id": "arrjob-trace-001",
                "run_status": "accepted",
                "public_status": "succeeded",
                "delivery_mode": "artifact_callback",
                "business_date": "2026-07-29",
                "created_at": self.started,
                "updated_at": finished,
                "validated_at": finished,
                "finished_at": finished,
                "filename": "source.xml",
                "byte_size": 2048,
                "source_sha256": "a" * 64,
                "requested_processor_version": "4.0.0",
                "delivered_processor_version": "4.0.0",
                "object_key": "must/not/escape",
            },
            attempts=[
                {
                    "attempt_no": 1,
                    "attempt_status": "succeeded",
                    "remote_run_id": None,
                    "created_at": self.started + timedelta(seconds=1),
                    "started_at": self.started + timedelta(seconds=2),
                    "finished_at": finished,
                }
            ],
            deliveries=[
                {
                    "delivery_key": "local-arrjob-trace-001-a1",
                    "attempt_no": 1,
                    "delivery_status": "committed",
                    "result_status": "success",
                    "processor_version": "4.0.0",
                    "business_date": "2026-07-29",
                    "daily_version_id": 41,
                    "received_at": self.started + timedelta(seconds=5),
                    "validated_at": self.started + timedelta(seconds=6),
                    "committed_at": finished,
                }
            ],
            versions=[
                {
                    "daily_version_id": 41,
                    "business_date": "2026-07-29",
                    "version_no": 2,
                    "version_status": "active",
                    "result_delivery_mode": "artifact_callback",
                    "source_rows": 20,
                    "retained_rows": 17,
                    "excluded_rate_code_rows": 1,
                    "duplicate_rows": 2,
                    "validation_failed_rows": 0,
                    "price_unmatched_rows": 0,
                    "created_at": self.started + timedelta(seconds=7),
                    "validated_at": finished,
                    "activated_at": finished,
                }
            ],
            outbox_events=[
                {
                    "event_key": "processing-run:1:accepted",
                    "event_type": "arr.daily_version_committed",
                    "publish_status": "pending",
                    "publish_attempts": 0,
                    "created_at": finished,
                    "available_at": finished,
                    "payload": {"must": "not escape"},
                }
            ],
            refreshed_at=finished + timedelta(seconds=1),
        )

        self.assertEqual(trace["trace_version"], "arr-job-trace-4")
        self.assertEqual(trace["job"]["status"], "succeeded")
        self.assertEqual(trace["job"]["current_stage"], "downstream")
        self.assertFalse(trace["job"]["active"])
        self.assertIsNone(trace["job"]["remote_run_id"])
        self.assertEqual(trace["job"]["execution_scope"], "arr_runtime")
        self.assertEqual(trace["job"]["processor_mode"], "fixed_processor")
        self.assertEqual(trace["job"]["remote_dispatch"], "none")
        self.assertEqual(trace["job"]["version_no"], 2)
        self.assertEqual(trace["job"]["output_rows"], 17)
        codes = [item["code"] for item in trace["logs"]]
        for expected in (
            "UPLOAD_REGISTERED",
            "ATTEMPT_QUEUED",
            "PROCESSOR_STARTED",
            "ARTIFACT_RESULT_RECEIVED",
            "ARTIFACT_RESULT_VALIDATED",
            "ARTIFACT_RESULT_COMMITTED",
            "FINANCE_VERSION_VALIDATED",
            "FINANCE_VERSION_ACTIVATED",
            "DOWNSTREAM_EVENT_QUEUED",
            "JOB_SUCCEEDED",
        ):
            self.assertIn(expected, codes)
        self.assertEqual(trace["evidence"]["artifact_deliveries"], 1)

        rendered = json.dumps(trace, ensure_ascii=False)
        for private_value in (
            "must/not/escape",
            "must not escape",
            '"payload"',
            '"object_key"',
            "submission_grant",
            "Agent",
            "MCP",
        ):
            self.assertNotIn(private_value, rendered)
        self.assertNotIn("本地处理", rendered)
        self.assertIn("ARR 固定处理器输出", rendered)
        self.assertIn("Finance 数据库提交已完成", rendered)

    def test_runtime_failure_is_located_at_processor_boundary(self) -> None:
        finished = self.started + timedelta(minutes=4)
        trace = build_job_trace(
            {
                "job_id": "arrjob-timeout-001",
                "run_status": "failed",
                "public_status": "failed",
                "delivery_mode": "artifact_callback",
                "failure_code": "PROCESSOR_TIMEOUT",
                "failure_message": "programmatic processing failed",
                "created_at": self.started,
                "updated_at": finished,
                "finished_at": finished,
                "filename": "source.xml",
                "byte_size": 99,
                "source_sha256": "b" * 64,
            },
            attempts=[
                {
                    "attempt_no": 1,
                    "attempt_status": "failed",
                    "remote_run_id": None,
                    "failure_code": "PROCESSOR_TIMEOUT",
                    "failure_message": "programmatic processing failed",
                    "created_at": self.started + timedelta(seconds=1),
                    "started_at": self.started + timedelta(seconds=2),
                    "finished_at": finished,
                }
            ],
            refreshed_at=finished,
        )

        self.assertEqual(trace["job"]["current_stage"], "processor")
        self.assertEqual(trace["job"]["failure"]["code"], "PROCESSOR_TIMEOUT")
        errors = [item for item in trace["logs"] if item["level"] == "error"]
        self.assertGreaterEqual(len(errors), 2)
        self.assertTrue(all(item["stage"] == "processor" for item in errors))

    def test_legacy_direct_mcp_trace_exposes_remote_semantics(self) -> None:
        finished = self.started + timedelta(minutes=4)
        trace = build_job_trace(
            {
                "job_id": "arrjob-legacy-001",
                "run_status": "failed",
                "public_status": "failed",
                "delivery_mode": "direct_mcp",
                "failure_code": "REMOTE_FAILED",
                "failure_message": "remote processor failed",
                "created_at": self.started,
                "updated_at": finished,
                "finished_at": finished,
                "filename": "source.xml",
                "byte_size": 99,
                "source_sha256": "d" * 64,
            },
            attempts=[
                {
                    "attempt_no": 1,
                    "attempt_status": "failed",
                    "remote_run_id": "remote-run-123",
                    "failure_code": "REMOTE_FAILED",
                    "failure_message": "remote processor failed",
                    "created_at": self.started + timedelta(seconds=1),
                    "started_at": self.started + timedelta(seconds=2),
                    "finished_at": finished,
                }
            ],
            refreshed_at=finished,
        )

        self.assertEqual(trace["job"]["execution_scope"], "remote_agent")
        self.assertEqual(trace["job"]["processor_mode"], "legacy_direct_mcp")
        self.assertEqual(trace["job"]["remote_dispatch"], "mcp")
        self.assertEqual(trace["job"]["remote_run_id"], "remote-run-123")

    def test_active_run_ends_with_current_programmatic_state(self) -> None:
        trace = build_job_trace(
            {
                "job_id": "arrjob-running-001",
                "run_status": "running",
                "public_status": "running",
                "delivery_mode": "artifact_callback",
                "created_at": self.started,
                "updated_at": self.started + timedelta(seconds=4),
                "filename": "source.xml",
                "byte_size": 128,
                "source_sha256": "c" * 64,
            },
            attempts=[
                {
                    "attempt_no": 1,
                    "attempt_status": "running",
                    "remote_run_id": None,
                    "created_at": self.started + timedelta(seconds=1),
                    "started_at": self.started + timedelta(seconds=2),
                }
            ],
        )

        self.assertTrue(trace["job"]["active"])
        self.assertEqual(trace["job"]["current_stage"], "processor")
        self.assertEqual(trace["logs"][-1]["code"], "CURRENT_RUNNING")

    def test_open_price_review_is_active_and_has_no_terminal_failure(self) -> None:
        updated = self.started + timedelta(seconds=12)
        trace = build_job_trace(
            {
                "job_id": "arrjob-review-001",
                "run_status": "awaiting_review",
                "public_status": "needs_review",
                "delivery_mode": "artifact_callback",
                "business_date": "2026-07-29",
                "created_at": self.started,
                "updated_at": updated,
                "filename": "source.xml",
                "byte_size": 512,
                "source_sha256": "e" * 64,
            },
            attempts=[
                {
                    "attempt_no": 1,
                    "attempt_status": "review_required",
                    "remote_run_id": None,
                    "created_at": self.started + timedelta(seconds=1),
                    "started_at": self.started + timedelta(seconds=2),
                    "finished_at": self.started + timedelta(seconds=7),
                }
            ],
            deliveries=[
                {
                    "delivery_key": "local-arrjob-review-001-a1",
                    "attempt_no": 1,
                    "delivery_status": "recorded_review",
                    "result_status": "review_required",
                    "processor_version": "4.0.0",
                    "business_date": "2026-07-29",
                    "daily_version_id": None,
                    "received_at": self.started + timedelta(seconds=3),
                    "validated_at": self.started + timedelta(seconds=5),
                    "committed_at": self.started + timedelta(seconds=7),
                }
            ],
            review_case={
                "case_id": "dailyreview-0123456789abcdef0123456789abcdef",
                "case_status": "open",
                "revision": 2,
                "completed_items": 1,
                "total_items": 2,
                "manual_override_sha256": None,
                "created_at": self.started + timedelta(seconds=7),
                "updated_at": updated,
                "frozen_at": None,
                "completed_at": None,
                "cancelled_at": None,
            },
            review_events=[
                {
                    "event_type": "PRICE_REVIEW_REQUIRED",
                    "actor_username": "operator@example.test",
                    "revision": 1,
                    "created_at": self.started + timedelta(seconds=7),
                },
                {
                    "event_type": "PRICE_REVIEW_UPDATED",
                    "actor_username": "operator@example.test",
                    "revision": 2,
                    "created_at": updated,
                },
            ],
            refreshed_at=updated,
        )

        self.assertEqual(trace["trace_version"], "arr-job-trace-4")
        self.assertEqual(trace["job"]["status"], "needs_review")
        self.assertTrue(trace["job"]["active"])
        self.assertEqual(trace["job"]["current_stage"], "review")
        self.assertIsNone(trace["job"]["failure"])
        self.assertEqual(trace["job"]["review"]["revision"], 2)
        self.assertEqual(trace["job"]["review"]["completed_items"], 1)
        self.assertEqual(trace["job"]["review"]["total_items"], 2)
        codes = [item["code"] for item in trace["logs"]]
        self.assertIn("ARTIFACT_RESULT_RECORDED_FOR_REVIEW", codes)
        self.assertIn("PRICE_REVIEW_REQUIRED", codes)
        self.assertIn("PRICE_REVIEW_UPDATED", codes)
        self.assertIn("CURRENT_AWAITING_REVIEW", codes)
        self.assertNotIn("DOWNSTREAM_EVENT_QUEUED", codes)
        self.assertNotIn("PRICE_REVIEW_FAILED", codes)
        rendered = json.dumps(trace, ensure_ascii=False)
        for private_value in ("SYNTHETIC GUEST", "RES_COMMENT", "TRACE_TEXT"):
            self.assertNotIn(private_value, rendered)


if __name__ == "__main__":
    unittest.main(verbosity=2)
