import asyncio
import copy
import uuid
from urllib.parse import parse_qsl, urlsplit

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_db
from app.core.permissions import deny_vendor, require_permission, require_run_access
from app.core.security import AuthContext, get_auth_context
from app.crud.base import CRUDBase
from app.crud.code_runner import Diagnosis, check_python_syntax, diagnose, logs_of, run_code
from app.crud.params import resolve_input_params
from app.crud.secrets import Scrubber, fill_templates, load_values, referenced_names
from app.crud.soap_client import SoapSpec, inspect as soap_inspect
from app.crud import task_history
from app.crud.task_history import RunTrigger
from app.crud.tasks import run_task_with_navigation
from app.models.automation import Task, TaskRun
from app.models.identity import Tenant, User
from app.models.navigation import Page
from app.models.workflow import Form
from app.models.reporting import PaymentTransaction
from app.schemas.automation import (
    CodeProblem,
    CodeRunRequest,
    CodeRunResult,
    SoapInspectRequest,
    SoapInspectResult,
    TaskCreate,
    TaskDuplicate,
    TaskHistorySettings,
    TaskRead,
    TaskRunDetail,
    TaskRunLevelPage,
    TaskRunPage,
    TaskRunRequest,
    TaskRunResult,
    TaskRunStepRead,
    TaskRunSummary,
    TaskUpdate,
)

router = APIRouter(prefix="/tasks", tags=["tasks"])
crud = CRUDBase[Task, TaskCreate, TaskUpdate](Task)


@router.get("", response_model=list[TaskRead])
async def list_tasks(
    skip: int = 0,
    limit: int = 100,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("tasks", "view")),
) -> list[Task]:
    return await crud.list(db, ctx.tenant_id, skip=skip, limit=limit)


# Registered before the /{task_id} routes so "code" is never read as a task id.
@router.post("/soap/inspect", response_model=SoapInspectResult)
async def inspect_soap_service(
    payload: SoapInspectRequest,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("tasks", "edit")),
    __: AuthContext = Depends(deny_vendor()),
) -> dict:
    """The operations in a WSDL, for the SOAP node's picker. {{secret.NAME}}
    in the sign-in details is filled in here; never echoed back."""
    names = referenced_names(payload.model_dump())
    values = await load_values(db, ctx.tenant_id, names) if names else {}
    filled = fill_templates(payload.model_dump(), values)
    spec = SoapSpec(
        wsdl_url=filled["wsdl_url"].strip(),
        auth=filled["soap_auth"],
        username=filled["soap_username"],
        password=filled["soap_password"],
        headers=filled["headers"],
        timeout=filled["soap_timeout_seconds"],
        verify_tls=filled["soap_verify_tls"],
    )
    try:
        return await asyncio.to_thread(soap_inspect, spec)
    except Exception as exc:  # noqa: BLE001 - zeep/requests raise many types; the words are what the author needs
        detail = Scrubber(values)(str(exc).strip()[:500]) or type(exc).__name__
        raise HTTPException(status_code=400, detail=f"Couldn't read that WSDL: {detail}") from exc


@router.post("/code/run", response_model=CodeRunResult)
async def run_code_endpoint(
    payload: CodeRunRequest,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("tasks", "edit")),
    __: AuthContext = Depends(deny_vendor()),
) -> CodeRunResult:
    """Runs a Custom Node's script from the code editor, exactly as a task run
    would (app/crud/code_runner.py), and explains any problem: the failing
    line, what the error means and how to fix it. Same server-side execution
    as the node itself, so it needs the same right — editing tasks."""
    diagnosis = Diagnosis()
    # Inputs may read secrets (source "secret.NAME"), exactly as in a task run
    # — and, as there, every value is masked out of what comes back.
    params = [p.model_dump() for p in payload.input_params]
    secret_names = referenced_names(params)
    secret_values = await load_values(db, ctx.tenant_id, secret_names) if secret_names else {}
    scrub = Scrubber(secret_values)

    def result(ran: bool, detail: str, **extra) -> CodeRunResult:
        problems = [CodeProblem(**{**vars(p), "message": scrub(p.message), "fix": scrub(p.fix)}) for p in diagnosis.problems]
        ok = ran and extra.pop("run_ok", False) and not any(p.severity == "error" for p in problems)
        return CodeRunResult(ok=ok, ran=ran, detail=scrub(detail), problems=problems, **scrub(extra))

    if not payload.code.strip():
        diagnosis.add("error", "There is no code to run.", "Write the script, or use AI Assist to generate one.")
        return result(False, "no code")

    missing = sorted(secret_names - secret_values.keys())
    if missing:
        diagnosis.add("error", f"Unknown secret {', '.join(missing)}.", "Add it under Settings › Secrets, or fix the name — names are case-sensitive.")
        return result(False, "unknown secret")
    context = {**payload.context, "secret": secret_values} if secret_names else payload.context
    in_ok, in_detail, inputs = resolve_input_params(params, context)
    if not in_ok:
        diagnosis.add(
            "error", f"Inputs: {in_detail}.",
            "Give that variable a value in Test input (each input reads the variable named after \"from\"), or untick the input's Required box.",
        )
        return result(False, in_detail)

    if payload.language != "node" and not check_python_syntax(payload.code, diagnosis):
        diagnose(payload.language, payload.code, None, inputs, [], diagnosis)
        return result(False, "syntax error", inputs=inputs)

    run = await asyncio.to_thread(run_code, payload.language, payload.code, inputs)
    outputs = diagnose(payload.language, payload.code, run, inputs, [p.model_dump() for p in payload.output_params], diagnosis)
    return result(
        True, run.detail, run_ok=run.ok, inputs=inputs, result=run.result if run.ok else None, outputs=outputs,
        logs=logs_of(run), stderr=run.stderr[-4000:], exit_code=run.exit_code, duration_ms=run.duration_ms,
    )


@router.get("/history-settings", response_model=TaskHistorySettings)
async def get_history_settings(
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("tasks", "view")),
    __: AuthContext = Depends(deny_vendor()),
) -> TaskHistorySettings:
    days = (await db.execute(select(Tenant.task_history_days).where(Tenant.id == ctx.tenant_id))).scalar_one()
    return TaskHistorySettings(retention_days=days, max_runs_per_task=task_history.MAX_RUNS_PER_TASK)


@router.put("/history-settings", response_model=TaskHistorySettings)
async def update_history_settings(
    payload: TaskHistorySettings,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("settings", "edit")),
    __: AuthContext = Depends(deny_vendor()),
) -> TaskHistorySettings:
    """How long this tenant keeps run history. Shortening it takes effect at
    the next hourly cleanup."""
    tenant = (await db.execute(select(Tenant).where(Tenant.id == ctx.tenant_id))).scalar_one()
    tenant.task_history_days = payload.retention_days
    await db.flush()
    return TaskHistorySettings(retention_days=payload.retention_days, max_runs_per_task=task_history.MAX_RUNS_PER_TASK)


@router.get("/{task_id}", response_model=TaskRead)
async def get_task(
    task_id: uuid.UUID,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("tasks", "view")),
) -> Task:
    return await crud.get(db, ctx.tenant_id, task_id)


@router.post("", response_model=TaskRead, status_code=201)
async def create_task(
    payload: TaskCreate,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("tasks", "create")),
    __: AuthContext = Depends(deny_vendor()),
) -> Task:
    # CRUDBase.create dumps the whole payload with exclude_unset=True, which
    # (being recursive) also strips any field a node's own object omitted —
    # e.g. a node with no declared outputs would be stored with no
    # output_params key at all instead of the schema's `[]` default. The
    # Task Designer assumes every node has the full TaskNodeDef shape, so
    # re-dump nodes in full (matching how workflows' step_definitions are
    # already stored) and let it override the stripped-down version.
    return await crud.create(
        db,
        ctx.tenant_id,
        payload,
        created_by=ctx.user_id,
        nodes=[n.model_dump(mode="json") for n in payload.nodes],
        variables=[v.model_dump(mode="json") for v in payload.variables],
    )


@router.patch("/{task_id}", response_model=TaskRead)
async def update_task(
    task_id: uuid.UUID,
    payload: TaskUpdate,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("tasks", "edit")),
    __: AuthContext = Depends(deny_vendor()),
) -> Task:
    """Like Pages/Dashboards, a task stays freely editable regardless of
    status — draft vs. published just gates whether /run will fire it."""
    extra = {} if payload.nodes is None else {"nodes": [n.model_dump(mode="json") for n in payload.nodes]}
    if payload.variables is not None:
        extra["variables"] = [v.model_dump(mode="json") for v in payload.variables]
    return await crud.update(db, ctx.tenant_id, task_id, payload, **extra)


@router.post("/{task_id}/duplicate", response_model=TaskRead, status_code=201)
async def duplicate_task(
    task_id: uuid.UUID,
    payload: TaskDuplicate,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("tasks", "create")),
    __: AuthContext = Depends(deny_vendor()),
) -> Task:
    """A new draft task with the source's nodes. Node ids are kept as-is —
    they're only unique within a task, and its own transitions reference
    them. Form/page events and schedules point at the source task and
    aren't copied."""
    source = await crud.get(db, ctx.tenant_id, task_id)
    name = (payload.name or "").strip() or f"{source.name} (copy)"
    task = Task(
        tenant_id=ctx.tenant_id,
        name=name[:200],
        nodes=copy.deepcopy(source.nodes or []),
        variables=copy.deepcopy(source.variables or []),
        status="draft",
        created_by=ctx.user_id,
    )
    db.add(task)
    await db.flush()
    await db.refresh(task)
    return task


@router.post("/{task_id}/publish", response_model=TaskRead)
async def publish_task(
    task_id: uuid.UUID,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("tasks", "publish")),
    __: AuthContext = Depends(deny_vendor()),
) -> Task:
    task = await crud.get(db, ctx.tenant_id, task_id)
    task.status = "published"
    await db.flush()
    await db.refresh(task)
    return task


@router.delete("/{task_id}", status_code=204)
async def delete_task(
    task_id: uuid.UUID,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("tasks", "delete")),
    __: AuthContext = Depends(deny_vendor()),
) -> None:
    await crud.delete(db, ctx.tenant_id, task_id)


# What a payment's result looks like to a task (a Payment Callback block's
# "after payment" task). Only ever taken from the stored PaymentTransaction.
_PAYMENT_RESULT_KEYS = ("payment_status", "payment_paid", "payment_provider_status", "payment_record_id")
_PAYMENT_KEYS = ("payment_reference", "payment_label", "payment_amount", "payment_currency", *_PAYMENT_RESULT_KEYS)


async def _trusted_payment_context(db: AsyncSession, tenant_id: uuid.UUID, context: dict) -> dict:
    """A payment's outcome arrives at the browser in a redirect, so a caller
    could claim any status. When the context names a payment_reference, every
    payment_* value is replaced with the stored transaction's own (the
    gateway's verdict, recorded server-side by /payments/callback and
    /payments/verify); an unknown reference is "unverified". Without a
    reference, any claimed result is dropped — the before-payment amount/
    label a Payment Initiator sends are left alone.

    The record the payment is for rides along the same way: the Initiator puts
    it in the return URL (?recordId=...), which the server stored on the
    transaction when the payment started — so payment_record_id (and recordId)
    come from that stored URL, not from the address bar the payer could edit."""
    reference = context.get("payment_reference")
    if not reference:
        return {k: v for k, v in context.items() if k not in _PAYMENT_RESULT_KEYS}
    txn = (
        await db.execute(
            select(PaymentTransaction).where(
                PaymentTransaction.tenant_id == tenant_id,
                or_(PaymentTransaction.reference == str(reference), PaymentTransaction.gateway_reference == str(reference)),
            )
        )
    ).scalars().first()
    trusted = {k: v for k, v in context.items() if k not in _PAYMENT_KEYS}
    if txn is None:
        return {**trusted, "payment_reference": str(reference), "payment_status": "unverified", "payment_paid": False}
    record_id = dict(parse_qsl(urlsplit(txn.return_url).query)).get("recordId") if txn.return_url else None
    return {
        **trusted,
        **({"recordId": record_id, "payment_record_id": record_id} if record_id else {}),
        "payment_reference": txn.reference,
        "payment_gateway_reference": txn.gateway_reference,
        "payment_label": txn.label,
        "payment_amount": txn.amount,
        "payment_currency": txn.currency,
        "payment_status": txn.status,
        "payment_paid": txn.status == "paid",
        "payment_provider_status": txn.provider_status,
    }


@router.post("/{task_id}/run", response_model=TaskRunResult)
async def run_task_endpoint(
    task_id: uuid.UUID,
    payload: TaskRunRequest,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_run_access("tasks", "edit")),
) -> TaskRunResult:
    """Called from a Form's OnSave/OnSubmit/OnCancel event (or, later, a Page
    button/Workflow step) with whatever context the caller has on hand — e.g.
    a form's just-submitted field values, keyed by field name. Reachable by
    a Public Portal (vendor) caller (require_run_access) since that's the
    normal way a citizen-facing form's on_submit action actually runs — but
    only ever against a published task: allow_draft is a Studio Test-panel
    concept, forced off here for a vendor regardless of what the request
    body asked for, so a portal submission can never execute an unreviewed
    draft task."""
    task = await crud.get(db, ctx.tenant_id, task_id)
    allow_draft = payload.allow_draft and ctx.vendor_id is None
    if task.status != "published" and not allow_draft:
        raise HTTPException(status_code=409, detail="This task isn't published yet.")
    # Always the real caller, never whatever the request body claimed — a
    # create_notification node can read it (input source_field
    # "currentUserId") to notify the very user whose Form/Page action ran
    # this task, e.g. a vendor confirming their own portal submission.
    initial_context = {
        **await _trusted_payment_context(db, ctx.tenant_id, payload.context),
        "currentUserId": str(ctx.user_id) if ctx.user_id else None,
    }
    # allow_draft comes only from the Task Designer's test run (and never a vendor).
    trigger = RunTrigger(
        kind="designer" if allow_draft else (payload.source.kind if payload.source else "app"),
        ref=payload.source.id if payload.source and not allow_draft else None,
        started_by=ctx.user_id,
    )
    ok, trace, context, navigation, kept = await run_task_with_navigation(db, ctx.tenant_id, task, initial_context, trigger)
    return TaskRunResult(ok=ok, trace=trace, context=context, navigation=navigation, session_data=kept["session_data"], params=kept["params"], run_id=trigger.run_id)


# ---- Execution history (app/crud/task_history.py) -----------------------------------------------------------


async def _named(db: AsyncSession, tenant_id: uuid.UUID, runs: list[TaskRun]) -> list[TaskRunSummary]:
    """Runs with the names people know: who started each, and the page or form it
    was asked from (the run stores only the id the browser gave)."""
    user_ids = {r.started_by for r in runs if r.started_by}
    users = dict((await db.execute(select(User.id, User.name).where(User.tenant_id == tenant_id, User.id.in_(user_ids)))).all()) if user_ids else {}

    def ids(kind: str) -> set[uuid.UUID]:
        found = set()
        for r in runs:
            if r.trigger == kind and r.trigger_ref and not r.trigger_name:
                try:
                    found.add(uuid.UUID(r.trigger_ref))
                except ValueError:
                    pass
        return found

    names: dict[str, str] = {}
    if page_ids := ids("page"):
        names |= {str(i): n for i, n in (await db.execute(select(Page.id, Page.name).where(Page.tenant_id == tenant_id, Page.id.in_(page_ids)))).all()}
    if form_ids := ids("form"):
        names |= {str(i): n for i, n in (await db.execute(select(Form.form_id, Form.name).where(Form.tenant_id == tenant_id, Form.form_id.in_(form_ids)).order_by(Form.version))).all()}
    return [
        TaskRunSummary.model_validate(r).model_copy(update={"started_by_name": users.get(r.started_by), "trigger_name": r.trigger_name or names.get(r.trigger_ref or "")})
        for r in runs
    ]


@router.get("/{task_id}/runs", response_model=TaskRunPage)
async def list_task_runs(
    task_id: uuid.UUID,
    status: str | None = None,
    trigger: str | None = None,
    skip: int = Query(default=0, ge=0),
    limit: int = Query(default=30, ge=1, le=100),
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("tasks", "view")),
    __: AuthContext = Depends(deny_vendor()),
) -> TaskRunPage:
    """Newest first. Doesn't need the task to still exist."""
    runs, more = await task_history.list_runs(db, ctx.tenant_id, task_id, status=status, trigger=trigger, skip=skip, limit=limit)
    return TaskRunPage(items=await _named(db, ctx.tenant_id, runs), has_more=more)


async def _run_or_404(db: AsyncSession, tenant_id: uuid.UUID, task_id: uuid.UUID, run_id: uuid.UUID) -> TaskRun:
    run = await task_history.get_run(db, tenant_id, task_id, run_id)
    if run is None:
        raise HTTPException(status_code=404, detail="This run isn't in the task's history.")
    return run


@router.get("/{task_id}/runs/{run_id}", response_model=TaskRunDetail)
async def get_task_run(
    task_id: uuid.UUID,
    run_id: uuid.UUID,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("tasks", "view")),
    __: AuthContext = Depends(deny_vendor()),
) -> TaskRunDetail:
    run = await _run_or_404(db, ctx.tenant_id, task_id, run_id)
    named = (await _named(db, ctx.tenant_id, [run]))[0]
    return TaskRunDetail(**named.model_dump(), input=run.input, output=run.output)


@router.get("/{task_id}/runs/{run_id}/timeline", response_model=TaskRunLevelPage)
async def get_task_run_timeline(
    task_id: uuid.UUID,
    run_id: uuid.UUID,
    parent: str | None = None,
    after_seq: int = Query(default=0, ge=0),
    limit: int = Query(default=100, ge=1, le=500),
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("tasks", "view")),
    __: AuthContext = Depends(deny_vendor()),
) -> TaskRunLevelPage:
    """One level of the run: its steps and the loop iterations inside it, in
    order. `parent` is an iteration's `path`; none means the top level."""
    await _run_or_404(db, ctx.tenant_id, task_id, run_id)
    items, more = await task_history.run_level(db, ctx.tenant_id, run_id, parent=parent, after_seq=after_seq, limit=limit)
    return TaskRunLevelPage(items=items, has_more=more)


@router.get("/{task_id}/runs/{run_id}/steps", response_model=TaskRunLevelPage)
async def list_task_run_steps(
    task_id: uuid.UUID,
    run_id: uuid.UUID,
    failed_only: bool = False,
    after_seq: int = Query(default=0, ge=0),
    limit: int = Query(default=100, ge=1, le=500),
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("tasks", "view")),
    __: AuthContext = Depends(deny_vendor()),
) -> TaskRunLevelPage:
    await _run_or_404(db, ctx.tenant_id, task_id, run_id)
    items, more = await task_history.list_steps(db, ctx.tenant_id, run_id, failed_only=failed_only, after_seq=after_seq, limit=limit)
    return TaskRunLevelPage(items=items, has_more=more)


@router.get("/{task_id}/runs/{run_id}/steps/{seq}", response_model=TaskRunStepRead)
async def get_task_run_step(
    task_id: uuid.UUID,
    run_id: uuid.UUID,
    seq: int,
    ctx: AuthContext = Depends(get_auth_context),
    db: AsyncSession = Depends(get_db),
    _: AuthContext = Depends(require_permission("tasks", "view")),
    __: AuthContext = Depends(deny_vendor()),
) -> object:
    await _run_or_404(db, ctx.tenant_id, task_id, run_id)
    step = await task_history.get_step(db, ctx.tenant_id, run_id, seq)
    if step is None:
        raise HTTPException(status_code=404, detail="This step isn't in the run's history.")
    return step
