-- =====================================================================
-- Migration 0010 — Reporting and alerting (roadmap Step 11).
--
-- Owner-facing read model: Z-reports, daily P&L per service, employee
-- scorecards, and a persistent alerts table fed by detector functions.
--
-- Threat-model rows addressed: 2, 3, 4, 6, 9, 10, 11, 12, 13, 14, 17,
--                              18, 20, 22, 23, 24, 25.
-- =====================================================================

-- =====================================================================
-- Z-report: one row per closed shift, what the system says vs what the
-- cashier declared vs what was found in the drawer.
-- =====================================================================
create or replace view app.v_z_report as
with cm as (
  select sh.id as shift_id,
         coalesce(sum(amount) filter (where currency='USD'),0) as net_usd,
         coalesce(sum(amount) filter (where currency='LBP'),0) as net_lbp
  from app.shifts sh
  left join app.cash_movements m on m.shift_id = sh.id
  group by sh.id
),
txn as (
  select sh.id as shift_id,
         count(*) filter (where t.status='completed')         as txn_count,
         count(*) filter (where t.status='voided')            as void_count,
         coalesce(sum(t.gross_usd) filter (where t.status='completed'),0) as gross_usd,
         coalesce(sum(t.gross_lbp) filter (where t.status='completed'),0) as gross_lbp,
         coalesce(sum(t.fee_usd)   filter (where t.status='completed'),0) as fee_usd,
         coalesce(sum(t.fee_lbp)   filter (where t.status='completed'),0) as fee_lbp,
         coalesce(sum(t.commission_usd) filter (where t.status='completed'),0) as comm_usd,
         coalesce(sum(t.commission_lbp) filter (where t.status='completed'),0) as comm_lbp
  from app.shifts sh
  left join app.transactions t
         on t.shift_id = sh.id
  group by sh.id
)
select
  sh.id              as shift_id,
  sh.shop_id,
  sh.till_id,
  sh.user_id as cashier_id,
  sh.opened_at,
  sh.closed_at,
  sh.status,
  sh.opening_usd,
  sh.opening_lbp,
  cm.net_usd         as expected_close_usd,    -- = sum(cash_movements USD)
  cm.net_lbp         as expected_close_lbp,
  sh.declared_close_usd,
  sh.declared_close_lbp,
  sh.declared_close_usd - cm.net_usd as variance_usd,
  sh.declared_close_lbp - cm.net_lbp as variance_lbp,
  txn.txn_count,
  txn.void_count,
  txn.gross_usd,
  txn.gross_lbp,
  txn.fee_usd + txn.comm_usd as revenue_usd,
  txn.fee_lbp + txn.comm_lbp as revenue_lbp
from app.shifts sh
join cm  on cm.shift_id  = sh.id
join txn on txn.shift_id = sh.id;

-- =====================================================================
-- Daily P&L per shop / service.
-- =====================================================================
create or replace view app.v_daily_pnl as
select
  t.shop_id,
  (t.occurred_at at time zone 'UTC')::date as day,
  t.service_code,
  count(*) filter (where t.status='completed')                 as txn_count,
  sum(t.gross_usd) filter (where t.status='completed')         as gross_usd,
  sum(t.gross_lbp) filter (where t.status='completed')         as gross_lbp,
  sum(t.fee_usd)   filter (where t.status='completed')         as fee_usd,
  sum(t.fee_lbp)   filter (where t.status='completed')         as fee_lbp,
  sum(t.commission_usd) filter (where t.status='completed')    as comm_usd,
  sum(t.commission_lbp) filter (where t.status='completed')    as comm_lbp,
  count(*) filter (where t.status='voided')                    as void_count
from app.transactions t
group by t.shop_id, (t.occurred_at at time zone 'UTC')::date, t.service_code;

-- =====================================================================
-- Per-employee scorecard (last 30 days). Owner uses this to spot the
-- cashier whose numbers always look just slightly off.
-- =====================================================================
create or replace view app.v_employee_scorecard_30d as
with base as (
  select sh.user_id as cashier_id, sh.shop_id, sh.id as shift_id,
         (sh.declared_close_usd - z.expected_close_usd) as var_usd,
         (sh.declared_close_lbp - z.expected_close_lbp) as var_lbp
  from app.shifts sh
  join app.v_z_report z on z.shift_id = sh.id
  where sh.closed_at >= now() - interval '30 days'
    and sh.status = 'closed'
),
voids as (
  select t.shop_id, t.user_id as cashier_id,
         count(*) as voids_30d,
         count(*) filter (where t.voided_at - t.occurred_at > interval '10 minutes') as late_voids_30d
  from app.transactions t
  where t.status = 'voided'
    and t.voided_at >= now() - interval '30 days'
  group by t.shop_id, t.user_id
),
overrides as (
  select t.shop_id, t.user_id as cashier_id,
         count(*) as overrides_30d
  from app.price_overrides p
  join app.transactions t on t.id = p.txn_id
  where p.created_at >= now() - interval '30 days'
  group by t.shop_id, t.user_id
)
select
  b.cashier_id,
  b.shop_id,
  count(*)                                  as shifts_30d,
  count(*) filter (where b.var_usd < 0)     as short_shifts_usd,
  count(*) filter (where b.var_lbp < 0)     as short_shifts_lbp,
  sum(b.var_usd)                            as total_var_usd,
  sum(b.var_lbp)                            as total_var_lbp,
  avg(b.var_usd)                            as avg_var_usd,
  avg(b.var_lbp)                            as avg_var_lbp,
  coalesce(v.voids_30d,0)                   as voids_30d,
  coalesce(v.late_voids_30d,0)              as late_voids_30d,
  coalesce(o.overrides_30d,0)               as overrides_30d
from base b
left join voids     v on v.cashier_id = b.cashier_id and v.shop_id = b.shop_id
left join overrides o on o.cashier_id = b.cashier_id and o.shop_id = b.shop_id
group by b.cashier_id, b.shop_id, v.voids_30d, v.late_voids_30d, o.overrides_30d;

-- =====================================================================
-- Detector views (raw signals used by the alert engine).
-- =====================================================================
-- Recon backlog (vector #24)
create or replace view app.v_alert_recon_backlog as
select s.shop_id,
       s.id as settlement_id,
       s.provider,
       s.period_start,
       s.period_end,
       count(e.id) as open_exceptions
from app.settlements s
join app.reconciliation_exceptions e on e.settlement_id = s.id and e.resolved_at is null
where s.status = 'has_exceptions'
group by s.shop_id, s.id, s.provider, s.period_start, s.period_end;

-- After-hours activity (vector #22)
create or replace view app.v_alert_after_hours as
select t.shop_id,
       t.id as txn_id,
       t.user_id as cashier_id,
       t.occurred_at,
       t.gross_usd, t.gross_lbp
from app.transactions t
where t.status = 'completed'
  and (extract(hour from (t.occurred_at at time zone 'Asia/Beirut')) < 7
    or extract(hour from (t.occurred_at at time zone 'Asia/Beirut')) >= 23);

-- Chronic short cashier (vector #2)
create or replace view app.v_alert_chronic_shorts as
select cashier_id, shop_id,
       short_shifts_usd, short_shifts_lbp,
       total_var_usd, total_var_lbp
from app.v_employee_scorecard_30d
where short_shifts_usd >= 5 or short_shifts_lbp >= 5
   or total_var_usd <= -50 or total_var_lbp <= -1000000;

-- Void spike (vector #10) — >5 voids/day per cashier or any cashier with
-- voids_30d > 20.
create or replace view app.v_alert_void_spikes as
select t.shop_id, t.user_id as cashier_id,
       (t.occurred_at at time zone 'Asia/Beirut')::date as day,
       count(*) as void_count
from app.transactions t
where t.status = 'voided'
  and t.voided_at >= now() - interval '30 days'
group by t.shop_id, t.user_id, (t.occurred_at at time zone 'Asia/Beirut')::date
having count(*) >= 5;

-- Override spike (vector #12)
create or replace view app.v_alert_override_spikes as
select t.shop_id, t.user_id as cashier_id,
       (p.created_at at time zone 'Asia/Beirut')::date as day,
       count(*) as override_count
from app.price_overrides p
join app.transactions t on t.id = p.txn_id
where p.created_at >= now() - interval '30 days'
group by t.shop_id, t.user_id, (p.created_at at time zone 'Asia/Beirut')::date
having count(*) >= 3;

-- Stock shrinkage (vector #13)
create or replace view app.v_alert_stock_shrinkage as
select s.shop_id, s.sku,
       sum(case when m.type in ('damaged_out','lost_out','adjustment_out')
                then -m.qty_delta else 0 end) as shrink_qty_30d,
       sum(case when m.type = 'sale_out' then -m.qty_delta else 0 end) as sales_qty_30d
from app.stock_movements m
join app.stock_on_hand s on s.shop_id = m.shop_id and s.sku = m.sku
where m.created_at >= now() - interval '30 days'
group by s.shop_id, s.sku
having sum(case when m.type in ('damaged_out','lost_out','adjustment_out')
                then -m.qty_delta else 0 end) >= 5;

-- Voucher loss / damage spike (vector #14)
create or replace view app.v_alert_voucher_writeoffs as
select v.shop_id,
       v.sku,
       count(*) filter (where v.status in ('damaged','lost')) as bad_30d,
       count(*)                                                as total_30d
from app.voucher_inventory v
where coalesce(v.sold_at, v.received_at) >= now() - interval '30 days'
group by v.shop_id, v.sku
having count(*) filter (where v.status in ('damaged','lost'))::numeric
       / nullif(count(*),0)::numeric > 0.02;   -- > 2 %

-- =====================================================================
-- Persistent alerts table + detector engine
-- =====================================================================
do $$ begin
  create type app.alert_severity as enum ('info','warn','critical');
exception when duplicate_object then null; end $$;

do $$ begin
  create type app.alert_kind as enum (
    'chronic_short',
    'void_spike',
    'override_spike',
    'voucher_writeoffs',
    'stock_shrinkage',
    'after_hours',
    'recon_backlog',
    'aml_structuring',
    'aml_burst',
    'shift_unclosed',
    'chain_break',
    'reference_gap'
  );
exception when duplicate_object then null; end $$;

create table if not exists app.alerts (
  id            uuid primary key default gen_random_uuid(),
  shop_id       uuid not null references app.shops(id) on delete restrict,
  kind          app.alert_kind not null,
  severity      app.alert_severity not null default 'warn',
  subject_id    uuid,                  -- cashier / txn / settlement / shift
  payload       jsonb not null,
  created_at    timestamptz not null default now(),
  acknowledged_at timestamptz,
  acknowledged_by uuid references auth.users(id),
  ack_note      text,
  -- Avoid duplicate alerts for the same condition on the same day:
  dedupe_key    text not null unique
);
create index if not exists idx_alerts_open on app.alerts(shop_id, kind)
  where acknowledged_at is null;

-- Append-only / controlled update.
create or replace function app._alerts_guard()
returns trigger language plpgsql as $$
begin
  if tg_op = 'DELETE' then
    raise exception 'alerts cannot be deleted';
  end if;
  if current_setting('app.alerts_internal', true) is distinct from 'on' then
    raise exception 'alerts can only be modified via app.* functions';
  end if;
  return new;
end;
$$;
drop trigger if exists trg_alerts_guard on app.alerts;
create trigger trg_alerts_guard before update or delete on app.alerts
  for each row execute function app._alerts_guard();

create or replace function app._raise_alert(
  p_shop uuid, p_kind app.alert_kind, p_severity app.alert_severity,
  p_subject uuid, p_payload jsonb, p_dedupe text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare aid uuid;
begin
  insert into app.alerts(shop_id, kind, severity, subject_id, payload, dedupe_key)
    values (p_shop, p_kind, p_severity, p_subject, p_payload, p_dedupe)
  on conflict (dedupe_key) do nothing
  returning id into aid;
  return aid;
end;
$$;

-- The detector. Idempotent: each rule produces a deterministic
-- `dedupe_key` so re-running it doesn't multiply alerts.
create or replace function app.run_alert_detectors()
returns int
language plpgsql
security definer
set search_path = app, public
as $$
declare n int := 0; r record;
begin
  -- Chronic shorts (vector #2)
  for r in select * from app.v_alert_chronic_shorts loop
    if app._raise_alert(r.shop_id, 'chronic_short', 'critical',
         r.cashier_id,
         jsonb_build_object('short_usd_shifts', r.short_shifts_usd,
                            'short_lbp_shifts', r.short_shifts_lbp,
                            'total_var_usd', r.total_var_usd,
                            'total_var_lbp', r.total_var_lbp),
         format('chronic_short:%s:%s:%s', r.shop_id, r.cashier_id, to_char(now(),'YYYYMMDD'))
       ) is not null then n := n + 1; end if;
  end loop;

  -- Void spikes (vector #10)
  for r in select * from app.v_alert_void_spikes loop
    if app._raise_alert(r.shop_id, 'void_spike', 'warn',
         r.cashier_id,
         jsonb_build_object('day', r.day, 'count', r.void_count),
         format('void_spike:%s:%s:%s', r.shop_id, r.cashier_id, r.day)
       ) is not null then n := n + 1; end if;
  end loop;

  -- Override spikes (vector #12)
  for r in select * from app.v_alert_override_spikes loop
    if app._raise_alert(r.shop_id, 'override_spike', 'warn',
         r.cashier_id,
         jsonb_build_object('day', r.day, 'count', r.override_count),
         format('override_spike:%s:%s:%s', r.shop_id, r.cashier_id, r.day)
       ) is not null then n := n + 1; end if;
  end loop;

  -- Voucher write-off rate (vector #14)
  for r in select * from app.v_alert_voucher_writeoffs loop
    if app._raise_alert(r.shop_id, 'voucher_writeoffs', 'critical',
         null,
         jsonb_build_object('sku', r.sku, 'bad_30d', r.bad_30d, 'total_30d', r.total_30d),
         format('voucher_writeoffs:%s:%s:%s', r.shop_id, r.sku, to_char(now(),'YYYYMMDD'))
       ) is not null then n := n + 1; end if;
  end loop;

  -- Stock shrinkage (vector #13)
  for r in select * from app.v_alert_stock_shrinkage loop
    if app._raise_alert(r.shop_id, 'stock_shrinkage', 'warn',
         null,
         jsonb_build_object('sku', r.sku, 'shrink_qty_30d', r.shrink_qty_30d, 'sales_qty_30d', r.sales_qty_30d),
         format('stock_shrinkage:%s:%s:%s', r.shop_id, r.sku, to_char(now(),'YYYYMMDD'))
       ) is not null then n := n + 1; end if;
  end loop;

  -- After-hours (vector #22) — bucket per cashier per day
  for r in
    select shop_id, cashier_id,
           (occurred_at at time zone 'Asia/Beirut')::date as day,
           count(*) as cnt,
           sum(coalesce(gross_usd,0)) as g_usd,
           sum(coalesce(gross_lbp,0)) as g_lbp
    from app.v_alert_after_hours
    where occurred_at >= now() - interval '7 days'
    group by shop_id, cashier_id, (occurred_at at time zone 'Asia/Beirut')::date
  loop
    if app._raise_alert(r.shop_id, 'after_hours', 'warn',
         r.cashier_id,
         jsonb_build_object('day', r.day, 'count', r.cnt,
                            'gross_usd', r.g_usd, 'gross_lbp', r.g_lbp),
         format('after_hours:%s:%s:%s', r.shop_id, r.cashier_id, r.day)
       ) is not null then n := n + 1; end if;
  end loop;

  -- Recon backlog (vector #24)
  for r in select * from app.v_alert_recon_backlog loop
    if app._raise_alert(r.shop_id, 'recon_backlog', 'critical',
         r.settlement_id,
         jsonb_build_object('provider', r.provider,
                            'period_start', r.period_start,
                            'period_end', r.period_end,
                            'open_exceptions', r.open_exceptions),
         format('recon_backlog:%s', r.settlement_id)
       ) is not null then n := n + 1; end if;
  end loop;

  -- AML signals (from 0006)
  for r in select * from app.v_aml_structuring_by_customer loop
    if app._raise_alert(r.shop_id, 'aml_structuring', 'critical',
         r.customer_id,
         jsonb_build_object('day', r.day, 'service', r.service_code,
                            'cnt', r.cnt, 'sum_usd', r.sum_usd, 'sum_lbp', r.sum_lbp),
         format('aml_structuring:%s:%s:%s:%s', r.shop_id, r.customer_id, r.service_code, r.day)
       ) is not null then n := n + 1; end if;
  end loop;
  for r in select * from app.v_aml_same_beneficiary_burst loop
    if app._raise_alert(r.shop_id, 'aml_burst', 'critical',
         null,
         jsonb_build_object('beneficiary_phone', r.beneficiary_phone,
                            'window_hour', r.window_hour,
                            'cashier_count', r.cashier_count, 'cnt', r.cnt),
         format('aml_burst:%s:%s:%s', r.shop_id, r.beneficiary_phone, r.window_hour)
       ) is not null then n := n + 1; end if;
  end loop;

  -- Shift left open > 18 hours (vector #4)
  for r in
    select id, shop_id, cashier_id, opened_at
    from app.shifts
    where status = 'open' and opened_at < now() - interval '18 hours'
  loop
    if app._raise_alert(r.shop_id, 'shift_unclosed', 'warn',
         r.cashier_id,
         jsonb_build_object('shift_id', r.id, 'opened_at', r.opened_at),
         format('shift_unclosed:%s', r.id)
       ) is not null then n := n + 1; end if;
  end loop;

  -- Reference number gaps (vector #20)
  for r in select * from app.v_reference_gaps loop
    if app._raise_alert(r.shop_id, 'reference_gap', 'critical',
         null,
         jsonb_build_object('expected', r.expected_ref, 'actual', r.actual_ref),
         format('reference_gap:%s:%s', r.shop_id, r.expected_ref)
       ) is not null then n := n + 1; end if;
  end loop;

  -- Hash chain break (vector #25) — verify per shop, raise if any row fails
  for r in
    select s.id as shop_id
    from app.shops s
    where exists (select 1 from app.verify_chain(s.id) v where v.ok = false)
  loop
    if app._raise_alert(r.shop_id, 'chain_break', 'critical',
         null,
         jsonb_build_object('detected_at', now()),
         format('chain_break:%s:%s', r.shop_id, to_char(now(),'YYYYMMDDHH24'))
       ) is not null then n := n + 1; end if;
  end loop;

  return n;
end;
$$;
revoke all on function app.run_alert_detectors() from public;
grant execute on function app.run_alert_detectors() to authenticated;

-- Acknowledge an alert (owner only, audited).
create or replace function app.ack_alert(p_alert uuid, p_note text)
returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare a app.alerts%rowtype;
begin
  if p_note is null or length(btrim(p_note)) < 5 then
    raise exception 'ack note >= 5 chars required';
  end if;
  select * into a from app.alerts where id = p_alert;
  if a.id is null then raise exception 'alert not found'; end if;
  if not app.has_role_in_shop(a.shop_id, 'owner') then
    raise exception 'owner role required';
  end if;
  if a.acknowledged_at is not null then
    raise exception 'alert already acknowledged';
  end if;
  perform set_config('app.alerts_internal', 'on', true);
  update app.alerts
    set acknowledged_at = now(), acknowledged_by = auth.uid(), ack_note = p_note
    where id = p_alert;
  perform set_config('app.alerts_internal', 'off', true);
  perform app.log_auth_event('alert_ack', a.shop_id, null,
    jsonb_build_object('alert_id', p_alert, 'kind', a.kind));
end;
$$;
revoke all on function app.ack_alert(uuid, text) from public;
grant execute on function app.ack_alert(uuid, text) to authenticated;

-- =====================================================================
-- Owner dashboard rollup
-- =====================================================================
create or replace view app.v_owner_dashboard as
select
  s.id as shop_id,
  s.name as shop_name,
  (select count(*) from app.shifts where shop_id=s.id and status='open')                  as open_shifts,
  (select count(*) from app.alerts where shop_id=s.id and acknowledged_at is null)        as open_alerts,
  (select count(*) from app.alerts where shop_id=s.id and acknowledged_at is null
                                       and severity='critical')                            as critical_alerts,
  (select count(*) from app.reconciliation_exceptions e
     join app.settlements st on st.id=e.settlement_id
     where st.shop_id=s.id and e.resolved_at is null)                                      as open_recon_exceptions,
  (select coalesce(sum(gross_usd),0) from app.v_daily_pnl
     where shop_id=s.id and day = (now() at time zone 'Asia/Beirut')::date)                as today_gross_usd,
  (select coalesce(sum(gross_lbp),0) from app.v_daily_pnl
     where shop_id=s.id and day = (now() at time zone 'Asia/Beirut')::date)                as today_gross_lbp
from app.shops s;

-- =====================================================================
-- RLS
-- =====================================================================
alter table app.alerts enable row level security;
alter table app.alerts force  row level security;
revoke insert, update, delete on app.alerts from authenticated;

drop policy if exists alerts_select on app.alerts;
create policy alerts_select on app.alerts
  for select to authenticated
  using (
    app.has_any_role_in_shop(shop_id,
      array['owner','manager','auditor']::app.business_role[])
  );
grant select on app.alerts to authenticated;

-- End migration 0010 ----------------------------------------------------
