# pyright: reportAttributeAccessIssue=false
# Uses ORM models with dynamically-created attributes from metaclasses

from keylime import agent_util, config, keylime_logging
from keylime.common import retry
from keylime.models.verifier import Attestation, VerifierAgent
from keylime.verification import EngineDriver
from keylime.web.base import APIError, APILink, APIMessageBody, APIMeta, APIResource, Controller

logger = keylime_logging.init_logging("web")


class AttestationController(Controller):
    """The AttestationController services requests for the management of attestation resources when the verifier is
    operating in push mode. Such attestation resources are represented by instances of the ``Attestation`` model.

    Attestation evidence is prepared and sent according the push attestation protocol which operates in two phases::

                          Agent                                           Verifier
                          -----                                           --------
                            │                                                 │
                        ┬   │                 1. Capabilities                 █─────────────────┐
                        │   │ ----------------------------------------------> █                 │
             1st Phase: │   │         i.e., supported TPM algorithms          █   Attestation   │
           ------------ │   │                                                 █    resource     │
           CAPABILITIES │   │                                                 █     created     │
            NEGOTIATION │   │            2. Attestation Parameters            █                 │
                        │   │ <---------------------------------------------- █        │        │
                        ┴   │    i.e., ima offset, chosen algorithms, etc.    █────────│────────┘
                            │                                                 │        │
                            │                                                 │        │
                        ┬   │                   3. Evidence                   █────────│────────┐
                        │   │ ----------------------------------------------> █        🡓        │
             2nd Phase: │   │       i.e., quote, UEFI log, IMA log, etc.      █   Attestation   │
             ---------- │   │                                                 █    resource     │
               EVIDENCE │   │                                                 █     updated     │
               HANDLING │   │                   4. Response                   █                 │
                        │   │ <---------------------------------------------- █                 │
                        ┴   │  i.e., whether the request appears well-formed  █─────────────────┘
                            │                                                 │


    Identifying Attestation Resources
    ---------------------------------

    Each attestation resource is scoped to an agent and is identified by an auto-incrementing index (starting from 0).
    While this index may be used to reference a particular attestation, most often it is the latest attestation which is
    relevant. Routes are therefore provided (in ``keylime.web.verifier_server``) to GET and PATCH the latest attestation
    resource. Requests which match these routes are handled by the ``show_latest`` and ``update_latest`` actions
    respectively.

    The following paths may therefore refer to the same attestation resource if a total of five (5) attestations exist
    for agent 1:

        * /v3/agents/1/attestations/4
        * /v3/agents/1/attestations/latest

    Lifetime of an Attestation Resource
    -----------------------------------

    Even though every attestation resource is uniquely identifiable by an ``(agent_id, index)`` tuple, attestation
    resources are not accessible indefinitely forever. This is because old attestations records are automatically
    cleared to limit the storage requirements of the database.

    Generally, the latest attestation and any attestations which fail verification are always retained. The specifics
    are documented in `keylime.models.verifier.push_attestation`.

    Creating Attestation Resources
    ------------------------------

    An attestation resource is only creatable under certain conditions to enforce correct behaviour of untrusted agents
    which may make requests to the verifier at will.

    A request to create a new attestation resource may fail for any of the following reasons:

        * the agent does not exist (404 "Not Found");
        * attestations are disabled for the agent, e.g., if a previous verification failed (403 "Forbidden");
        * the request has been received too soon after evidence was received for the previous attestation (429 "Too Many
          Requests");
        * verification of a previous attestation is still in progress (503 "Service Unavailable");
        * the request data is not valid despite having the right syntax (422 "Unprocessable Content");
        * the request data cannot be accepted for some other reason, such as having an incorrect Content-Type (200 "Bad
          Request"); or
        * the request was received while another request to create an attestation resource was still being processed
          (409 "Conflict").

    In all cases other than the first two, the request may be re-attempted. When a 429 or 503 status is returned, the
    ``Retry-After`` HTTP header indicates the number of seconds after which the request will be accepted.

    Details about each error condition can be found by inspecting the ``errors`` structure in the JSON response.

    Mutating Attestation Resources
    ------------------------------

    Any given attestation resource may only be updated once after creation to supply the evidence for the attestation.
    At this point, assuming the received evidence is in the expected format, the resource becomes immutable for the
    remainder of its lifetime.

    A request to update an attestation resource may fail for any of the following reasons:

        * the attestation resource never existed (404 "Not Found");
        * the attestation resource no longer exists (410 "Gone");
        * the attestation resource has already been updated with the expected evidence (403 "Forbidden");
        * the nonce for the attestation resource has expired (403 "Forbidden");
        * the evidence provided was not as expected (400 "Bad Request"); or
        * too many worker processes are currently occupied by a verification task (503 "Service Unavailable").

    In the final two cases, the request may be re-attempted. When a 503 status is returned, the ``Retry-After`` HTTP
    header indicates the estimated number of seconds after which the verifier will accept the evidence.

    In all other cases, a new attestation resource must first be created before the verifier will accept evidence from
    that specific agent.

    Details about each error condition can be found by inspecting the ``errors`` structure in the JSON response.

    Performance Considerations
    --------------------------

    In general, the cooperative multitasking of the underlying web framework ensures that long-running I/O tasks do
    not block a worker process from handling other requests in the interim. However, because verification is largely a
    CPU-bound task, when a worker is busy processing a verification, that process is blocked until the task is complete.
    A number of mechanisms have been implemented to limit this impact, some of which are user-configurable:

        * To prevent the verifier from being overloaded with constant attestations from an individual agent, creation of
          a new attestation is not allowed until a certain amount of time has passed since evidence was received for the
          agent's previous attestation. This is determined by the ``quote_interval`` config option which is also used to
          determine the ``next_attestation_expected_after`` ISO datetime returned to the agent when valid evidence is
          received. The user should set ``quote_interval`` based on their desired attestation frequency.

        * To prevent attestations from taking so long as to exhaust the verifier's resources, e.g., if an agent, perhaps
          maliciously, sends an excessive amount of data as evidence, the verifier will give up trying to verify an
          attestation after a certain cut off is reached. This is determined by the ``verification_timeout`` config
          option. When set to 0 (the default), verifications will time out after m*3 seconds where m is the average time
          taken to process attestations since the verifier was started. When a value > 0 is given, this is interpreted
          as a fixed timeout in seconds.

        * To allow the verifier to respond to requests even when many attestations are received all at once, a number of
          worker processes are kept free for request handling and will not perform verification tasks. This number is
          usually determined by the formula floor(n*r) where n is the total number of workers and r is the percentage
          which will dedicated to servicing incoming requests. When the formula returns <1, a single dedicated worker is
          used. But when n equals one, a warning is produced as no process can be dedicated. The value of r is 25% by
          default but may be overridden by setting the ``dedicated_web_workers`` config value to a different percentage.
          Alternatively, a fixed number may be provided instead.

    Additionally:

        * Regardless of how long a verification task takes, it will not block the verifier from issuing its response to
          the agent which submitted the evidence.

        * When an agent submits evidence but all the worker processes which are not dedicated to request handling are
          occupied by a verification task, the verifier will instruct the agent to wait a certain amount of time before
          retrying. This value is determined by the formula n*m where n is a count of the requests which the verifier
          has had to reject for this reason (including the current request) and m is the average number of seconds the
          verifier has taken to complete each verification task since starting. The value n is reset to 0 when enough
          workers become available to service more verification tasks.
    """

    # GET /v3[.:minor]/agents/:agent_id/attestations
    def index(self, agent_id, **_params):  # type: ignore[no-untyped-def]
        agent = VerifierAgent.get(agent_id)

        if not agent:
            APIError("not_found", f"No enrolled agent with ID '{agent_id}'.").send_via(self)

        results = Attestation.all(agent_id=agent_id)

        resources = [
            APIResource("attestation", attestation.render_state()).include(  # type: ignore[no-untyped-call]
                APILink("self", f"{self.path}/{attestation.index}")
            )
            for attestation in results
        ]

        # JSON:API requires at least 'data', 'errors', or 'meta'
        # For empty results, we need to set _data to [] to satisfy validity check
        if resources:
            APIMessageBody(*resources).send_via(self)  # type: ignore[no-untyped-call]
        else:
            # Send empty data array for agents with no attestations
            body = APIMessageBody()
            body._data = []  # type: ignore[attr-defined]  # pylint: disable=protected-access
            body.send_via(self)  # type: ignore[no-untyped-call]

    # GET /v3[.:minor]/agents/:agent_id/attestations/:index
    def show(self, agent_id, index, **_params):  # type: ignore[no-untyped-def]
        agent = VerifierAgent.get(agent_id)
        attestation = Attestation.get(agent_id=agent_id, index=index)

        if not agent:
            APIError("not_found", f"No enrolled agent with ID '{agent_id}'.").send_via(self)

        if not attestation:
            APIError("not_found", f"No attestation {index} exists for agent '{agent_id}'.").send_via(self)

        APIResource("attestation", attestation.render_state()).include(  # type: ignore[no-untyped-call, union-attr]
            APILink("self", f"/{self.version}/agents/{agent_id}/attestations/{index}")
        ).send_via(
            self
        )  # type: ignore[no-untyped-call]

    # GET /v3[.:minor]/agents/:agent_id/attestations/latest
    def show_latest(self, agent_id, **_params):  # type: ignore[no-untyped-def]
        agent = VerifierAgent.get(agent_id)

        if not agent:
            APIError("not_found", f"No enrolled agent with ID '{agent_id}'.").send_via(self)

        latest = agent.latest_attestation  # type: ignore[union-attr]

        if not latest:
            APIError("not_found", f"No attestation exists for agent '{agent_id}'.").send_via(self)

        self.show(agent_id, latest.index, **_params)  # type: ignore[union-attr, no-untyped-call]

    # POST /v3[.:minor]/agents/:agent_id/attestations
    @Controller.require_json_api
    def create(self, agent_id, attestation, **params):  # type: ignore[no-untyped-def]  # pylint: disable=unused-argument
        agent = VerifierAgent.get(agent_id)

        if not agent:
            APIError("not_found", f"No enrolled agent with ID '{agent_id}'.").send_via(self)

        # Check if attestations are disabled
        # For PULL mode: reject if accept_attestations=False (verifier controls attestation timing)
        # For PUSH mode: allow even if accept_attestations=False (agent needs to recover from timeout)
        if not agent.accept_attestations:  # type: ignore[union-attr]
            # In PUSH mode, allow attestation attempt even when disabled - this enables recovery
            # from timeout-induced failures by allowing the agent to re-attest successfully
            if not agent_util.is_push_mode_agent(agent):  # type: ignore[arg-type]
                # PULL mode: reject attestations when disabled
                APIError("agent_attestations_disabled", 403).set_detail(
                    f"Attestations for agent '{agent_id}' are currently disabled. This may be due to a previous "
                    f"attestation not passing verification."
                ).send_via(self)

        latest = agent.latest_attestation  # type: ignore[union-attr]

        # Per enhancement #103, section "Error Conditions for Attestation Protocol":
        # If last attestation failed AND policy hasn't changed, return 503 with exponential backoff
        # Skip this for PUSH mode agents to allow immediate recovery from timeout-induced failures
        if (
            latest
            and latest.evaluation == "fail"
            and latest.stage == "verification_complete"
            and not agent_util.is_push_mode_agent(agent)  # type: ignore[arg-type]
        ):
            # Calculate retry-after using exponential backoff (same formula as rest of codebase)
            exponential_backoff = config.getboolean("verifier", "exponential_backoff", fallback=True)
            retry_interval = config.getfloat("verifier", "retry_interval", fallback=2.0)
            max_interval = config.getint("verifier", "quote_interval", fallback=60)
            consecutive_failures = agent.consecutive_attestation_failures or 1  # type: ignore[union-attr]

            # Use existing retry_time function for consistency with rest of codebase
            retry_after = retry.retry_time(exponential_backoff, retry_interval, consecutive_failures, logger)
            retry_after = min(int(retry_after), max_interval)

            self.set_header("Retry-After", str(retry_after))  # type: ignore[no-untyped-call]
            APIError("attestation_failed_retry", 503).set_detail(
                f"Last attestation for agent '{agent_id}' failed verification. "
                f"Retry with exponential backoff (wait at least {retry_after} seconds). "
                f"If the failure was due to policy violation, update the policy or fix the agent before retrying."
            ).send_via(self)

        if latest and latest.verification_in_progress:
            self.set_header("Retry-After", str(latest.seconds_to_decision))  # type: ignore[no-untyped-call]
            APIError("verification_in_progress", 503).set_detail(
                f"Cannot create attestation for agent '{agent_id}' while the last attestation is still being "
                f"verified. The active verification task is expected to complete or time out within "
                f"{latest.seconds_to_decision} seconds."
            ).send_via(self)

        if latest and not latest.ready_for_next_attestation:
            self.set_header("Retry-After", str(latest.seconds_to_next_attestation))  # type: ignore[no-untyped-call]
            APIError("premature_attestation", 429).set_detail(
                f"Cannot create attestation for agent '{agent_id}' before the configured interval has elapsed. "
                f"Wait {latest.seconds_to_next_attestation} seconds before trying again."
            ).send_via(self)

        attestation_record = Attestation.create(agent)  # type: ignore[no-untyped-call]
        attestation_record.receive_capabilities(attestation)  # type: ignore[no-untyped-call]
        EngineDriver(attestation_record).process_capabilities()  # type: ignore[no-untyped-call]

        if not attestation_record.changes_valid:
            APIMessageBody.from_record_errors(attestation_record).send_via(self)  # type: ignore[no-untyped-call]

        try:
            attestation_record.commit_changes()  # type: ignore[no-untyped-call]
        except ValueError:
            # Another attestation for this agent was created while this request is being processed. Reject the request
            # as otherwise the new attestation may be created prior to or shortly after evidence is received and
            # processed for the other attestation, meaning that the configured quote interval would not be respected
            APIError("conflict").set_detail(
                f"Cannot create attestation for agent '{agent_id}' while another creation attempt is in progress."
            ).send_via(self)

        # TODO: Re-enable:
        # # The attestation was created successfully, so delete any previous attestation for which evidence was never
        # # received or for which verification never completed
        # attestation_record.cleanup_stale_priors()

        log_data = (attestation_record.index, agent_id)
        logger.info("Created attestation %s for agent '%s', sending chosen parameters", *log_data)

        APIResource("attestation", attestation_record.render_evidence_requested()).include(  # type: ignore[no-untyped-call]
            APILink("self", f"{self.path}/{attestation_record.index}")
        ).send_via(
            self
        )  # type: ignore[no-untyped-call]

    # PATCH /v3[.:minor]/agents/:agent_id/attestations/:index
    @Controller.require_json_api
    def update(self, agent_id, index, **params):  # type: ignore[no-untyped-def]  # pylint: disable=unused-argument
        # Extract attestation from params - it should be provided by the API request
        attestation = params.get("attestation")

        agent = VerifierAgent.get(agent_id)

        if not agent:
            APIError("not_found", f"No enrolled agent with ID '{agent_id}'.").send_via(self)

        latest = agent.latest_attestation  # type: ignore[union-attr]

        # If there are no attestations for the agent, the attestation at 'index' does not exist
        if not latest:
            APIError("not_found", f"No attestation {index} exists for agent '{agent_id}'.").send_via(self)

        # Only allow the attestation at 'index' to be updated if it is the latest attestation
        if str(latest.index) != str(index):  # type: ignore[union-attr]
            APIError("old_attestation", 403).set_detail(
                f"Attestation {index} is not the latest for agent '{agent_id}'. Only evidence for the most recent "
                f"attestation may be updated."
            ).send_via(self)

        if latest.stage != "awaiting_evidence":  # type: ignore[union-attr]
            APIError("evidence_immutable", 403).set_detail(
                f"Cannot alter evidence for attestation {index} which has already been received and accepted."
            ).send_via(self)

        if not latest.challenges_valid:  # type: ignore[union-attr]
            APIError("challenges_expired", 403).set_detail(
                f"Challenges for attestation {index} expired at {latest.challenges_expire_at}. "  # type: ignore[union-attr]
                f"Create a new attestation and try again."
            ).send_via(self)

        if attestation is None:
            APIError("invalid_request", 400).set_detail(
                "Request body must include attestation evidence data."
            ).send_via(self)

        latest.receive_evidence(attestation)  # type: ignore[no-untyped-call, union-attr]
        driver = EngineDriver(latest).process_evidence()  # type: ignore[no-untyped-call, union-attr]

        # Send error if the received evidence appears invalid
        if not latest.changes_valid:  # type: ignore[union-attr]
            APIMessageBody.from_record_errors(latest).send_via(self)  # type: ignore[no-untyped-call, union-attr]

        latest.commit_changes()  # type: ignore[no-untyped-call, union-attr]

        # Send acknowledgement of received evidence, but continue executing
        APIMessageBody(
            APIResource("attestation", latest.render_evidence_acknowledged()).include(  # type: ignore[no-untyped-call, union-attr]
                APILink("self", f"/{self.version}/agents/{agent_id}/attestations/{index}")
            ),
            APIMeta("seconds_to_next_attestation", latest.seconds_to_next_attestation),  # type: ignore[union-attr]
        ).send_via(
            self, code=202, stop_action=False
        )  # type: ignore[no-untyped-call]

        # Verify attestation after response is sent, so the agent does not need to wait for verification to complete
        # Note: verify_evidence() calls _commit_verification_results() which commits agent changes
        driver.verify_evidence()  # type: ignore[no-untyped-call]

    # PATCH /v3[.:minor]/agents/:agent_id/attestations/latest
    @Controller.require_json_api
    def update_latest(self, agent_id, **params):  # type: ignore[no-untyped-def]
        agent = VerifierAgent.get(agent_id)

        if not agent:
            APIError("not_found", f"No enrolled agent with ID '{agent_id}'.").send_via(self)

        latest = agent.latest_attestation  # type: ignore[union-attr]

        if not latest:
            APIError("not_found", f"No attestation exists for agent '{agent_id}'.").send_via(self)

        # Call update with the same params, which includes attestation
        self.update(agent_id, latest.index, **params)  # type: ignore[union-attr]
