#!/usr/bin/env python3

from __future__ import print_function

import argparse
import csv
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request


SOURCE_REF_PATTERN = re.compile(r"^[0-9a-f]{64}$")
VIN_PATTERN = re.compile(r"^[A-Z0-9]{6,64}$")
MAX_ROWS = 500


class ImportError(RuntimeError):
    pass


class Client:
    def __init__(self, base_url, token, timeout):
        self.base_url = base_url.rstrip("/")
        self.token = token
        self.timeout = timeout

    def data(self, method, path, payload=None):
        body = None
        headers = {"Authorization": "Bearer " + self.token}
        if payload is not None:
            body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
            headers["Content-Type"] = "application/json"
        request = urllib.request.Request(
            self.base_url + path,
            data=body,
            headers=headers,
            method=method
        )
        try:
            response = urllib.request.urlopen(request, timeout=self.timeout)
            status = response.getcode()
            raw = response.read()
        except urllib.error.HTTPError as error:
            status = error.code
            raw = error.read()
        if status < 200 or status >= 300:
            code = ""
            message = "HTTP {0}".format(status)
            try:
                envelope = json.loads(raw.decode("utf-8"))
                code = envelope.get("code") or envelope.get("errorCode") or ""
                message = envelope.get("message") or envelope.get("error") or message
            except (ValueError, UnicodeDecodeError):
                pass
            detail = "{0}: {1}".format(code, message) if code else message
            raise ImportError("{0} {1} failed: {2}".format(method, path, detail))
        try:
            envelope = json.loads(raw.decode("utf-8"))
        except (ValueError, UnicodeDecodeError):
            raise ImportError("{0} {1} returned invalid JSON".format(method, path))
        if not isinstance(envelope, dict) or "data" not in envelope:
            raise ImportError("{0} {1} returned no data envelope".format(method, path))
        return envelope["data"]


def clean(value):
    return (value or "").strip()


def read_rows(path):
    rows = []
    seen = set()
    try:
        handle = open(path, "r", encoding="utf-8-sig", newline="")
    except (IOError, OSError) as error:
        raise ImportError("cannot open CSV: {0}".format(error))
    with handle:
        reader = csv.DictReader(handle)
        required = {"vin", "source_ref", "provider_name", "evidence"}
        fields = set(reader.fieldnames or [])
        missing = sorted(required - fields)
        if missing:
            raise ImportError("CSV missing columns: " + ",".join(missing))
        for line_number, raw in enumerate(reader, 2):
            vin = clean(raw.get("vin")).upper()
            source_ref = clean(raw.get("source_ref")).lower()
            provider_name = clean(raw.get("provider_name"))
            evidence = clean(raw.get("evidence"))
            expected_protocol = clean(raw.get("expected_protocol")).upper()
            expected_current_provider = clean(raw.get("expected_current_provider"))
            if not any((vin, source_ref, provider_name, evidence, expected_protocol, expected_current_provider)):
                continue
            if not VIN_PATTERN.match(vin):
                raise ImportError("line {0}: invalid VIN".format(line_number))
            if not SOURCE_REF_PATTERN.match(source_ref):
                raise ImportError("line {0}: source_ref must be a 64-character lowercase SHA-256 value".format(line_number))
            if not provider_name:
                raise ImportError("line {0}: provider_name is required".format(line_number))
            if len(provider_name) > 128:
                raise ImportError("line {0}: provider_name exceeds 128 characters".format(line_number))
            if not evidence:
                raise ImportError("line {0}: evidence is required".format(line_number))
            if len(evidence) > 255:
                raise ImportError("line {0}: evidence exceeds 255 characters".format(line_number))
            key = (vin, source_ref)
            if key in seen:
                raise ImportError("line {0}: duplicate VIN/source_ref".format(line_number))
            seen.add(key)
            rows.append({
                "line": line_number,
                "vin": vin,
                "sourceRef": source_ref,
                "providerName": provider_name,
                "evidence": evidence,
                "expectedProtocol": expected_protocol,
                "expectedCurrentProvider": expected_current_provider
            })
            if len(rows) > MAX_ROWS:
                raise ImportError("CSV exceeds the {0}-row safety limit".format(MAX_ROWS))
    if not rows:
        raise ImportError("CSV contains no provider rows")
    return rows


def diagnostic_path(vin):
    return "/api/v2/operations/vehicles/{0}/sources".format(urllib.parse.quote(vin, safe=""))


def update_path(vin, source_ref):
    return "/api/v2/operations/vehicles/{0}/sources/{1}".format(
        urllib.parse.quote(vin, safe=""),
        urllib.parse.quote(source_ref, safe="")
    )


def sources(diagnostic):
    evidence = diagnostic.get("evidence") if isinstance(diagnostic, dict) else None
    result = evidence.get("locationSources") if isinstance(evidence, dict) else None
    return result if isinstance(result, list) else []


def find_source(diagnostic, row):
    for source in sources(diagnostic):
        if clean(source.get("sourceRef")).lower() == row["sourceRef"]:
            return source
    raise ImportError(
        "line {0}: source_ref no longer exists for VIN {1}".format(row["line"], row["vin"])
    )


def policy_version(diagnostic, row):
    policy = diagnostic.get("policy") if isinstance(diagnostic, dict) else None
    version = policy.get("version") if isinstance(policy, dict) else None
    if not isinstance(version, int) or version < 1:
        raise ImportError(
            "line {0}: invalid source policy version for VIN {1}".format(row["line"], row["vin"])
        )
    return version


def effective_provider(source):
    return clean(source.get("providerOverride")) or clean(source.get("sourceLabel"))


def validate_source(row, source):
    source_protocol = clean(source.get("protocol")).upper()
    if row["expectedProtocol"] and source_protocol != row["expectedProtocol"]:
        raise ImportError(
            "line {0}: protocol changed from {1} to {2}".format(
                row["line"], row["expectedProtocol"], source_protocol or "-"
            )
        )
    current_provider = effective_provider(source)
    if row["expectedCurrentProvider"] and current_provider != row["expectedCurrentProvider"]:
        raise ImportError(
            "line {0}: current provider changed from {1} to {2}".format(
                row["line"], row["expectedCurrentProvider"], current_provider or "-"
            )
        )
    priority = source.get("priority")
    if not isinstance(priority, int) or priority < 1 or priority > 1000:
        raise ImportError("line {0}: current priority is invalid".format(row["line"]))
    if not isinstance(source.get("enabled"), bool):
        raise ImportError("line {0}: current enabled state is invalid".format(row["line"]))


def preflight(client, rows):
    diagnostics = {}
    changes = 0
    for row in rows:
        vin = row["vin"]
        if vin not in diagnostics:
            diagnostics[vin] = client.data("GET", diagnostic_path(vin))
            policy_version(diagnostics[vin], row)
        source = find_source(diagnostics[vin], row)
        validate_source(row, source)
        if clean(source.get("providerOverride")) != row["providerName"]:
            changes += 1
    return diagnostics, changes


def export_missing(client, path):
    vehicles = []
    offset = 0
    total = None
    while total is None or offset < total:
        page = client.data("POST", "/api/v2/access/vehicles", payload={
            "connectionState": "master_data",
            "limit": 200,
            "offset": offset
        })
        if not isinstance(page, dict) or not isinstance(page.get("items"), list):
            raise ImportError("access vehicle query returned an invalid page")
        if total is None:
            total = page.get("total")
            if not isinstance(total, int) or total < 0:
                raise ImportError("access vehicle query returned an invalid total")
        vehicles.extend(page["items"])
        if not page["items"]:
            break
        offset += len(page["items"])
    rows = []
    for vehicle in vehicles:
        issues = vehicle.get("masterDataIssues") or []
        if "JT808 接入方未维护" not in issues:
            continue
        vin = clean(vehicle.get("vin")).upper()
        if not VIN_PATTERN.match(vin):
            raise ImportError("access vehicle query returned an invalid VIN")
        diagnostic = client.data("GET", diagnostic_path(vin))
        for source in sources(diagnostic):
            if clean(source.get("protocol")).upper() != "JT808":
                continue
            if clean(source.get("providerOverride")):
                continue
            current_provider = effective_provider(source)
            source_ref = clean(source.get("sourceRef")).lower()
            if not SOURCE_REF_PATTERN.match(source_ref):
                raise ImportError("diagnostic returned an invalid source_ref for VIN " + vin)
            rows.append({
                "vin": vin,
                "plate": clean(vehicle.get("plate")),
                "source_ref": source_ref,
                "terminal_label": clean(source.get("terminalLabel")),
                "expected_protocol": "JT808",
                "expected_current_provider": current_provider,
                "provider_name": "",
                "evidence": ""
            })
            if len(rows) > MAX_ROWS:
                raise ImportError("missing provider export exceeds the {0}-row safety limit".format(MAX_ROWS))
    if not rows:
        raise ImportError("no JT808 provider gaps were found")
    fieldnames = [
        "vin", "plate", "source_ref", "terminal_label",
        "expected_protocol", "expected_current_provider", "provider_name", "evidence"
    ]
    try:
        handle = open(path, "x", encoding="utf-8-sig", newline="")
    except FileExistsError:
        raise ImportError("export path already exists: " + path)
    except (IOError, OSError) as error:
        raise ImportError("cannot create export CSV: {0}".format(error))
    with handle:
        writer = csv.DictWriter(handle, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(rows)
    return len(rows), len(set(row["vin"] for row in rows))


def apply_rows(client, rows, diagnostics):
    updated = 0
    unchanged = 0
    for row in rows:
        diagnostic = diagnostics[row["vin"]]
        source = find_source(diagnostic, row)
        validate_source(row, source)
        if clean(source.get("providerOverride")) == row["providerName"]:
            unchanged += 1
            continue
        payload = {
            "version": policy_version(diagnostic, row),
            "providerName": row["providerName"],
            "providerEvidence": row["evidence"],
            "enabled": source["enabled"],
            "priority": source["priority"],
            "remark": clean(source.get("policyRemark"))
        }
        diagnostic = client.data(
            "PUT",
            update_path(row["vin"], row["sourceRef"]),
            payload=payload
        )
        diagnostics[row["vin"]] = diagnostic
        saved = find_source(diagnostic, row)
        if clean(saved.get("providerOverride")) != row["providerName"]:
            raise ImportError(
                "line {0}: server did not persist provider for VIN {1}".format(row["line"], row["vin"])
            )
        updated += 1
        print(
            "source_provider_row=updated vin={0} source_ref={1} provider={2}".format(
                row["vin"], row["sourceRef"][:12], row["providerName"]
            )
        )
    return updated, unchanged


def parser():
    value = argparse.ArgumentParser(
        description="Preflight and apply an authoritative source-provider CSV through the audited admin API."
    )
    mode = value.add_mutually_exclusive_group(required=True)
    mode.add_argument("--csv", help="UTF-8 CSV with vin,source_ref,provider_name,evidence")
    mode.add_argument(
        "--export-missing",
        metavar="PATH",
        help="Write a fillable CSV for current JT808 provider gaps; refuses to overwrite an existing file."
    )
    value.add_argument("--base-url", default="http://127.0.0.1:20300")
    value.add_argument("--token-env", default="SOURCE_PROVIDER_ADMIN_TOKEN")
    value.add_argument("--timeout", type=float, default=20.0)
    value.add_argument(
        "--apply",
        action="store_true",
        help="Apply changes after a full preflight. Without this flag the command is read-only."
    )
    return value


def main(argv=None):
    args = parser().parse_args(argv)
    token = os.environ.get(args.token_env, "")
    if not token:
        print("source_provider_import=failed reason=token_environment_missing", file=sys.stderr)
        return 2
    try:
        client = Client(args.base_url, token, args.timeout)
        if args.export_missing:
            if args.apply:
                raise ImportError("--apply cannot be used with --export-missing")
            row_count, vehicle_count = export_missing(client, args.export_missing)
            print(
                "source_provider_export=ok rows={0} vehicles={1} path={2}".format(
                    row_count, vehicle_count, args.export_missing
                )
            )
            return 0
        rows = read_rows(args.csv)
        diagnostics, changes = preflight(client, rows)
        unchanged = len(rows) - changes
        if not args.apply:
            print(
                "source_provider_import=ready rows={0} vehicles={1} changes={2} unchanged={3} mode=dry-run".format(
                    len(rows), len(diagnostics), changes, unchanged
                )
            )
            return 0
        updated, unchanged = apply_rows(client, rows, diagnostics)
    except ImportError as error:
        print(
            "source_provider_import=failed reason={0}".format(str(error).replace("\n", " ")),
            file=sys.stderr
        )
        return 1
    print(
        "source_provider_import=ok rows={0} vehicles={1} updated={2} unchanged={3}".format(
            len(rows), len(diagnostics), updated, unchanged
        )
    )
    return 0


if __name__ == "__main__":
    sys.exit(main())
