from __future__ import annotations

import argparse
import contextlib
import io
import json
import sys
import tempfile
import unittest
from datetime import date
from pathlib import Path

from openpyxl import load_workbook


PROJECT_ROOT = Path(__file__).resolve().parents[1]
SKILL_ROOT = PROJECT_ROOT / "opera-daily-channel-report"
SCRIPTS = SKILL_ROOT / "scripts"

sys.path.insert(0, str(SCRIPTS))
import process_reports as core  # noqa: E402
import validate_outputs as validator  # noqa: E402


def reservation(
    sequence: int,
    *,
    company: str = "T / Q.B.D. TRAVEL GROUP",
    rate_code: str = "GRPA1",
    rate_amount: str = "900",
    room: str | None = None,
    confirmation: str | None = None,
    full_name: str = "SYNTHETIC GUEST",
    block_code: str = "",
    res_comment: str | None = None,
    rooms: str = "1",
    departure: str = "2026-07-28",
) -> str:
    room = room or f"SYNTHETIC-ROOM-{sequence}"
    confirmation = confirmation or f"SYNTHETIC-CONF-{sequence}"
    comment_xml = ""
    if res_comment is not None:
        comment_xml = (
            "<LIST_G_COMMENT_RESV_NAME_ID><G_COMMENT_RESV_NAME_ID>"
            f"<RES_COMMENT>{res_comment}</RES_COMMENT>"
            "</G_COMMENT_RESV_NAME_ID></LIST_G_COMMENT_RESV_NAME_ID>"
        )
    return f"""
        <G_RESERVATION>
          <ADULTS>2</ADULTS><BLOCK_CODE>{block_code}</BLOCK_CODE><CF_CHILDREN>0</CF_CHILDREN>
          <COMPANY_NAME>{company}</COMPANY_NAME>
          <CONFIRMATION_NO>{confirmation}</CONFIRMATION_NO><DISP_ROOM_NO>{room}</DISP_ROOM_NO>
          <EFFECTIVE_RATE_AMOUNT>{rate_amount}</EFFECTIVE_RATE_AMOUNT><FULL_NAME>{full_name}</FULL_NAME>
          {comment_xml}
          <NO_OF_ROOMS>{rooms}</NO_OF_ROOMS><PRODUCTS>SYNTHETIC</PRODUCTS><RATE_CODE>{rate_code}</RATE_CODE>
          <ROOM_CATEGORY_LABEL>SYNTHETIC ROOM TYPE</ROOM_CATEGORY_LABEL>
          <TRUNC_BEGIN>2026-07-27</TRUNC_BEGIN><TRUNC_END>{departure}</TRUNC_END>
        </G_RESERVATION>
    """


def xml_document(*rows: str) -> str:
    return f"""<?xml version="1.0" encoding="UTF-8"?>
<RES_DETAIL>
  <LIST_G_GROUP_BY1>
    <G_GROUP_BY1>
      <GROUPBY1_SORT_COL>20260727</GROUPBY1_SORT_COL>
      <GROUPBY1_COL>27-07-26</GROUPBY1_COL>
      <LIST_G_RESERVATION>{''.join(rows)}</LIST_G_RESERVATION>
    </G_GROUP_BY1>
  </LIST_G_GROUP_BY1>
</RES_DETAIL>
"""


def run_processor(
    xml_text: str,
    root: Path,
    monthly_base: Path | None = None,
    *,
    mode: str = "daily-monthly",
):
    xml_path = root / "synthetic.xml"
    output_dir = root / "output"
    result_path = output_dir / "result.json"
    structured_path = output_dir / "structured-result.json"
    xml_path.write_text(xml_text, encoding="utf-8")
    args = argparse.Namespace(
        xml=str(xml_path.resolve()),
        monthly_base=str(monthly_base.resolve()) if monthly_base else None,
        output_dir=str(output_dir.resolve()),
        result_json=str(result_path.resolve()),
        structured_result_json=str(structured_path.resolve()),
        mode=mode,
    )
    with contextlib.redirect_stdout(io.StringIO()):
        exit_code = core.process(args)
    result = json.loads(result_path.read_text(encoding="utf-8"))
    structured = json.loads(structured_path.read_text(encoding="utf-8"))
    return exit_code, xml_path, output_dir, result, structured


class StructuredOutputTests(unittest.TestCase):
    def success_xml(self) -> str:
        return xml_document(
            reservation(1, rate_code="NOT-ALLOWED", rate_amount="INVALID"),
            reservation(2, rate_code="grpa1", res_comment="  ab-123  ", rooms="2", departure="2026-07-30"),
            reservation(
                3,
                room="SYNTHETIC-ROOM-2",
                confirmation="SYNTHETIC-DUPLICATE",
                res_comment="AB-123",
            ),
            reservation(
                4,
                company="T- Rainbow Holiday Service",
                rate_code="LBMS",
                rate_amount="98765",
                block_code="MUST-NOT-BECOME-GROUP-CODE",
                res_comment=None,
                departure="2026-07-29",
            ),
        )

    def test_success_payload_preserves_every_source_outcome_and_lineage(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            exit_code, xml_path, output_dir, result, payload = run_processor(
                self.success_xml(), Path(temp_dir)
            )
            self.assertEqual(exit_code, 0)
            self.assertEqual(set(result), {
                "version", "status", "business_date", "month_key", "message", "metrics", "outputs", "errors"
            })
            self.assertNotIn("structured_result", result["outputs"])

            self.assertEqual(payload["status"], "success")
            self.assertTrue(payload["activation_eligible"])
            self.assertEqual(payload["processor_version"], core.PROCESSOR_VERSION)
            self.assertEqual(payload["rule_set_sha256"], core.rule_set_sha256())
            self.assertEqual(payload["source_rows"], 4)
            self.assertEqual(payload["removed_by_rate_code"], 1)
            self.assertEqual(payload["removed_as_duplicates"], 1)
            self.assertEqual(payload["output_rows"], 2)
            self.assertEqual(
                payload["outcome_counts"],
                {
                    "duplicate": 1,
                    "excluded_rate_code": 1,
                    "price_unmatched": 0,
                    "retained": 2,
                    "validation_failed": 0,
                },
            )
            records = payload["records"]
            self.assertEqual([row["source_sequence"] for row in records], [1, 2, 3, 4])
            self.assertEqual(
                [row["outcome"] for row in records],
                ["excluded_rate_code", "retained", "duplicate", "retained"],
            )
            self.assertEqual(records[2]["duplicate_of_source_sequence"], 2)
            self.assertEqual(records[1]["rate_code"], "grpa1")
            self.assertEqual(records[1]["normalized_rate_code"], "GRPA1")
            self.assertEqual(records[1]["group_code_key"], "AB-123")
            self.assertEqual(records[1]["booking_source_match_status"], "not_checked")
            self.assertIsNone(records[3]["group_code_key"])
            self.assertEqual(records[3]["booking_source_match_status"], "missing_group_code")
            self.assertNotEqual(records[3]["block_code"], records[3]["group_code_key"])
            self.assertEqual(records[1]["real_price"], 1800)
            self.assertEqual(records[1]["total_price"], 10800)
            self.assertEqual(records[1]["pricing_method"], "price_reference_exact")
            self.assertEqual(records[3]["real_price"], 0)
            self.assertEqual(records[3]["total_price"], 0)
            self.assertEqual(records[3]["kb_amount"], 100)
            self.assertEqual(records[3]["pricing_method"], "zero_price_exception")
            self.assertTrue(all(row["source_worksheet"] is None for row in records))
            self.assertTrue(all(row["source_row_no"] is None for row in records))

            artifact_paths = {
                "source_xml": xml_path,
                "daily_report": output_dir / result["outputs"]["daily_report"],
                "monthly_report": output_dir / result["outputs"]["monthly_report"],
                "result_json": output_dir / "result.json",
            }
            for name, path in artifact_paths.items():
                with self.subTest(artifact=name):
                    artifact = payload["artifacts"][name]
                    self.assertEqual(artifact["path"], str(path.resolve()))
                    self.assertEqual(artifact["sha256"], core.sha256_file(path))
                    self.assertEqual(artifact["byte_size"], path.stat().st_size)
            self.assertIsNone(payload["artifacts"]["exception_report"])
            core.validate_structured_completeness(payload)

    def test_same_business_date_rerun_replaces_monthly_rows_without_duplication(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            root = Path(temp_dir)
            first_root = root / "first"
            second_root = root / "second"
            first_root.mkdir()
            second_root.mkdir()
            first = run_processor(self.success_xml(), first_root)
            self.assertEqual(first[0], 0)
            first_monthly = first[2] / first[3]["outputs"]["monthly_report"]
            second = run_processor(self.success_xml(), second_root, monthly_base=first_monthly)
            self.assertEqual(second[0], 0)
            self.assertEqual(first[4]["records"], second[4]["records"])

            second_monthly = second[2] / second[3]["outputs"]["monthly_report"]
            workbook = load_workbook(second_monthly, data_only=False)
            try:
                current_rows = 0
                for sheet in workbook.worksheets:
                    for row_number in range(2, sheet.max_row + 1):
                        if sheet.cell(row_number, 1).value is not None:
                            current_rows += 1
                self.assertEqual(current_rows, 2)
            finally:
                workbook.close()

    def test_daily_mode_produces_no_monthly_workbook_and_keeps_channel_lineage(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            exit_code, xml_path, output_dir, result, payload = run_processor(
                self.success_xml(), Path(temp_dir), mode="daily"
            )

            self.assertEqual(exit_code, 0)
            self.assertEqual(result["version"], core.DAILY_RESULT_VERSION)
            self.assertEqual(result["processing_mode"], "daily")
            self.assertIsNone(result["outputs"]["monthly_report"])
            self.assertEqual(
                result["metrics"]["channels"],
                [{"worksheet": "QBD", "rows": 1}, {"worksheet": core.KB_SHEET, "rows": 1}],
            )

            self.assertEqual(
                payload["result_schema_version"],
                core.DAILY_STRUCTURED_RESULT_SCHEMA_VERSION,
            )
            self.assertEqual(payload["processing_mode"], "daily")
            self.assertIsNone(payload["artifacts"]["monthly_report"])
            self.assertEqual(
                [record["channel_key"] for record in payload["records"]],
                [None, "QBD", None, core.KB_SHEET],
            )
            self.assertEqual(payload["records"][3]["kb_amount"], 100)
            self.assertFalse(
                any(
                    path.suffix.lower() == ".xlsx"
                    and path.name != result["outputs"]["daily_report"]
                    for path in output_dir.iterdir()
                )
            )
            core.validate_structured_completeness(payload)

            errors = validator.validate(
                argparse.Namespace(
                    mode="daily",
                    xml=str(xml_path.resolve()),
                    daily=str((output_dir / result["outputs"]["daily_report"]).resolve()),
                    monthly=None,
                    result_json=str((output_dir / "result.json").resolve()),
                    structured_result_json=str(
                        (output_dir / "structured-result.json").resolve()
                    ),
                    price_reference=str(core.PRICE_REFERENCE.resolve()),
                )
            )
            self.assertEqual(errors, [])

    def test_daily_mode_rejects_monthly_base_without_creating_formal_outputs(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            root = Path(temp_dir)
            monthly_base = SKILL_ROOT / "assets" / "channel-report-template.xlsx"
            exit_code, _xml_path, output_dir, result, payload = run_processor(
                self.success_xml(), root, monthly_base=monthly_base, mode="daily"
            )

            self.assertEqual(exit_code, 3)
            self.assertEqual(result["version"], core.DAILY_RESULT_VERSION)
            self.assertEqual(result["processing_mode"], "daily")
            self.assertEqual(result["status"], "failed")
            self.assertIn("INPUT_MONTHLY_FORBIDDEN", {item["code"] for item in result["errors"]})
            self.assertIsNone(payload["artifacts"]["daily_report"])
            self.assertIsNone(payload["artifacts"]["monthly_report"])
            self.assertFalse(payload["activation_eligible"])
            self.assertFalse(
                any(
                    path.suffix.lower() == ".xlsx" and path.name != "异常清单.xlsx"
                    for path in output_dir.iterdir()
                )
            )

    def test_daily_validator_rejects_channel_tampering(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            exit_code, xml_path, output_dir, result, payload = run_processor(
                self.success_xml(), Path(temp_dir), mode="daily"
            )
            self.assertEqual(exit_code, 0)
            structured_path = output_dir / "structured-result.json"
            payload["records"][1]["channel_key"] = "FENGRUN"
            structured_path.write_text(
                json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
            )

            errors = validator.validate(
                argparse.Namespace(
                    mode="daily",
                    xml=str(xml_path.resolve()),
                    daily=str((output_dir / result["outputs"]["daily_report"]).resolve()),
                    monthly=None,
                    result_json=str((output_dir / "result.json").resolve()),
                    structured_result_json=str(structured_path.resolve()),
                    price_reference=str(core.PRICE_REFERENCE.resolve()),
                )
            )
            self.assertIn(
                "OUTPUT_STRUCTURED_RECORD_MISMATCH",
                {error.code for error in errors},
            )

    def test_price_failure_keeps_price_unmatched_and_excluded_rows_but_no_formal_outputs(self):
        failure_xml = xml_document(
            reservation(1, rate_code="GRPA4", rate_amount="1800", res_comment="GROUP-FAIL"),
            reservation(2, rate_code="NOT-ALLOWED", rate_amount="INVALID"),
        )
        with tempfile.TemporaryDirectory() as temp_dir:
            exit_code, _xml_path, output_dir, result, payload = run_processor(
                failure_xml, Path(temp_dir)
            )
            self.assertEqual(exit_code, 2)
            self.assertEqual(result["status"], "failed")
            self.assertFalse(payload["activation_eligible"])
            self.assertEqual(payload["output_rows"], 0)
            self.assertEqual(
                [row["outcome"] for row in payload["records"]],
                ["price_unmatched", "excluded_rate_code"],
            )
            self.assertIn("PRICE_UNMATCHED", payload["records"][0]["decision_codes"])
            self.assertIsNone(payload["artifacts"]["daily_report"])
            self.assertIsNone(payload["artifacts"]["monthly_report"])
            self.assertIsNotNone(payload["artifacts"]["exception_report"])
            self.assertFalse(any(path.suffix == ".xlsx" and path.name != "异常清单.xlsx" for path in output_dir.iterdir()))
            core.validate_structured_completeness(payload)

    def test_row_validation_failure_marks_other_pending_rows_not_validated(self):
        failure_xml = xml_document(
            reservation(1, full_name=""),
            reservation(2, res_comment="VALID-GROUP"),
        )
        with tempfile.TemporaryDirectory() as temp_dir:
            exit_code, _xml_path, _output_dir, _result, payload = run_processor(
                failure_xml, Path(temp_dir)
            )
            self.assertEqual(exit_code, 2)
            self.assertEqual(
                [row["outcome"] for row in payload["records"]],
                ["validation_failed", "validation_failed"],
            )
            self.assertIn("XML_FULL_NAME_MISSING", payload["records"][0]["decision_codes"])
            self.assertIn("BATCH_NOT_VALIDATED", payload["records"][1]["decision_codes"])
            self.assertEqual(payload["outcome_counts"]["validation_failed"], 2)
            self.assertEqual(payload["output_rows"], 0)
            core.validate_structured_completeness(payload)

    def test_zero_night_rows_are_retained_in_daily_monthly_and_structured_outputs(self):
        zero_night_xml = xml_document(
            reservation(1, departure="2026-07-27", res_comment="ZERO-NIGHT-QBD"),
            reservation(
                2,
                company="T- Rainbow Holiday Service",
                rate_code="LBMS",
                rate_amount="98765",
                departure="2026-07-27",
                res_comment="ZERO-NIGHT-KB",
            ),
        )
        with tempfile.TemporaryDirectory() as temp_dir:
            root = Path(temp_dir)
            first_root = root / "first"
            second_root = root / "second"
            first_root.mkdir()
            second_root.mkdir()
            exit_code, _xml_path, output_dir, result, payload = run_processor(
                zero_night_xml, first_root
            )
            self.assertEqual(exit_code, 0)
            self.assertTrue(payload["activation_eligible"])
            self.assertEqual(
                [record["outcome"] for record in payload["records"]],
                ["retained", "retained"],
            )
            self.assertEqual([record["nights"] for record in payload["records"]], [0, 0])
            self.assertEqual([record["total_price"] for record in payload["records"]], [0, 0])
            self.assertEqual(payload["records"][0]["real_price"], 1800)
            self.assertEqual(payload["records"][1]["real_price"], 0)
            self.assertEqual(payload["records"][1]["kb_amount"], 100)

            daily = load_workbook(output_dir / result["outputs"]["daily_report"], data_only=False)
            try:
                nights_col = core.DAILY_HEADERS.index("NIGHTS") + 1
                total_col = core.DAILY_HEADERS.index("TOTAL PRICE") + 1
                self.assertEqual(
                    [daily.active.cell(row, nights_col).value for row in (2, 3)],
                    [0, 0],
                )
                self.assertEqual(
                    [daily.active.cell(row, total_col).value for row in (2, 3)],
                    [0, 0],
                )
            finally:
                daily.close()

            monthly_path = output_dir / result["outputs"]["monthly_report"]
            monthly = load_workbook(monthly_path, data_only=False)
            try:
                self.assertEqual(monthly["QBD"].cell(2, 3).value, 0)
                self.assertEqual(monthly["QBD"].cell(2, 19).value, 0)
                self.assertEqual(monthly[core.KB_SHEET].cell(2, 3).value, 0)
                self.assertEqual(monthly[core.KB_SHEET].cell(2, 19).value, 0)
                self.assertEqual(monthly[core.KB_SHEET].cell(2, 20).value, 100)
            finally:
                monthly.close()

            rerun = run_processor(zero_night_xml, second_root, monthly_base=monthly_path)
            self.assertEqual(rerun[0], 0)
            self.assertTrue(rerun[4]["activation_eligible"])

    def test_negative_night_remains_a_validation_failure(self):
        negative_night_xml = xml_document(
            reservation(1, departure="2026-07-26", res_comment="NEGATIVE-NIGHT")
        )
        with tempfile.TemporaryDirectory() as temp_dir:
            exit_code, _xml_path, _output_dir, _result, payload = run_processor(
                negative_night_xml, Path(temp_dir)
            )
            self.assertEqual(exit_code, 2)
            self.assertFalse(payload["activation_eligible"])
            self.assertEqual(payload["records"][0]["outcome"], "validation_failed")
            self.assertEqual(payload["records"][0]["nights"], -1)
            self.assertIn("XML_NEGATIVE_NIGHTS", payload["records"][0]["decision_codes"])

    def test_independent_validator_rejects_negative_nights(self):
        zero_night_xml = xml_document(
            reservation(1, departure="2026-07-27", res_comment="VALIDATOR-ZERO-NIGHT")
        )
        with tempfile.TemporaryDirectory() as temp_dir:
            exit_code, xml_path, output_dir, result, _payload = run_processor(
                zero_night_xml, Path(temp_dir)
            )
            self.assertEqual(exit_code, 0)
            daily_path = output_dir / result["outputs"]["daily_report"]
            daily = load_workbook(daily_path, data_only=False)
            try:
                departure_col = core.DAILY_HEADERS.index("DEPARTURE") + 1
                nights_col = core.DAILY_HEADERS.index("NIGHTS") + 1
                daily.active.cell(2, departure_col).value = date(2026, 7, 26)
                daily.active.cell(2, nights_col).value = -1
                daily.save(daily_path)
            finally:
                daily.close()

            errors = validator.validate(
                argparse.Namespace(
                    xml=str(xml_path.resolve()),
                    daily=str(daily_path.resolve()),
                    monthly=str((output_dir / result["outputs"]["monthly_report"]).resolve()),
                    result_json=str((output_dir / "result.json").resolve()),
                    structured_result_json=str((output_dir / "structured-result.json").resolve()),
                    price_reference=str(core.PRICE_REFERENCE.resolve()),
                )
            )
            self.assertIn("OUTPUT_NIGHTS_MISMATCH", {error.code for error in errors})

    def test_independent_validator_rejects_structured_artifact_hash_tampering(self):
        with tempfile.TemporaryDirectory() as temp_dir:
            exit_code, xml_path, output_dir, result, payload = run_processor(
                self.success_xml(), Path(temp_dir)
            )
            self.assertEqual(exit_code, 0)
            structured_path = output_dir / "structured-result.json"
            payload["artifacts"]["daily_report"]["sha256"] = "0" * 64
            structured_path.write_text(
                json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
            )
            errors = validator.validate(
                argparse.Namespace(
                    xml=str(xml_path.resolve()),
                    daily=str((output_dir / result["outputs"]["daily_report"]).resolve()),
                    monthly=str((output_dir / result["outputs"]["monthly_report"]).resolve()),
                    result_json=str((output_dir / "result.json").resolve()),
                    structured_result_json=str(structured_path.resolve()),
                    price_reference=str(core.PRICE_REFERENCE.resolve()),
                )
            )
            self.assertIn(
                "OUTPUT_STRUCTURED_ARTIFACT_MISMATCH", {error.code for error in errors}
            )

    def test_schema_is_strict_and_covers_finance_contract_fields(self):
        schema = json.loads(
            (SKILL_ROOT / "references" / "structured-result.schema.json").read_text(
                encoding="utf-8"
            )
        )
        self.assertFalse(schema["additionalProperties"])
        self.assertEqual(
            schema["properties"]["result_schema_version"]["enum"], ["1.0", "2.0"]
        )
        self.assertEqual(schema["properties"]["processing_mode"]["const"], "daily")
        daily_condition = next(
            condition
            for condition in schema["allOf"]
            if condition.get("if", {})
            .get("properties", {})
            .get("result_schema_version", {})
            .get("const")
            == "2.0"
        )
        self.assertEqual(
            daily_condition["then"]["properties"]["artifacts"]["properties"][
                "monthly_report"
            ],
            {"type": "null"},
        )
        required = set(schema["required"])
        self.assertTrue(
            {
                "business_date",
                "processor_version",
                "rule_set_sha256",
                "result_schema_version",
                "source_rows",
                "removed_by_rate_code",
                "removed_as_duplicates",
                "output_rows",
                "channels",
                "artifacts",
                "records",
            }.issubset(required)
        )
        record_schema = schema["$defs"]["record"]
        self.assertFalse(record_schema["additionalProperties"])
        retained_properties = record_schema["allOf"][1]["then"]["properties"]
        self.assertEqual(retained_properties["nights"]["minimum"], 0)
        for field in (
            "source_sequence",
            "source_location",
            "source_worksheet",
            "source_row_no",
            "outcome",
            "decision_codes",
            "duplicate_of_source_sequence",
            "effective_rate_amount",
            "room_category_label",
            "real_price",
            "total_price",
            "kb_amount",
            "channel_key",
            "pricing_method",
            "normalized_rate_code",
            "group_code_key",
            "company_key",
        ):
            self.assertIn(field, record_schema["required"])


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