from __future__ import annotations

import csv
import hashlib
import io
import os
import re
from pathlib import Path
from datetime import datetime, timezone

from fastapi import HTTPException

from app.modules.clients import repository
from sqlalchemy import select, func, or_
from app.core.security.passwords import hash_password
from app.modules.clients.association_admin_service import (
    ensure_active_association,
    update_association_fields,
)
from app.modules.clients.models import ClientAcceptanceDeclaration, ClientKycVerification, ClientEngagementLetter
from app.modules.clients.constants import (
    CLIENT_CATEGORY_OPTIONS,
    CLIENT_STATUS,
    CLIENT_TYPES,
    RISK_CATEGORIES,
    CLIENT_ACCEPTANCE_APPROVAL_REQUIRED_RISKS,
)
from app.modules.alerts.service import create_alert
from app.modules.core.iam.models import User
from app.modules.core.rbac.models import Role, UserRole
from app.modules.documents.models import PermanentClientDocument
from app.modules.consultants.service import sync_primary_client_consultant_link
from app.modules.clients.models import Client



def _get_client_group(*args, **kwargs):
    from app.modules.client_groups.service import get_group
    return get_group(*args, **kwargs)


def _payload_from_schema(data):
    payload = data.model_dump(exclude_none=True) if hasattr(data, "model_dump") else data.dict(exclude_none=True)
    # primary_consultant_id belongs to ClientConsultantLink, not the clients table.
    payload.pop("primary_consultant_id", None)
    return payload






def _validate_client_group_assignment(db, *, payload: dict, existing_row=None):
    group_id = payload.get("client_group_id")
    tenant_id = payload.get("tenant_id") or getattr(existing_row, "tenant_id", None)
    if group_id:
        group = _get_client_group(db, tenant_id=int(tenant_id), group_id=int(group_id))
        if not group or not group.is_active:
            raise HTTPException(status_code=400, detail="Selected client group is invalid or inactive for this firm.")
    if payload.get("is_group_head") and group_id:
        stmt = select(Client).where(Client.tenant_id == int(tenant_id), Client.client_group_id == int(group_id), Client.is_group_head.is_(True))
        if existing_row is not None:
            stmt = stmt.where(Client.id != existing_row.id)
        existing_head = db.execute(stmt).scalar_one_or_none()
        if existing_head:
            raise HTTPException(status_code=400, detail=f"This group already has group head {existing_head.client_code} - {existing_head.client_name}.")
    if not group_id:
        payload["group_relationship"] = None
        payload["is_group_head"] = False


def _is_high_risk(risk_category: str | None) -> bool:
    return (risk_category or "").strip().lower() in CLIENT_ACCEPTANCE_APPROVAL_REQUIRED_RISKS


def _client_acceptance_ready(payload: dict) -> bool:
    if not payload.get("acceptance_required", True):
        return True
    if not payload.get("independence_check_completed"):
        return False
    if not payload.get("conflict_check_completed"):
        return False
    if not payload.get("kyc_completed"):
        return False
    if payload.get("engagement_letter_required", True) and not payload.get("engagement_letter_received"):
        return False
    return True


def _enforce_client_acceptance_controls(payload: dict, *, existing_row=None):
    risk = payload.get("risk_category")
    if risk is None and existing_row is not None:
        risk = getattr(existing_row, "risk_category", None)

    status = payload.get("status")
    if status is None and existing_row is not None:
        status = getattr(existing_row, "status", None)

    acceptance_status = payload.get("acceptance_status")
    if acceptance_status is None and existing_row is not None:
        acceptance_status = getattr(existing_row, "acceptance_status", "pending_review")
    acceptance_status = acceptance_status or "pending_review"

    if _is_high_risk(risk) and status == "active" and acceptance_status != "approved":
        raise HTTPException(
            status_code=400,
            detail="High/Critical risk clients cannot be active until client acceptance is approved by an authorised partner or firm admin.",
        )

    if acceptance_status == "approved" and not _client_acceptance_ready(payload):
        raise HTTPException(
            status_code=400,
            detail="Client acceptance cannot be approved until independence, conflict, KYC and required engagement letter controls are completed.",
        )

    return payload

def _ensure_portal_passwords(email: str | None, portal_password: str | None, portal_password_confirm: str | None, *, required: bool):
    email_clean = (email or '').strip().lower()
    pw = (portal_password or '').strip()
    pw2 = (portal_password_confirm or '').strip()
    if not required and not pw and not pw2:
        return email_clean, None
    if not email_clean:
        raise HTTPException(status_code=400, detail="Email is required to create the client frontend login.")
    if len(pw) < 8:
        raise HTTPException(status_code=400, detail="Portal password must be at least 8 characters.")
    if pw != pw2:
        raise HTTPException(status_code=400, detail="Portal password and confirm password do not match.")
    return email_clean, pw


def _sync_client_portal_user(db, *, row, portal_password: str | None = None, portal_password_confirm: str | None = None):
    email_clean, pw = _ensure_portal_passwords(
        getattr(row, 'email', None),
        portal_password,
        portal_password_confirm,
        required=bool(portal_password or portal_password_confirm or not getattr(row, 'portal_user_id', None)),
    )

    if not pw:
        return row

    existing_user = repository.get_user_by_email(db, email=email_clean, exclude_user_id=getattr(row, 'portal_user_id', None))
    if existing_user:
        raise HTTPException(status_code=400, detail="That email is already used by another login.")

    if row.portal_user_id:
        user = db.get(repository.User, int(row.portal_user_id))
        if not user:
            row = repository.update_client(db, row, {'portal_user_id': None})
        else:
            user.email = email_clean
            user.full_name = (row.client_name or '').strip()
            user.password_hash = hash_password(pw)
            user.tenant_id = row.tenant_id
            user.branch_id = row.branch_id
            user.is_active = True
            user.allow_login = True
            user.is_locked = False
            user.must_change_password = False
            db.add(user)
            db.commit()
            db.refresh(user)
            return row

    if not row.portal_user_id:
        user = repository.create_portal_user(
            db,
            email=email_clean,
            full_name=row.client_name,
            tenant_id=row.tenant_id,
            branch_id=row.branch_id,
            password=pw,
        )
        role = repository.get_role_by_name(db, 'Client')
        if not role:
            raise HTTPException(status_code=500, detail='Client role is not available.')
        repository.ensure_user_role(db, user_id=user.id, role_id=role.id)
        row = repository.update_client(db, row, {'portal_user_id': user.id})
    return row


def _validate_scope_for_create(data, scope, actor_user_id):
    if scope.own_only:
        data.partner_id = scope.locked_partner_id or actor_user_id
    if not scope.allow_cross_tenant:
        data.tenant_id = scope.tenant_id
    if not scope.allow_cross_branch and scope.branch_id:
        data.branch_id = scope.branch_id
    return data


def _validate_scope_for_edit(data, scope, actor_user_id, *, existing_row, current_user_roles):
    role_names = {str(r).lower() for r in current_user_roles}

    if data.tenant_id is None:
        data.tenant_id = existing_row.tenant_id
    if data.branch_id is None:
        data.branch_id = existing_row.branch_id
    if data.partner_id is None:
        data.partner_id = existing_row.partner_id

    if "partner" in role_names and data.partner_id and data.partner_id != actor_user_id:
        raise HTTPException(status_code=400, detail="Partner users cannot assign clients to another partner.")

    if "consultant" in role_names and data.partner_id and data.partner_id != existing_row.partner_id:
        raise HTTPException(status_code=400, detail="Consultants cannot assign or change partner mapping.")

    if "firm admin" in role_names:
        if existing_row.tenant_id != scope.tenant_id:
            raise HTTPException(status_code=403, detail="Firm Admin can only manage clients within own firm.")
        data.tenant_id = scope.tenant_id
        if not scope.allow_cross_branch and data.branch_id != scope.branch_id:
            raise HTTPException(status_code=403, detail="Branch change is not allowed in current scope.")
        return data

    if "system admin" in role_names:
        return data

    if scope.own_only:
        data.partner_id = scope.locked_partner_id or actor_user_id
        if existing_row.partner_id != actor_user_id:
            raise HTTPException(status_code=403, detail="You can only edit your own associated clients.")

    return data


def _write_association_from_client(db, client_row, *, actor_user_id, current_user_roles):
    roles = {str(r).lower() for r in current_user_roles}
    ensure_active_association(db, client_row.id)

    if "system admin" in roles:
        return update_association_fields(
            db,
            client_row.id,
            association_type="firm" if client_row.tenant_id else "self_service_unassigned",
            firm_tenant_id=client_row.tenant_id,
            partner_user_id=client_row.partner_id,
            created_source="system_admin",
        )

    if "firm admin" in roles:
        return update_association_fields(
            db,
            client_row.id,
            association_type="firm",
            firm_tenant_id=client_row.tenant_id,
            partner_user_id=client_row.partner_id,
            created_source="firm_admin",
        )

    if "partner" in roles:
        return update_association_fields(
            db,
            client_row.id,
            association_type="firm",
            firm_tenant_id=client_row.tenant_id,
            partner_user_id=actor_user_id,
            created_source="partner",
        )

    if "consultant" in roles:
        return update_association_fields(
            db,
            client_row.id,
            association_type="consultant",
            consultant_id=actor_user_id,
            created_source="consultant",
        )

    return update_association_fields(
        db,
        client_row.id,
        association_type="self_service_unassigned",
        created_source="self_service",
    )


def create_client_service(db, *, data, actor_user_id: int, scope, current_user_roles=None, portal_password: str | None = None, portal_password_confirm: str | None = None):
    current_user_roles = current_user_roles or []
    data = _validate_scope_for_create(data, scope, actor_user_id)

    existing = repository.get_client_by_code(db, tenant_id=data.tenant_id, client_code=data.client_code)
    if existing:
        raise HTTPException(status_code=400, detail="Client code already exists.")

    if data.pan:
        existing_pan = repository.get_client_by_pan(db, tenant_id=data.tenant_id, pan=data.pan)
        if existing_pan:
            raise HTTPException(status_code=400, detail="PAN already exists for another client in this tenant.")

    if data.gstin:
        existing_gstin = repository.get_client_by_gstin(db, tenant_id=data.tenant_id, gstin=data.gstin)
        if existing_gstin:
            raise HTTPException(status_code=400, detail="GSTIN already exists for another client in this tenant.")

    payload = _payload_from_schema(data)
    _enforce_client_acceptance_controls(payload)
    _validate_client_group_assignment(db, payload=payload)
    row = repository.create_client(db, payload)
    row = _sync_client_portal_user(db, row=row, portal_password=portal_password, portal_password_confirm=portal_password_confirm)
    sync_primary_client_consultant_link(db, client=row, consultant_id=getattr(data, "primary_consultant_id", None), actor_user_id=actor_user_id)
    _write_association_from_client(db, row, actor_user_id=actor_user_id, current_user_roles=current_user_roles)

    repository.write_audit_log(
        db,
        client_id=row.id,
        tenant_id=row.tenant_id,
        branch_id=row.branch_id,
        actor_user_id=actor_user_id,
        action="created",
        summary="Client created with association sync.",
        payload_json={"client_id": row.id, "partner_id": row.partner_id},
    )
    return row


def update_client_service(db, *, row, data, actor_user_id: int, scope, current_user_roles=None, portal_password: str | None = None, portal_password_confirm: str | None = None):
    current_user_roles = current_user_roles or []
    data = _validate_scope_for_edit(
        data,
        scope,
        actor_user_id,
        existing_row=row,
        current_user_roles=current_user_roles,
    )

    payload = _payload_from_schema(data)
    _enforce_client_acceptance_controls(payload, existing_row=row)
    _validate_client_group_assignment(db, payload=payload, existing_row=row)

    if payload.get("pan"):
        existing_pan = repository.get_client_by_pan(db, tenant_id=payload["tenant_id"], pan=payload["pan"])
        if existing_pan and existing_pan.id != row.id:
            raise HTTPException(status_code=400, detail="PAN already exists for another client in this tenant.")

    if payload.get("gstin"):
        existing_gstin = repository.get_client_by_gstin(db, tenant_id=payload["tenant_id"], gstin=payload["gstin"])
        if existing_gstin and existing_gstin.id != row.id:
            raise HTTPException(status_code=400, detail="GSTIN already exists for another client in this tenant.")

    updated = repository.update_client(db, row, payload)
    updated = _sync_client_portal_user(db, row=updated, portal_password=portal_password, portal_password_confirm=portal_password_confirm)
    sync_primary_client_consultant_link(db, client=updated, consultant_id=getattr(data, "primary_consultant_id", None), actor_user_id=actor_user_id)
    _write_association_from_client(db, updated, actor_user_id=actor_user_id, current_user_roles=current_user_roles)

    repository.write_audit_log(
        db,
        client_id=updated.id,
        tenant_id=updated.tenant_id,
        branch_id=updated.branch_id,
        actor_user_id=actor_user_id,
        action="updated",
        summary="Client updated with association sync.",
        payload_json={"client_id": updated.id, "partner_id": updated.partner_id},
    )
    return updated


def get_client_or_404(
    db,
    *,
    client_id: int,
    tenant_id: int,
    branch_id: int | None,
    allow_cross_branch: bool,
    allow_all_clients: bool = False,
    viewer_partner_id: int | None = None,
    allow_review_access: bool = False,
):
    row = repository.get_client_by_id(db, client_id)
    if not row:
        raise HTTPException(status_code=404, detail="Client not found.")
    if not allow_all_clients and row.tenant_id != tenant_id:
        raise HTTPException(status_code=404, detail="Client not found in current tenant.")
    if not allow_all_clients and not allow_cross_branch and branch_id and row.branch_id != branch_id:
        raise HTTPException(status_code=404, detail="Client not found in current branch.")
    if viewer_partner_id is not None and int(row.partner_id or 0) != int(viewer_partner_id):
        if not allow_review_access or not repository.has_partner_review_access(
            db, tenant_id=tenant_id, client_id=client_id, partner_user_id=viewer_partner_id
        ):
            raise HTTPException(status_code=404, detail="Client not found.")
    return row


def list_clients_payload(db, **kwargs):
    return repository.list_clients(db, **kwargs)


def list_client_audit_logs(db, *, row, limit: int = 50):
    return repository.list_audit_logs(db, client_id=row.id, limit=limit)


def deactivate_client_service(db, *, row, actor_user_id: int):
    row = repository.update_client(db, row, {"status": "inactive"})
    repository.write_audit_log(
        db,
        client_id=row.id,
        tenant_id=row.tenant_id,
        branch_id=row.branch_id,
        actor_user_id=actor_user_id,
        action="deactivated",
        summary="Client deactivated.",
        payload_json=None,
    )
    return row


def activate_client_service(db, *, row, actor_user_id: int):
    payload = {
        "status": "active",
        "risk_category": getattr(row, "risk_category", None),
        "acceptance_status": getattr(row, "acceptance_status", "pending_review"),
        "acceptance_required": getattr(row, "acceptance_required", True),
        "independence_check_completed": getattr(row, "independence_check_completed", False),
        "conflict_check_completed": getattr(row, "conflict_check_completed", False),
        "kyc_completed": getattr(row, "kyc_completed", False),
        "engagement_letter_required": getattr(row, "engagement_letter_required", True),
        "engagement_letter_received": getattr(row, "engagement_letter_received", False),
    }
    _enforce_client_acceptance_controls(payload, existing_row=row)
    row = repository.update_client(db, row, {"status": "active"})
    repository.write_audit_log(
        db,
        client_id=row.id,
        tenant_id=row.tenant_id,
        branch_id=row.branch_id,
        actor_user_id=actor_user_id,
        action="activated",
        summary="Client activated.",
        payload_json=None,
    )
    return row


def archive_client_service(db, *, row, actor_user_id: int):
    row = repository.update_client(db, row, {"status": "archived", "is_archived": True})
    repository.write_audit_log(
        db,
        client_id=row.id,
        tenant_id=row.tenant_id,
        branch_id=row.branch_id,
        actor_user_id=actor_user_id,
        action="archived",
        summary="Client archived.",
        payload_json=None,
    )
    return row


def restore_client_service(db, *, row, actor_user_id: int):
    row = repository.update_client(db, row, {"status": "active", "is_archived": False})
    repository.write_audit_log(
        db,
        client_id=row.id,
        tenant_id=row.tenant_id,
        branch_id=row.branch_id,
        actor_user_id=actor_user_id,
        action="restored",
        summary="Client restored from archive.",
        payload_json=None,
    )
    return row


def export_clients_csv(payload: dict) -> str:
    output = io.StringIO()
    writer = csv.writer(output)
    writer.writerow(
        [
            "client_code",
            "client_name",
            "client_type",
            "status",
            "pan",
            "gstin",
            "partner",
            "client_group_code",
            "client_group_name",
            "group_relationship",
            "is_group_head",
            "association_type",
            "association_source",
        ]
    )
    for row in payload.get("rows", []):
        writer.writerow(
            [
                row.get("client_code"),
                row.get("client_name"),
                row.get("client_type"),
                row.get("status"),
                row.get("pan"),
                row.get("gstin"),
                row.get("partner_name") or row.get("effective_partner_id"),
                row.get("client_group_code"),
                row.get("client_group_name"),
                row.get("group_relationship"),
                row.get("is_group_head"),
                row.get("association_type"),
                row.get("assoc_created_source"),
            ]
        )
    return output.getvalue()


def get_filter_options():
    return {
        "client_types": CLIENT_TYPES,
        "client_statuses": CLIENT_STATUS,
        "client_categories": CLIENT_CATEGORY_OPTIONS,
        "risk_categories": RISK_CATEGORIES,
    }


SELF_SERVICE_EDITABLE_FIELDS = {
    "client_name",
    "trade_name",
    "contact_person_name",
    "contact_person_designation",
    "mobile",
    "alternate_mobile",
    "email",
    "alternate_email",
    "address_line_1",
    "address_line_2",
    "city",
    "state",
    "pincode",
    "country",
    "notes",
}


def update_client_self_profile_service(db, *, row, data, current_user):
    payload = _payload_from_schema(data)
    payload = {key: value for key, value in payload.items() if key in SELF_SERVICE_EDITABLE_FIELDS}

    new_email = (payload.get("email") or "").strip().lower()
    if new_email:
        existing_user = repository.get_user_by_email(db, email=new_email, exclude_user_id=int(current_user.id))
        if existing_user:
            raise HTTPException(status_code=400, detail="That email is already used by another login.")

    updated = repository.update_client(db, row, payload)

    if new_email and new_email != (getattr(current_user, "email", "") or "").strip().lower():
        current_user.email = new_email
        db.add(current_user)
        db.commit()
        db.refresh(current_user)

    repository.write_audit_log(
        db,
        client_id=updated.id,
        tenant_id=updated.tenant_id,
        branch_id=updated.branch_id,
        actor_user_id=current_user.id,
        action="client_self_profile_updated",
        summary="Client updated own contact profile.",
        payload_json={"fields": sorted(payload.keys())},
    )
    return updated


def reset_client_portal_password_service(db, *, current_user, new_password: str):
    if len((new_password or "").strip()) < 8:
        raise HTTPException(status_code=400, detail="New password must be at least 8 characters.")
    current_user.password_hash = hash_password(new_password.strip())
    current_user.must_change_password = False
    db.add(current_user)
    db.commit()
    db.refresh(current_user)
    return current_user


def approve_client_acceptance_service(db, *, row, actor_user_id: int, review_notes: str | None = None):
    payload = {
        "acceptance_status": "approved",
        "acceptance_required": getattr(row, "acceptance_required", True),
        "independence_check_completed": getattr(row, "independence_check_completed", False),
        "conflict_check_completed": getattr(row, "conflict_check_completed", False),
        "kyc_completed": getattr(row, "kyc_completed", False),
        "engagement_letter_required": getattr(row, "engagement_letter_required", True),
        "engagement_letter_received": getattr(row, "engagement_letter_received", False),
        "risk_category": getattr(row, "risk_category", None),
        "status": getattr(row, "status", None),
    }
    _enforce_client_acceptance_controls(payload, existing_row=row)
    now = datetime.now(timezone.utc)
    updated = repository.update_client(db, row, {
        "acceptance_status": "approved",
        "acceptance_approved_by_user_id": actor_user_id,
        "acceptance_approved_at_utc": now,
        "acceptance_review_notes": review_notes or getattr(row, "acceptance_review_notes", None),
        "acceptance_rejection_reason": None,
    })
    repository.write_audit_log(
        db,
        client_id=updated.id,
        tenant_id=updated.tenant_id,
        branch_id=updated.branch_id,
        actor_user_id=actor_user_id,
        action="acceptance_approved",
        summary="Client acceptance approved.",
        payload_json={"review_notes": review_notes},
    )
    return updated


def reject_client_acceptance_service(db, *, row, actor_user_id: int, rejection_reason: str | None = None):
    updated = repository.update_client(db, row, {
        "acceptance_status": "rejected",
        "acceptance_approved_by_user_id": None,
        "acceptance_approved_at_utc": None,
        "acceptance_rejection_reason": rejection_reason,
    })
    repository.write_audit_log(
        db,
        client_id=updated.id,
        tenant_id=updated.tenant_id,
        branch_id=updated.branch_id,
        actor_user_id=actor_user_id,
        action="acceptance_rejected",
        summary="Client acceptance rejected.",
        payload_json={"rejection_reason": rejection_reason},
    )
    return updated


def mark_client_acceptance_pending_service(db, *, row, actor_user_id: int, review_notes: str | None = None):
    updated = repository.update_client(db, row, {
        "acceptance_status": "pending_review",
        "acceptance_approved_by_user_id": None,
        "acceptance_approved_at_utc": None,
        "acceptance_review_notes": review_notes or getattr(row, "acceptance_review_notes", None),
        "acceptance_rejection_reason": None,
    })
    repository.write_audit_log(
        db,
        client_id=updated.id,
        tenant_id=updated.tenant_id,
        branch_id=updated.branch_id,
        actor_user_id=actor_user_id,
        action="acceptance_pending_review",
        summary="Client acceptance moved to pending review.",
        payload_json={"review_notes": review_notes},
    )
    return updated

# ---------------------------------------------------------------------------
# Phase 2.1 - digital client acceptance workflow
# ---------------------------------------------------------------------------

DECLARATION_TYPES = {"independence", "conflict"}
DECLARATION_CLEAR_STATUSES = {"declared_clear"}
ENGAGEMENT_LETTER_ACCEPTED_STATUSES = {"digitally_accepted", "manual_verified"}
KYC_REQUIRED_KEYWORDS = ["PAN", "GST", "Incorporation", "Partnership Deed", "Address Proof", "Authorisation"]
ACCEPTANCE_STORAGE_ROOT = Path(os.getenv("CLIENT_ACCEPTANCE_STORAGE_ROOT", "documents/client_acceptance")).resolve()


def _clean_text(value: str | None, max_len: int | None = None) -> str | None:
    value = (value or "").strip()
    if not value:
        return None
    return value[:max_len] if max_len else value


def _slug(value: str | None) -> str:
    value = re.sub(r"[^A-Za-z0-9._-]+", "_", (value or "file").strip())
    return value.strip("._-")[:80] or "file"


def _request_ip_and_agent(request):
    ip = None
    try:
        ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip() or request.client.host
    except Exception:
        ip = None
    try:
        agent = request.headers.get("user-agent")
    except Exception:
        agent = None
    return ip, agent


def _declaration_text(client, declaration_type: str) -> str:
    if declaration_type == "conflict":
        return (
            f"I confirm that I have reviewed possible conflict of interest for {client.client_name}. "
            "I do not have any financial, business, family, employment, litigation or other relationship "
            "that creates a conflict, except as specifically disclosed in my remarks."
        )
    return (
        f"I confirm that I am independent for the proposed/continuing engagement of {client.client_name}. "
        "I will immediately report any independence threat or safeguard requirement to the engagement partner."
    )


def _role_name_for_user(db, user_id: int) -> str:
    names = db.execute(
        select(Role.name)
        .join(UserRole, UserRole.role_id == Role.id)
        .where(UserRole.user_id == user_id)
        .order_by(Role.name.asc())
    ).scalars().all()
    return ",".join(names) if names else ""


def _applicable_acceptance_user_ids(db, client) -> list[int]:
    ids = []
    for uid in [getattr(client, "partner_id", None), getattr(client, "default_review_partner_user_id", None), getattr(client, "created_by_user_id", None)]:
        if uid and uid not in ids:
            ids.append(int(uid))
    if not ids and getattr(client, "updated_by_user_id", None):
        ids.append(int(client.updated_by_user_id))
    return ids


def _safe_create_alert(db, *, user_id: int, title: str, message: str, client, actor_user_id: int | None = None, target_url: str | None = None):
    try:
        create_alert(
            db,
            user_id=user_id,
            tenant_id=client.tenant_id,
            branch_id=client.branch_id,
            role_context="client_acceptance",
            alert_type="client",
            priority="high",
            title=title,
            message=message,
            target_url=target_url or f"/clients/{client.id}",
            created_by_user_id=actor_user_id,
            commit=False,
        )
    except Exception:
        pass


def list_client_acceptance_declarations(db, *, client_id: int):
    return db.execute(
        select(ClientAcceptanceDeclaration)
        .where(ClientAcceptanceDeclaration.client_id == client_id)
        .order_by(ClientAcceptanceDeclaration.declaration_type.asc(), ClientAcceptanceDeclaration.assigned_user_id.asc())
    ).scalars().all()


def list_my_pending_acceptance_declarations(db, *, user_id: int, limit: int = 25):
    return db.execute(
        select(ClientAcceptanceDeclaration)
        .where(ClientAcceptanceDeclaration.assigned_user_id == user_id, ClientAcceptanceDeclaration.status == "pending")
        .order_by(ClientAcceptanceDeclaration.requested_at_utc.desc())
        .limit(limit)
    ).scalars().all()


def request_client_acceptance_declarations_service(db, *, row, actor_user_id: int):
    assigned_ids = _applicable_acceptance_user_ids(db, row)
    if not assigned_ids:
        raise HTTPException(status_code=400, detail="No applicable partner/review user is mapped to this client.")
    created = 0
    for uid in assigned_ids:
        for dtype in sorted(DECLARATION_TYPES):
            existing = db.execute(
                select(ClientAcceptanceDeclaration).where(
                    ClientAcceptanceDeclaration.client_id == row.id,
                    ClientAcceptanceDeclaration.declaration_type == dtype,
                    ClientAcceptanceDeclaration.assigned_user_id == uid,
                )
            ).scalar_one_or_none()
            if not existing:
                db.add(ClientAcceptanceDeclaration(
                    client_id=row.id,
                    tenant_id=row.tenant_id,
                    branch_id=row.branch_id,
                    declaration_type=dtype,
                    assigned_user_id=uid,
                    assigned_role_snapshot=_role_name_for_user(db, uid),
                    declaration_text=_declaration_text(row, dtype),
                    requested_by_user_id=actor_user_id,
                ))
                created += 1
            _safe_create_alert(
                db,
                user_id=uid,
                title=f"{dtype.title()} declaration required",
                message=f"Please submit your {dtype} declaration for {row.client_name}.",
                client=row,
                actor_user_id=actor_user_id,
            )
    repository.write_audit_log(
        db,
        client_id=row.id,
        tenant_id=row.tenant_id,
        branch_id=row.branch_id,
        actor_user_id=actor_user_id,
        action="acceptance_declarations_requested",
        summary="Digital independence/conflict declarations requested.",
        payload_json={"created": created, "assigned_user_ids": assigned_ids},
    )
    db.commit()
    return list_client_acceptance_declarations(db, client_id=row.id)


def submit_client_acceptance_declaration_service(db, *, declaration_id: int, current_user, clear: bool, notes: str | None, issue_details: str | None, request=None):
    decl = db.get(ClientAcceptanceDeclaration, declaration_id)
    if not decl:
        raise HTTPException(status_code=404, detail="Declaration not found.")
    if decl.assigned_user_id != current_user.id:
        raise HTTPException(status_code=403, detail="This declaration is assigned to another user.")
    ip, agent = _request_ip_and_agent(request)
    decl.status = "declared_clear" if clear else "declared_issue"
    decl.response_notes = _clean_text(notes)
    decl.issue_details = _clean_text(issue_details)
    decl.responded_at_utc = datetime.now(timezone.utc)
    decl.response_ip = ip
    decl.response_user_agent = agent
    db.add(decl)
    db.flush()
    _refresh_acceptance_derived_statuses(db, client_id=decl.client_id, actor_user_id=current_user.id)
    db.commit()
    return decl


def get_client_kyc_verification(db, *, client_id: int) -> ClientKycVerification | None:
    return db.execute(select(ClientKycVerification).where(ClientKycVerification.client_id == client_id)).scalar_one_or_none()


def _permanent_docs_summary(db, *, client_id: int) -> tuple[str, int]:
    docs = db.execute(
        select(PermanentClientDocument)
        .where(PermanentClientDocument.client_id == client_id, PermanentClientDocument.is_deleted.is_(False))
        .order_by(PermanentClientDocument.category.asc(), PermanentClientDocument.title.asc())
    ).scalars().all()
    parts = []
    for d in docs:
        parts.append(f"{d.category}: {d.title} ({d.current_version_no} version(s))")
    return "\n".join(parts), len(docs)


def sync_client_kyc_from_permanent_documents_service(db, *, row, actor_user_id: int):
    summary, count = _permanent_docs_summary(db, client_id=row.id)
    kyc = get_client_kyc_verification(db, client_id=row.id)
    status = "pending_verification" if count else "pending_documents"
    required_summary = "\n".join(f"- {x}" for x in KYC_REQUIRED_KEYWORDS)
    if not kyc:
        kyc = ClientKycVerification(client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id)
    if kyc.status != "verified":
        kyc.status = status
    kyc.required_document_summary = required_summary
    kyc.available_document_summary = summary or "No permanent client documents uploaded yet."
    db.add(kyc)
    repository.write_audit_log(
        db,
        client_id=row.id,
        tenant_id=row.tenant_id,
        branch_id=row.branch_id,
        actor_user_id=actor_user_id,
        action="kyc_synced_from_permanent_documents",
        summary="KYC status refreshed from permanent client documents.",
        payload_json={"permanent_document_count": count, "status": kyc.status},
    )
    db.commit()
    return kyc


def verify_client_kyc_service(db, *, row, actor_user_id: int, notes: str | None = None):
    kyc = sync_client_kyc_from_permanent_documents_service(db, row=row, actor_user_id=actor_user_id)
    if not kyc.available_document_summary or kyc.available_document_summary.startswith("No permanent"):
        raise HTTPException(status_code=400, detail="KYC cannot be verified until permanent client documents are uploaded.")
    now = datetime.now(timezone.utc)
    kyc.status = "verified"
    kyc.verified_by_user_id = actor_user_id
    kyc.verified_at_utc = now
    kyc.rejected_by_user_id = None
    kyc.rejected_at_utc = None
    kyc.verification_notes = _clean_text(notes)
    db.add(kyc)
    updated = repository.update_client(db, row, {"kyc_completed": True})
    repository.write_audit_log(db, client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id, actor_user_id=actor_user_id, action="kyc_verified", summary="KYC verified using permanent client documents.", payload_json={"notes": notes})
    db.commit()
    return updated, kyc


def reject_client_kyc_service(db, *, row, actor_user_id: int, notes: str | None = None):
    kyc = get_client_kyc_verification(db, client_id=row.id) or ClientKycVerification(client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id)
    now = datetime.now(timezone.utc)
    kyc.status = "rejected"
    kyc.rejected_by_user_id = actor_user_id
    kyc.rejected_at_utc = now
    kyc.verification_notes = _clean_text(notes)
    db.add(kyc)
    updated = repository.update_client(db, row, {"kyc_completed": False})
    _safe_create_alert(db, user_id=row.portal_user_id, title="KYC documents require resubmission", message=notes or "Please update the permanent documents requested by the firm.", client=row, actor_user_id=actor_user_id, target_url="/client/documents") if getattr(row, "portal_user_id", None) else None
    repository.write_audit_log(db, client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id, actor_user_id=actor_user_id, action="kyc_rejected", summary="KYC rejected / resubmission requested.", payload_json={"notes": notes})
    db.commit()
    return updated, kyc


def get_current_engagement_letter(db, *, client_id: int) -> ClientEngagementLetter | None:
    return db.execute(
        select(ClientEngagementLetter)
        .where(ClientEngagementLetter.client_id == client_id)
        .order_by(ClientEngagementLetter.version_no.desc(), ClientEngagementLetter.id.desc())
    ).scalars().first()


def _default_engagement_letter_body(row) -> str:
    return f"""Dear {row.client_name},

We are pleased to confirm our understanding of the terms and objectives of our professional engagement. The services will be performed subject to applicable laws, professional standards, ICAI requirements, management responsibilities, timely submission of records and payment of agreed fees.

Management is responsible for the completeness and accuracy of records, explanations and representations provided to the firm. Our responsibility is limited to the scope agreed for the relevant service/financial year. This engagement letter, once accepted digitally or by signed upload, will form part of the client acceptance and continuance evidence.

Regards,
A R R R & Associates"""


def draft_client_engagement_letter_service(db, *, row, actor_user_id: int, title: str | None = None, body_text: str | None = None):
    latest = get_current_engagement_letter(db, client_id=row.id)
    version = (latest.version_no + 1) if latest else 1
    letter = ClientEngagementLetter(
        client_id=row.id,
        tenant_id=row.tenant_id,
        branch_id=row.branch_id,
        letter_code=f"ENG-{row.id}",
        title=_clean_text(title, 255) or f"Engagement Letter - {row.client_name}",
        body_text=_clean_text(body_text) or _default_engagement_letter_body(row),
        version_no=version,
        status="draft",
        created_by_user_id=actor_user_id,
    )
    db.add(letter)
    repository.write_audit_log(db, client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id, actor_user_id=actor_user_id, action="engagement_letter_drafted", summary="Engagement letter draft created.", payload_json={"version_no": version})
    db.commit()
    db.refresh(letter)
    return letter


def approve_and_send_engagement_letter_service(db, *, row, letter_id: int, actor_user_id: int):
    letter = db.get(ClientEngagementLetter, letter_id)
    if not letter or letter.client_id != row.id:
        raise HTTPException(status_code=404, detail="Engagement letter not found.")
    now = datetime.now(timezone.utc)
    letter.status = "sent_to_client"
    letter.approved_by_user_id = actor_user_id
    letter.approved_at_utc = now
    letter.sent_to_client_at_utc = now
    letter.pdf_hash_sha256 = hashlib.sha256(letter.body_text.encode("utf-8")).hexdigest()
    db.add(letter)
    if getattr(row, "portal_user_id", None):
        _safe_create_alert(db, user_id=row.portal_user_id, title="Engagement letter ready for acceptance", message=f"Please accept the engagement letter for {row.client_name} digitally using OTP or upload the signed copy.", client=row, actor_user_id=actor_user_id, target_url="/client/engagement-letter")
    repository.write_audit_log(db, client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id, actor_user_id=actor_user_id, action="engagement_letter_sent", summary="Engagement letter approved and sent to client.", payload_json={"letter_id": letter.id, "version_no": letter.version_no})
    db.commit()
    return letter


def render_engagement_letter_html(letter: ClientEngagementLetter, client) -> str:
    import html
    body = html.escape(letter.body_text).replace("\n", "<br>")
    return f"""<!doctype html><html><head><meta charset='utf-8'><title>{html.escape(letter.title)}</title>
<style>body{{font-family:Arial,sans-serif;max-width:850px;margin:40px auto;line-height:1.55;color:#111827}}.meta{{color:#64748b;font-size:13px}}.box{{border:1px solid #cbd5e1;border-radius:12px;padding:24px}}</style></head><body>
<div class='box'><h1>{html.escape(letter.title)}</h1><p class='meta'>Client: {html.escape(client.client_name)} | Version: {letter.version_no} | Status: {html.escape(letter.status)}</p><p>{body}</p></div>
<script>window.print && setTimeout(function(){{window.print()}}, 300);</script></body></html>"""


def digitally_accept_engagement_letter_service(db, *, row, letter_id: int, current_user, declaration_text: str | None, request=None):
    letter = db.get(ClientEngagementLetter, letter_id)
    if not letter or letter.client_id != row.id:
        raise HTTPException(status_code=404, detail="Engagement letter not found.")
    if letter.status not in {"sent_to_client", "manual_uploaded", "rejected"}:
        raise HTTPException(status_code=400, detail="Only partner-approved engagement letters sent to client can be accepted.")
    ip, agent = _request_ip_and_agent(request)
    now = datetime.now(timezone.utc)
    declaration = _clean_text(declaration_text) or "I have read and accept the engagement letter for and on behalf of the client."
    letter.status = "digitally_accepted"
    letter.accepted_mode = "digital_otp"
    letter.accepted_by_user_id = current_user.id
    letter.accepted_at_utc = now
    letter.accepted_ip = ip
    letter.accepted_user_agent = agent
    letter.acceptance_declaration_text = declaration
    letter.otp_verified = True
    db.add(letter)
    updated = repository.update_client(db, row, {"engagement_letter_received": True})
    repository.write_audit_log(db, client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id, actor_user_id=current_user.id, action="engagement_letter_digitally_accepted", summary="Engagement letter accepted digitally with OTP/session evidence.", payload_json={"letter_id": letter.id, "ip": ip})
    db.commit()
    return updated, letter


def save_manual_signed_engagement_letter_service(db, *, row, letter_id: int, upload_file, current_user, request=None):
    letter = db.get(ClientEngagementLetter, letter_id)
    if not letter or letter.client_id != row.id:
        raise HTTPException(status_code=404, detail="Engagement letter not found.")
    if letter.status not in {"sent_to_client", "manual_uploaded", "rejected"}:
        raise HTTPException(status_code=400, detail="Signed copy can be uploaded only after partner approval and sending to client.")
    ACCEPTANCE_STORAGE_ROOT.mkdir(parents=True, exist_ok=True)
    folder = ACCEPTANCE_STORAGE_ROOT / str(row.tenant_id) / str(row.id)
    folder.mkdir(parents=True, exist_ok=True)
    filename = _slug(getattr(upload_file, "filename", None) or "signed_engagement_letter.pdf")
    target = folder / f"letter_{letter.id}_signed_{filename}"
    content = upload_file.file.read()
    if not content:
        raise HTTPException(status_code=400, detail="Uploaded signed engagement letter is empty.")
    target.write_bytes(content)
    letter.status = "manual_uploaded"
    letter.accepted_mode = "manual_signed_upload"
    letter.accepted_by_user_id = current_user.id
    letter.accepted_at_utc = datetime.now(timezone.utc)
    letter.manual_signed_file_path = str(target)
    letter.manual_signed_file_hash_sha256 = hashlib.sha256(content).hexdigest()
    db.add(letter)
    repository.write_audit_log(db, client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id, actor_user_id=current_user.id, action="engagement_letter_signed_uploaded", summary="Client uploaded manually signed engagement letter.", payload_json={"letter_id": letter.id, "filename": filename})
    if row.partner_id:
        _safe_create_alert(db, user_id=row.partner_id, title="Signed engagement letter uploaded", message=f"Please verify signed engagement letter for {row.client_name}.", client=row, actor_user_id=current_user.id)
    db.commit()
    return letter


def verify_manual_engagement_letter_service(db, *, row, letter_id: int, actor_user_id: int):
    letter = db.get(ClientEngagementLetter, letter_id)
    if not letter or letter.client_id != row.id:
        raise HTTPException(status_code=404, detail="Engagement letter not found.")
    if letter.status != "manual_uploaded":
        raise HTTPException(status_code=400, detail="Only manually uploaded engagement letters can be verified.")
    now = datetime.now(timezone.utc)
    letter.status = "manual_verified"
    letter.manual_verified_by_user_id = actor_user_id
    letter.manual_verified_at_utc = now
    db.add(letter)
    updated = repository.update_client(db, row, {"engagement_letter_received": True})
    repository.write_audit_log(db, client_id=row.id, tenant_id=row.tenant_id, branch_id=row.branch_id, actor_user_id=actor_user_id, action="engagement_letter_manual_verified", summary="Manual signed engagement letter verified.", payload_json={"letter_id": letter.id})
    db.commit()
    return updated, letter


def _refresh_acceptance_derived_statuses(db, *, client_id: int, actor_user_id: int | None = None):
    client = repository.get_client_by_id(db, client_id)
    if not client:
        return None
    declarations = list_client_acceptance_declarations(db, client_id=client_id)
    independence = [d for d in declarations if d.declaration_type == "independence"]
    conflict = [d for d in declarations if d.declaration_type == "conflict"]
    independence_ok = bool(independence) and all(d.status in DECLARATION_CLEAR_STATUSES for d in independence)
    conflict_ok = bool(conflict) and all(d.status in DECLARATION_CLEAR_STATUSES for d in conflict)
    any_issue = any(d.status == "declared_issue" for d in declarations)
    kyc = get_client_kyc_verification(db, client_id=client_id)
    letter = get_current_engagement_letter(db, client_id=client_id)
    payload = {
        "independence_check_completed": independence_ok,
        "conflict_check_completed": conflict_ok,
        "kyc_completed": bool(kyc and kyc.status == "verified"),
        "engagement_letter_received": bool(letter and letter.status in ENGAGEMENT_LETTER_ACCEPTED_STATUSES),
    }
    if any_issue:
        payload["acceptance_status"] = "pending_review"
    updated = repository.update_client(db, client, payload)
    return updated


def build_client_acceptance_workflow_payload(db, *, client_id: int) -> dict:
    client = repository.get_client_by_id(db, client_id)
    declarations = list_client_acceptance_declarations(db, client_id=client_id)
    kyc = get_client_kyc_verification(db, client_id=client_id)
    letter = get_current_engagement_letter(db, client_id=client_id)
    permanent_summary, permanent_count = _permanent_docs_summary(db, client_id=client_id)
    return {
        "acceptance_declarations": declarations,
        "kyc_verification": kyc,
        "engagement_letter": letter,
        "permanent_document_summary": permanent_summary,
        "permanent_document_count": permanent_count,
        "workflow_ready": bool(client and client.independence_check_completed and client.conflict_check_completed and client.kyc_completed and (not client.engagement_letter_required or client.engagement_letter_received)),
    }
