"""Unit tests for hcm_to_scim.py against fixtures. No network calls: --apply is
exercised with a fake poster. Illustrative: unit-tested against fixtures, not
proven against live tenants."""
from __future__ import annotations

import copy
import datetime as dt
import json
import sys
from pathlib import Path

import pytest

KIT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(KIT / "python"))

import hcm_to_scim as h  # noqa: E402

FX = KIT / "tests" / "fixtures"
AS_OF = dt.date(2026, 10, 25)
EXT = "urn:ietf:params:scim:schemas:extension:contoso:1.0:User"


@pytest.fixture()
def mapping():
    return h.load_json(KIT / "config" / "mapping.json")


@pytest.fixture()
def settings():
    return h.validate_settings(h.load_json(KIT / "config" / "settings.example.json"))


@pytest.fixture()
def rows():
    return h.read_rows(FX / "hcm_extract_sample.csv")


@pytest.fixture()
def result(rows, mapping, settings):
    return h.convert(rows, mapping, settings, AS_OF)


def ops_by_key(result):
    return {op["externalId"]: op for op in (o["data"] for b in result.batches for o in b["Operations"])}


# ---------------------------------------------------------------- the whole fixture

def test_matches_expected_bulk_request(result):
    expected = json.loads((FX / "expected_bulk_request.json").read_text(encoding="utf-8"))
    assert result.batches == [expected]


def test_batch_hash_matches_the_cross_implementation_fixture(result):
    # The PowerShell twin asserts the same value: both canonicalize and hash identically.
    expected = (FX / "expected_batch_sha256.txt").read_text(encoding="utf-8").strip()
    assert h.sha256_hex(result.batches[0]) == expected


def test_matches_expected_records_and_hashes(result):
    expected = json.loads((FX / "expected_records.json").read_text(encoding="utf-8"))
    actual = [
        {"person_key": o.person_key, "status": o.status, "leaver": o.is_leaver, "payload_sha256": o.payload_sha256}
        for o in result.outcomes
    ]
    assert actual == expected


def test_counts(result):
    counts = h.report(result)["counts"]
    assert counts == {"ready": 9, "quarantined": 9, "superseded": 1}
    assert result.disables == 3
    assert result.held is False


# ---------------------------------------------------------------- mapping (Book 03, Chapter 06)

def test_core_enterprise_and_extension_mapping(result):
    avery = ops_by_key(result)["100001"]
    assert avery["schemas"] == [h.CORE_SCHEMA, h.ENTERPRISE_SCHEMA, EXT]
    assert avery["active"] is False  # pre-start: disabled until the hire date (Appendix C)
    assert avery["name"] == {"givenName": "Avery", "familyName": "Kim"}
    ent = avery[h.ENTERPRISE_SCHEMA]
    assert ent["employeeNumber"] == "100001"
    assert ent["manager"] == {"value": "100000"}  # the manager's externalId
    assert ent["organization"] == "Contoso Agency"
    ext = avery[EXT]
    assert ext["workerType"] == "Employee" and ext["workerTypeCode"] == "EMP"
    assert ext["orgCode"] == "ORG-100" and ext["positionCategory"] == "PRO"
    assert ext["HireDate"] == "2026-11-02T10:00:00Z"  # 05:00 at -05:00
    assert "TermDate" not in ext


def test_contractor_maps_to_ctr_and_carries_its_end_date(result):
    sam = ops_by_key(result)["100004"]
    assert sam[EXT]["workerType"] == "Contractor" and sam[EXT]["workerTypeCode"] == "CTR"
    assert sam["active"] is True
    assert sam[EXT]["TermDate"] == "2027-01-01T04:00:00Z"  # 23:00 at -05:00 on 2026-12-31


def test_row_offset_overrides_default(result):
    jose = ops_by_key(result)["100002"]
    assert jose[EXT]["HireDate"] == "2019-06-17T13:00:00Z"  # 05:00 at -08:00
    assert jose["displayName"] == "José Ramírez"  # UTF-8 kept


def test_no_email_is_sent(result):
    # Book 03: mail is generated by the directory, not mastered in HR.
    for op in ops_by_key(result).values():
        assert "emails" not in op


# ---------------------------------------------------------------- lifecycle rules (Book 03, Appendix A)

def test_leavers_are_inactive_and_go_first(result):
    keys = [o["bulkId"] for o in result.batches[0]["Operations"]]
    assert keys[:3] == ["100003", "100008", "100013"]
    for k in ("100003", "100008", "100013"):
        assert ops_by_key(result)[k]["active"] is False


def test_managers_come_before_reports(result):
    keys = [o["bulkId"] for o in result.batches[0]["Operations"]]
    assert keys.index("100000") < keys.index("100001") < keys.index("100002")


def test_prestart_is_sent_disabled_but_is_not_a_leaver(result):
    avery = next(o for o in result.outcomes if o.person_key == "100001")
    assert avery.operation["data"]["active"] is False
    assert avery.is_leaver is False
    assert any("pre-start" in w for w in avery.warnings)
    assert result.disables == 3  # pre-start records don't count toward the large-delta hold


def test_prestart_rule_can_be_turned_off(rows, mapping, settings):
    res = h.convert(rows, mapping, dict(settings, prestart_disabled=False), AS_OF)
    assert ops_by_key(res)["100001"]["active"] is True


def test_on_the_hire_date_the_record_is_enabled(rows, mapping, settings):
    res = h.convert(rows, mapping, settings, dt.date(2026, 11, 2))
    assert ops_by_key(res)["100001"]["active"] is True


def test_leave_is_not_a_leaver(result):
    jordan = ops_by_key(result)["100005"]
    assert jordan["active"] is True
    outcome = next(o for o in result.outcomes if o.person_key == "100005")
    assert any("leave is not a leaver" in w for w in outcome.warnings)


def test_separation_wins_over_a_same_run_mover(result):
    drew = [o for o in result.outcomes if o.person_key == "100008"]
    assert sorted(o.status for o in drew) == ["ready", "superseded"]
    assert ops_by_key(result)["100008"]["active"] is False


def test_two_non_separation_rows_are_quarantined(mapping, settings, rows):
    dup = [r for r in rows if r["PersonNumber"] == "100001"]
    res = h.convert(dup + copy.deepcopy(dup), mapping, settings, AS_OF)
    assert [o.status for o in res.outcomes] == ["quarantined", "quarantined"]
    assert res.batches == []


def test_manager_separating_is_a_finding(result):
    hayden = next(o for o in result.outcomes if o.person_key == "100011")
    assert hayden.status == "ready"  # never blocks the joiner
    assert any("manager is separating" in w for w in hayden.warnings)


def test_late_separation_flags_the_on_demand_path(result):
    rowan = next(o for o in result.outcomes if o.person_key == "100013")
    assert any("on demand" in w for w in rowan.warnings)
    riley = next(o for o in result.outcomes if o.person_key == "100003")
    assert riley.warnings == []  # two days late: inside the catch-up window


@pytest.mark.parametrize(
    "key, fragment",
    [
        ("100006", "pending worker"),
        ("100007", "unknown worker type"),
        ("(missing)", "PersonNumber is empty"),
        ("12345678901234567", "at most 16 characters"),
        ("100009", "own manager"),
        ("100010", "no termination date"),
        ("100014", "longer than 64"),
        ("100015", "approved code list"),
        ("100016", "isn't a code"),
    ],
)
def test_fail_closed_reasons(result, key, fragment):
    outcome = next(o for o in result.outcomes if o.person_key == key)
    assert outcome.status == "quarantined"
    assert outcome.operation is None
    assert any(fragment in r for r in outcome.reasons)


def test_quarantine_contract_keeps_the_unchanged_record(result):
    entries = h.quarantine_entries(result)
    assert len(entries) == 9
    first = entries[0]
    assert set(first) == {"person_key", "reasons", "hr_record", "attempted_payload"}
    assert first["hr_record"]["WorkerType"] == "P"


def test_operational_report_carries_no_payload_or_names(result):
    text = json.dumps(h.report(result), ensure_ascii=False)
    for name in ("Avery", "Ramírez", "morgan.lee", "Program Analyst"):
        assert name not in text
    assert all(len(r["payload_sha256"]) in (0, 64) for r in h.report(result)["records"])


# ---------------------------------------------------------------- provenance

FAKE_EXTRACT_SHA = "a" * 64
FAKE_MAPPING_SHA = "b" * 64


def test_provenance_links_inputs_to_batches(result):
    manifest = h.provenance(result, AS_OF, FAKE_EXTRACT_SHA, FAKE_MAPPING_SHA)
    assert manifest["schema"] == "fmp-hcm-provenance/1"
    assert manifest["as_of"] == "2026-10-25"
    assert manifest["extract_sha256"] == FAKE_EXTRACT_SHA and manifest["mapping_sha256"] == FAKE_MAPPING_SHA
    assert [b["batch_sha256"] for b in manifest["batches"]] == [h.sha256_hex(b) for b in result.batches]
    sent = [k for b in manifest["batches"] for k in b["person_keys"]]
    assert sorted(sent) == sorted(o.person_key for o in result.outcomes if o.status == "ready")
    assert len(manifest["quarantined"]) == 9


def test_provenance_matches_the_cross_implementation_fixture(result):
    manifest = h.provenance(result, AS_OF, FAKE_EXTRACT_SHA, FAKE_MAPPING_SHA)
    expected = (FX / "expected_provenance_sha256.txt").read_text(encoding="utf-8").strip()
    assert h.sha256_hex(manifest) == expected


def test_provenance_carries_no_names_or_payloads(result):
    text = json.dumps(h.provenance(result, AS_OF, FAKE_EXTRACT_SHA, FAKE_MAPPING_SHA), ensure_ascii=False)
    for name in ("Avery", "Ramírez", "morgan.lee", "Program Analyst", "givenName"):
        assert name not in text


# ---------------------------------------------------------------- guards (Book 03, Appendix B)

def test_large_delta_holds_the_run(rows, mapping, settings):
    settings = dict(settings, max_disables_per_run=2)
    res = h.convert(rows, mapping, settings, AS_OF)
    assert res.held is True and "max_disables_per_run" in res.hold_reason


def test_batches_never_exceed_50(mapping, settings, rows):
    template = next(r for r in rows if r["PersonNumber"] == "100001")
    many = []
    for i in range(120):
        r = dict(template, PersonNumber=f"2{i:05d}", ManagerPersonNumber="")
        many.append(r)
    res = h.convert(many, mapping, settings, AS_OF)
    assert [len(b["Operations"]) for b in res.batches] == [50, 50, 20]


def test_settings_refuse_secrets():
    with pytest.raises(h.ConfigError):
        h.validate_settings({"client_secret": "x"})
    with pytest.raises(h.ConfigError):
        h.validate_settings({"bearerValue": "x"})


def test_settings_refuse_pacing_faster_than_documented_limit():
    with pytest.raises(h.ConfigError):
        h.validate_settings({"min_seconds_between_calls": 0.05})


@pytest.mark.parametrize(
    "url, ok",
    [
        ("https://graph.microsoft.com/v1.0/servicePrincipals/00000000-0000-0000-0000-000000000000/synchronization/jobs/API2AAD.abc.def/bulkUpload", True),
        ("https://graph.microsoft.us/beta/servicePrincipals/00000000-0000-0000-0000-000000000000/synchronization/jobs/API2AD.abc/bulkUpload", True),
        ("http://graph.microsoft.com/v1.0/servicePrincipals/00000000-0000-0000-0000-000000000000/synchronization/jobs/x/bulkUpload", False),
        ("https://example.com/v1.0/servicePrincipals/00000000-0000-0000-0000-000000000000/synchronization/jobs/x/bulkUpload", False),
        ("https://graph.microsoft.com/v1.0/users", False),
    ],
)
def test_endpoint_allow_list(url, ok):
    if ok:
        assert h.validate_endpoint(url) == url
    else:
        with pytest.raises(h.ConfigError):
            h.validate_endpoint(url)


# ---------------------------------------------------------------- apply, with a fake poster

ENDPOINT = "https://graph.microsoft.com/v1.0/servicePrincipals/00000000-0000-0000-0000-000000000000/synchronization/jobs/API2AAD.abc.def/bulkUpload"


def test_apply_posts_scim_json_and_records_the_logs_location(result, settings):
    calls, sleeps = [], []

    def poster(url, body, headers):
        calls.append((url, headers, json.loads(body)))
        return 202, {"Location": "https://graph.microsoft.com/v1.0/auditLogs/provisioning/?$filter=jobid eq 'x'"}, b""

    out = h.apply_batches(result.batches, ENDPOINT, "test-token", settings, poster=poster, sleeper=sleeps.append)
    assert len(calls) == 1
    url, headers, body = calls[0]
    assert url == ENDPOINT
    assert headers["Content-Type"] == "application/scim+json"
    assert headers["Authorization"] == "Bearer test-token"
    assert body == result.batches[0]
    assert out[0]["status"] == 202 and "auditLogs/provisioning" in out[0]["provisioning_logs"]
    assert sleeps == [settings["min_seconds_between_calls"]]


def test_apply_honors_retry_after_on_429(result, settings):
    responses = [(429, {"Retry-After": "7"}, b""), (202, {}, b"")]
    sleeps = []
    out = h.apply_batches(result.batches, ENDPOINT, "t", settings,
                          poster=lambda *a: responses.pop(0), sleeper=sleeps.append)
    assert sleeps[0] == 7.0
    assert out[0]["attempts"] == 2 and out[0]["status"] == 202


def test_apply_stops_on_first_failure(mapping, settings, rows):
    template = next(r for r in rows if r["PersonNumber"] == "100001")
    many = [dict(template, PersonNumber=f"3{i:05d}", ManagerPersonNumber="") for i in range(60)]
    res = h.convert(many, mapping, settings, AS_OF)
    out = h.apply_batches(res.batches, ENDPOINT, "t", settings,
                          poster=lambda *a: (403, {}, b""), sleeper=lambda s: None)
    assert len(out) == 1 and out[0]["status"] == 403


def test_apply_refuses_a_bad_endpoint(result, settings):
    with pytest.raises(h.ConfigError):
        h.apply_batches(result.batches, "https://example.com/bulkUpload", "t", settings,
                        poster=lambda *a: (202, {}, b""), sleeper=lambda s: None)


# ---------------------------------------------------------------- CLI

def test_cli_dry_run_writes_files_and_makes_no_calls(tmp_path, monkeypatch):
    def boom(*a, **k):
        raise AssertionError("dry run must not call the network")

    monkeypatch.setattr(h, "http_post", boom)
    code = h.main(["--extract", str(FX / "hcm_extract_sample.csv"), "--out", str(tmp_path), "--as-of", "2026-10-25"])
    assert code == h.EXIT_QUARANTINE
    assert (tmp_path / "bulk-request-001.json").exists()
    assert (tmp_path / "validation-report.json").exists()
    assert (tmp_path / "quarantine.json").exists()
    manifest = json.loads((tmp_path / "provenance.json").read_text(encoding="utf-8"))
    assert manifest["extract_sha256"] == h.file_sha256(FX / "hcm_extract_sample.csv")
    assert manifest["mapping_sha256"] == h.file_sha256(KIT / "config" / "mapping.json")
    assert not (tmp_path / "apply-results.json").exists()


def test_cli_apply_without_token_fails(tmp_path, monkeypatch):
    monkeypatch.delenv(h.TOKEN_ENV, raising=False)
    code = h.main(["--extract", str(FX / "hcm_extract_sample.csv"), "--out", str(tmp_path),
                   "--as-of", "2026-10-25", "--apply"])
    assert code == h.EXIT_ERROR


def test_cli_held_run_exits_3(tmp_path):
    s = tmp_path / "s.json"
    s.write_text(json.dumps({"max_disables_per_run": 1}), encoding="utf-8")
    code = h.main(["--extract", str(FX / "hcm_extract_sample.csv"), "--settings", str(s),
                   "--out", str(tmp_path / "o"), "--as-of", "2026-10-25"])
    assert code == h.EXIT_HELD
