"""Optional approval extension. Local policy fixtures, NOT an authentication service.
Before dispatch: validate an immutable action envelope. After dispatch: reconcile only.
"""
import json
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from memory import transaction
from provider import draft, PermanentError
from runtime import Runtime, digest, encode, LostLease, InjectedCrash

APPROVAL_SCHEMA = '''
CREATE TABLE IF NOT EXISTS approvals(
 job_id TEXT PRIMARY KEY REFERENCES jobs(id),
 action_json TEXT NOT NULL, binding_hash TEXT NOT NULL, payload_hash TEXT NOT NULL,
 operation_key TEXT NOT NULL UNIQUE, status TEXT NOT NULL,
 requested_at REAL NOT NULL, expires_at REAL NOT NULL,
 decided_by TEXT, decided_at REAL, receipt TEXT);
'''


@dataclass(frozen=True)
class Principal:
    user_id: str
    roles: frozenset
    allowed_scopes: frozenset


class PauseForApproval(BaseException):
    pass


class ApprovalRuntime(Runtime):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.db.executescript(APPROVAL_SCHEMA)
        self.execution = None
        self.fault = None

    def approval(self, job):
        row = self.db.execute('SELECT * FROM approvals WHERE job_id=?', (job,)).fetchone()
        return dict(row) if row else None

    def envelope(self, job, report):
        row = self.job(job)
        spec = json.loads(row['spec'])
        return {'job_id': job, 'action': 'report.publish/v1',
                'target': spec.get('report_target', 'local-report-store'),
                'scope': row['scope'], 'requester': json.loads(row['scope'])[1],
                'input_hash': row['input_hash'], 'payload_hash': digest(report),
                'memory_rev': row['memory_rev'], 'memory_expires': row['memory_expires']}

    def validate_binding(self, job, request, report):
        if digest(self.envelope(job, report)) != request['binding_hash']:
            raise PermanentError('approval_binding_changed')
        self.validate_memory(self.job(job))

    def before_step(self, job, owner, generation, step):
        super().before_step(job, owner, generation, step)
        if step != 'publish':
            return
        self.execution = (job, owner, generation)
        paused = False
        with transaction(self.db):
            row = self.guard(job, owner, generation)
            request = self.approval(job)
            report = self.saved(job, 'draft')
            if request is None:
                action = self.envelope(job, report)
                ttl = json.loads(row['spec']).get('approval_ttl', 120)
                if not isinstance(ttl, (int, float)) or ttl <= 0:
                    raise PermanentError('approval_ttl must be positive')
                expires = min(self.clock() + ttl, row['deadline'], row['memory_expires'])
                self.db.execute('INSERT INTO approvals(job_id,action_json,binding_hash,payload_hash,'
                    'operation_key,status,requested_at,expires_at) VALUES (?,?,?,?,?,?,?,?)',
                    (job, encode(action), digest(action), digest(report), digest([job, 'publish/v1']),
                     'pending', self.clock(), expires))
                self.db.execute("UPDATE jobs SET status='waiting_approval',owner=NULL,lease_until=0 WHERE id=?", (job,))
                self.event(job, generation, 'approval_requested', digest(action))
                paused = True
            else:
                self.validate_binding(job, request, report)
                if request['status'] != 'approved' or request['expires_at'] <= self.clock():
                    raise PermanentError('approval_not_valid')
        if paused:
            raise PauseForApproval()

    def decide(self, job, principal, expected_hash, decision):
        if decision not in ('approve', 'reject'):
            raise ValueError('decision must be approve or reject')
        with transaction(self.db):
            request, row = self.approval(job), self.job(job)
            if request is None:
                raise ValueError('no approval request')
            action = json.loads(request['action_json'])
            if ('report_reviewer' not in principal.roles or row['scope'] not in principal.allowed_scopes
                    or principal.user_id == action['requester']):
                raise PermissionError('reviewer role, scope and separation of duties required')
            if expected_hash != request['binding_hash']:
                raise ValueError('reviewed action hash does not match')
            # A retried identical approval response is harmless; no second grant is created.
            duplicate = (request['status'] == 'approved' and decision == 'approve'
                         and request['decided_by'] == principal.user_id)
            if not duplicate and (request['status'] != 'pending' or row['status'] != 'waiting_approval'):
                raise ValueError('request already decided or task no longer waiting')
            problem = None
            if request['expires_at'] <= self.clock() or row['deadline'] <= self.clock():
                problem = 'approval_expired'
            else:
                try:
                    self.validate_binding(job, request, self.saved(job, 'draft'))
                except PermanentError as exc:
                    problem = str(exc)
            if duplicate and not problem:
                return 'approved'
            status = ('expired' if problem == 'approval_expired' else 'invalidated') if problem else (
                'approved' if decision == 'approve' else 'rejected')
            next_state = 'queued' if status == 'approved' else 'failed'
            self.db.execute('UPDATE approvals SET status=?,decided_by=?,decided_at=? WHERE job_id=?',
                            (status, principal.user_id, self.clock(), job))
            self.db.execute('UPDATE jobs SET status=?,owner=NULL,lease_until=0,error=? WHERE id=?',
                            (next_state, problem or (None if status == 'approved' else 'approval_rejected'), job))
            self.event(job, row['generation'], 'approval_' + status, principal.user_id)
            return status

    def publish(self, job, payload):
        if not self.execution or self.execution[0] != job:
            raise PermissionError('publish requires the guarded execution path')
        _, owner, generation = self.execution
        with transaction(self.db):
            row = self.guard(job, owner, generation)
            request = self.approval(job)
            if request is None or request['status'] != 'approved':
                raise PermanentError('approval required')
            self.validate_binding(job, request, payload)
            if request['expires_at'] <= self.clock() or row['deadline'] <= self.clock():
                raise PermanentError('approval_expired_before_dispatch')
            # Authorization cutoff: after this durable marker, outcome may be unknown.
            self.db.execute("UPDATE approvals SET status='dispatching' WHERE job_id=?", (job,))
            self.event(job, generation, 'dispatch_started', request['operation_key'])
        if self.fault == 'before_effect':
            raise InjectedCrash('dispatch marker committed; no service call was made')
        return super().publish(job, payload)

    def commit_step(self, job, owner, generation, step, payload):
        if step != 'publish':
            return super().commit_step(job, owner, generation, step, payload)
        with transaction(self.db):
            row = self.guard(job, owner, generation)
            self.validate_memory(row)
            if row['deadline'] <= self.clock():
                raise PermanentError('deadline exceeded after dispatch')
            request = self.approval(job)
            if request['status'] != 'dispatching':
                raise PermanentError('unexpected approval state')
            self.db.execute('INSERT INTO checkpoints VALUES (?,?,?)', (job, step, encode(payload)))
            self.db.execute("UPDATE approvals SET status='completed',receipt=? WHERE job_id=?", (payload['receipt'], job))
            self.db.execute("UPDATE jobs SET status='succeeded',owner=NULL,error=NULL WHERE id=?", (job,))
            self.event(job, generation, 'step_committed', step)

    def fail(self, job, owner, generation, exc):
        request = self.approval(job)
        if request and request['status'] == 'dispatching':
            with transaction(self.db):
                # Another worker may already have reconciled the effect.
                if self.approval(job)['status'] != 'dispatching':
                    return
                self.db.execute("UPDATE jobs SET status='reconciling',owner=NULL,lease_until=0,error=? WHERE id=?",
                                ('outcome_unknown: ' + str(exc), job))
                self.event(job, generation, 'reconciliation_required', str(exc))
            return
        super().fail(job, owner, generation, exc)
        if request and self.job(job)['status'] == 'failed':
            with transaction(self.db):
                self.db.execute("UPDATE approvals SET status='invalidated' WHERE job_id=? AND status IN ('pending','approved')", (job,))

    def cancel(self, job):
        with transaction(self.db):
            row, request = self.job(job), self.approval(job)
            if row['status'] in ('succeeded', 'effect_confirmed', 'failed', 'cancelled'):
                return False
            dispatched = request and request['status'] == 'dispatching'
            state = 'reconciling' if dispatched else 'cancelled'
            reason = 'cancel_requested_after_dispatch' if dispatched else None
            self.db.execute('UPDATE jobs SET status=?,generation=generation+1,owner=NULL,lease_until=0,error=? WHERE id=?',
                            (state, reason, job))
            if request and not dispatched:
                self.db.execute("UPDATE approvals SET status='cancelled' WHERE job_id=?", (job,))
            self.event(job, row['generation'] + 1, 'cancel_requested' if dispatched else 'cancelled')
            return True

    def lookup_receipt(self, request):
        path = Path(self.path + '.publisher.sqlite').resolve()
        if not path.exists():
            return None
        # Read only: reconciliation cannot create a report or initialize a service DB.
        db = sqlite3.connect(path.as_uri() + '?mode=ro', uri=True, timeout=5)
        try:
            if not db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='receipts'").fetchone():
                return None
            row = db.execute('SELECT payload_hash,receipt FROM receipts WHERE op=?',
                             (request['operation_key'],)).fetchone()
            if row and row[0] != request['payload_hash']:
                raise PermanentError('service_receipt_payload_conflict')
            return row[1] if row else None
        finally:
            db.close()

    def reconcile(self, job):
        request, row = self.approval(job), self.job(job)
        if request is None or request['status'] != 'dispatching':
            return row['status']
        if row['status'] == 'running' and row['lease_until'] > self.clock():
            return 'running'  # Do not interfere with an active worker.
        receipt = self.lookup_receipt(request)
        with transaction(self.db):
            row = self.job(job)
            if self.approval(job)['status'] != 'dispatching':
                return row['status']
            if row['status'] == 'running' and row['lease_until'] > self.clock():
                return 'running'
            if receipt is None:
                if row['status'] != 'reconciling':
                    self.db.execute("UPDATE jobs SET status='reconciling',owner=NULL,lease_until=0,error='no_receipt_yet' WHERE id=?", (job,))
                    self.event(job, row['generation'], 'reconciliation_required', 'no receipt; do not resend')
                return 'reconciling'
            payload = {'receipt': receipt, 'reconciled': True, 'fresh_write_performed': False}
            self.db.execute('INSERT OR IGNORE INTO checkpoints VALUES (?,?,?)', (job, 'publish', encode(payload)))
            self.db.execute("UPDATE approvals SET status='effect_confirmed',receipt=? WHERE job_id=?", (receipt, job))
            self.db.execute("UPDATE jobs SET status='effect_confirmed',owner=NULL,lease_until=0 WHERE id=?", (job,))
            self.event(job, row['generation'], 'effect_observed', receipt)
            self.event(job, row['generation'], 'step_committed', 'publish')
            return 'effect_confirmed'  # Confirmed past effect; NOT a claim of fresh authorization.

    def run(self, job, fault=None, model_fn=draft):
        self.fault = fault
        request = self.approval(job)
        if request and request['status'] == 'dispatching':
            return self.reconcile(job)
        if request and request['status'] in ('pending', 'approved'):
            with transaction(self.db):
                request, row = self.approval(job), self.job(job)
                if request['status'] in ('pending', 'approved') and request['expires_at'] <= self.clock():
                    self.db.execute("UPDATE approvals SET status='expired' WHERE job_id=?", (job,))
                    self.db.execute("UPDATE jobs SET status='failed',generation=generation+1,owner=NULL,error='approval_expired' WHERE id=?", (job,))
                    self.event(job, row['generation'] + 1, 'approval_expired')
        try:
            return super().run(job, fault=fault, model_fn=model_fn)
        except PauseForApproval:
            return 'waiting_approval'
        except LostLease:
            request = self.approval(job)
            if request and request['status'] == 'dispatching':
                return self.reconcile(job)
            raise

    def snapshot(self, job):
        result = super().snapshot(job)
        result['approval'] = self.approval(job)
        return result
