"""Local staff scheduling: exact UTC intervals with explicit London clock folds."""
from datetime import date, datetime, time, timedelta, timezone
import json
import re
from typing import Literal
from fastapi import HTTPException, Request
from pydantic import Field, StrictBool, StrictInt, field_validator
from .models import Strict
from .material_rules import Identity, VersionNumber, Version
from .store import one, many, uid, now, audit
from .workforce import LONDON, required, current

SCHEMA='''
CREATE TABLE IF NOT EXISTS calendar_bookings(
 id TEXT PRIMARY KEY,kind TEXT NOT NULL CHECK(kind IN ('job','unavailable')),job_id TEXT REFERENCES jobs(id),
 title TEXT NOT NULL,resource_ids TEXT NOT NULL,resource_snapshot TEXT NOT NULL,whole_team INTEGER NOT NULL,
 all_day INTEGER NOT NULL,start_date TEXT NOT NULL,end_date TEXT NOT NULL,start_time TEXT NOT NULL,end_time TEXT NOT NULL,
 start_fold INTEGER,end_fold INTEGER,start_utc TEXT NOT NULL,end_utc TEXT NOT NULL,note TEXT NOT NULL,
 version INTEGER NOT NULL DEFAULT 1,archived INTEGER NOT NULL DEFAULT 0,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS calendar_interval ON calendar_bookings(start_utc,end_utc);
CREATE INDEX IF NOT EXISTS calendar_job ON calendar_bookings(job_id);
CREATE TABLE IF NOT EXISTS calendar_audit(id INTEGER PRIMARY KEY AUTOINCREMENT,record_id TEXT NOT NULL,actor TEXT NOT NULL,
 action TEXT NOT NULL,before_value TEXT,after_value TEXT,created_at TEXT NOT NULL);
'''


def calendar_date(value):
    if not re.fullmatch(r'\d{4}-\d{2}-\d{2}',value):raise ValueError('Use a date in YYYY-MM-DD format.')
    parsed=date.fromisoformat(value)
    if not 2020<=parsed.year<=2100:raise ValueError('Choose a date from 2020 through 2100.')
    return parsed


def utc_stamp(value):
    return value.astimezone(timezone.utc).isoformat(timespec='seconds')


def london_instant(day,clock,fold=None):
    if not re.fullmatch(r'(?:[01]\d|2[0-3]):[0-5]\d',clock):raise HTTPException(422,'Use times from 00:00 to 23:59.')
    naive=datetime.combine(day,time.fromisoformat(clock))
    candidates={}
    for candidate_fold in (0,1):
        candidate=naive.replace(tzinfo=LONDON,fold=candidate_fold)
        if candidate.astimezone(timezone.utc).astimezone(LONDON).replace(tzinfo=None)==naive:
            candidates[candidate_fold]=candidate
    if not candidates:raise HTTPException(422,f'{day} {clock} does not exist in UK time because the clocks move forward. Choose another time.')
    ambiguous=len({c.utcoffset() for c in candidates.values()})>1
    if ambiguous and fold is None:raise HTTPException(422,f'{day} {clock} occurs twice when UK clocks go back. Choose the first or second occurrence.')
    return candidates[fold if ambiguous else min(candidates)]


class BookingFields(Strict):
    kind: Literal['job','unavailable']
    job_id: Identity | None = None
    title: str = Field(default='',max_length=160)
    resource_ids: list[Identity] = Field(default_factory=list,max_length=50)
    whole_team: StrictBool = False
    all_day: StrictBool = False
    start_date: str
    end_date: str
    start_time: str = Field(default='',max_length=5)
    end_time: str = Field(default='',max_length=5)
    start_fold: StrictInt | None = Field(default=None,ge=0,le=1)
    end_fold: StrictInt | None = Field(default=None,ge=0,le=1)
    note: str = Field(default='',max_length=1000)

    @field_validator('start_date','end_date')
    @classmethod
    def dates(cls,value):return calendar_date(value).isoformat()


class BookingEdit(BookingFields):
    version: VersionNumber


def decode(row):
    if row:
        for key in ('resource_ids','resource_snapshot'):row[key]=json.loads(row[key])
    return row


def prepare(db,payload):
    fields=payload.model_dump(exclude={'version'})
    start=calendar_date(payload.start_date);end=calendar_date(payload.end_date)
    if not 0<=(end-start).days<31:raise HTTPException(422,'The end date must be on or after the start, spanning at most 31 calendar dates.')
    if payload.all_day:
        if payload.start_time or payload.end_time or payload.start_fold is not None or payload.end_fold is not None:raise HTTPException(422,'All-day bookings use dates only. Clear the times and clock choices.')
        start_utc=utc_stamp(datetime.combine(start,time.min,LONDON))
        end_utc=utc_stamp(datetime.combine(end+timedelta(days=1),time.min,LONDON))
    else:
        start_utc=utc_stamp(london_instant(start,payload.start_time,payload.start_fold))
        end_utc=utc_stamp(london_instant(end,payload.end_time,payload.end_fold))
    if end_utc<=start_utc:raise HTTPException(422,'The end must be after the start in UK time.')
    if len(set(payload.resource_ids))!=len(payload.resource_ids):raise HTTPException(422,'Choose each person once.')
    if payload.whole_team and (payload.kind!='unavailable' or payload.resource_ids):raise HTTPException(422,'Whole-team unavailability cannot also name individual people or a job.')
    if not payload.whole_team and not payload.resource_ids:raise HTTPException(422,'Choose at least one team member.')
    people=[]
    for rid in payload.resource_ids:
        person=required(one(db,'SELECT * FROM team_resources WHERE id=?',(rid,)))
        if person['archived']:raise HTTPException(409,'Choose an active team member. A selected person was archived.')
        people.append({k:person[k] for k in ('id','name','colour')})
    if payload.kind=='job':
        job=required(one(db,'SELECT service FROM jobs WHERE id=?',(payload.job_id,)))
        fields['title']=payload.title or job['service']
    else:
        if payload.job_id:raise HTTPException(422,'An unavailable period cannot be attached to a job.')
        if not payload.title:raise HTTPException(422,'Describe the unavailable period.')
    return {**fields,'resource_snapshot':people,'start_utc':start_utc,'end_utc':end_utc}


def no_conflict(db,candidate,exclude=''):
    rows=many(db,'SELECT * FROM calendar_bookings WHERE archived=0 AND id!=? AND start_utc<? AND end_utc>?',(exclude,candidate['end_utc'],candidate['start_utc']))
    for row in rows:
        if candidate['whole_team'] or row['whole_team'] or set(candidate['resource_ids'])&set(json.loads(row['resource_ids'])):
            raise HTTPException(409,f"This overlaps ‘{row['title']}’ ({row['start_date']} {row['start_time'] or 'all day'}). Change the time or team members.")


def log(db,s,action,before,after):
    db.execute('INSERT INTO calendar_audit(record_id,actor,action,before_value,after_value,created_at) VALUES(?,?,?,?,?,?)',((after or before)['id'],s['subject'],action,json.dumps(before) if before else None,json.dumps(after),now()))
    audit(db,'calendar.'+action,(after or before)['id'])


def mount_scheduling(app,store,session,once):
    @app.get('/api/calendar')
    def calendar(request:Request,start_date:str,end_date:str,resource_id:str='',job_id:str='',archived:bool=False):
        s=session(request)
        try:start=calendar_date(start_date);end=calendar_date(end_date)
        except ValueError as e:raise HTTPException(422,str(e))
        if not 0<=(end-start).days<62:raise HTTPException(422,'Choose up to 62 calendar dates.')
        lo=utc_stamp(datetime.combine(start,time.min,LONDON));hi=utc_stamp(datetime.combine(end+timedelta(days=1),time.min,LONDON))
        with store.tenant(s['business_id']) as db:
            if resource_id:required(one(db,'SELECT id FROM team_resources WHERE id=?',(resource_id,)))
            if job_id:required(one(db,'SELECT id FROM jobs WHERE id=?',(job_id,)))
            rows=[decode(r) for r in many(db,'SELECT b.*,j.service AS job_service,c.name AS customer_name FROM calendar_bookings b LEFT JOIN jobs j ON j.id=b.job_id LEFT JOIN customers c ON c.id=j.customer_id WHERE b.archived=? AND b.start_utc<? AND b.end_utc>? ORDER BY b.start_utc,b.created_at,b.rowid',(int(archived),hi,lo))]
            if resource_id:rows=[r for r in rows if r['whole_team'] or resource_id in r['resource_ids']]
            if job_id:rows=[r for r in rows if r['job_id']==job_id]
            from .recurrence import calendar_origin
            return {'items':[calendar_origin(db,r) for r in rows],'timezone':'Europe/London'}

    @app.post('/api/calendar')
    def create(payload:BookingFields,request:Request):
        s=session(request,csrf=True)
        with store.tenant(s['business_id']) as db:
            def action():
                fields=prepare(db,payload);no_conflict(db,fields)
                fields.update(id=uid(),created_at=now(),updated_at=now())
                for key in ('resource_ids','resource_snapshot'):fields[key]=json.dumps(fields[key])
                columns=','.join(fields);marks=','.join('?' for _ in fields)
                db.execute(f'INSERT INTO calendar_bookings({columns}) VALUES({marks})',tuple(fields.values()))
                row=decode(one(db,'SELECT * FROM calendar_bookings WHERE id=?',(fields['id'],)));log(db,s,'created',None,row);return row
            return once(db,request,payload.model_dump(),action)

    @app.put('/api/calendar/{booking_id}')
    def edit(booking_id:str,payload:BookingEdit,request:Request):
        s=session(request,csrf=True)
        with store.tenant(s['business_id']) as db:
            def action():
                from .recurrence import guard_generated
                guard_generated(db,booking_id)
                before=decode(current(one(db,'SELECT * FROM calendar_bookings WHERE id=?',(booking_id,)),payload.version))
                if payload.kind!=before['kind'] or payload.job_id!=before['job_id']:raise HTTPException(409,'Keep the original job and booking type. Archive this booking and create another to change them.')
                fields=prepare(db,payload);no_conflict(db,fields,booking_id)
                fields.update(version=before['version']+1,updated_at=now())
                for key in ('resource_ids','resource_snapshot'):fields[key]=json.dumps(fields[key])
                setters=','.join(k+'=?' for k in fields)
                db.execute(f'UPDATE calendar_bookings SET {setters} WHERE id=?',(*fields.values(),booking_id))
                row=decode(one(db,'SELECT * FROM calendar_bookings WHERE id=?',(booking_id,)));log(db,s,'edited',before,row);return row
            return once(db,request,payload.model_dump(),action)

    @app.get('/api/calendar/{booking_id}/history')
    def history(booking_id:str,request:Request):
        s=session(request)
        with store.tenant(s['business_id']) as db:
            required(one(db,'SELECT id FROM calendar_bookings WHERE id=?',(booking_id,)))
            rows=many(db,'SELECT action,before_value,after_value,created_at FROM calendar_audit WHERE record_id=? ORDER BY id DESC',(booking_id,))
            for row in rows:
                for k in ('before_value','after_value'):row[k]=json.loads(row[k]) if row[k] else None
            return rows

    @app.post('/api/calendar/{booking_id}/{action}')
    def archive(booking_id:str,action:str,payload:Version,request:Request):
        s=session(request,csrf=True)
        if action not in ('archive','restore'):raise HTTPException(404,'Route not found.')
        with store.tenant(s['business_id']) as db:
            def change():
                from .recurrence import guard_generated
                guard_generated(db,booking_id)
                before=decode(current(one(db,'SELECT * FROM calendar_bookings WHERE id=?',(booking_id,)),payload.version,False))
                target=int(action=='archive')
                if before['archived']==target:raise HTTPException(409,'This booking is already in that state.')
                if not target:
                    for rid in before['resource_ids']:
                        if required(one(db,'SELECT archived FROM team_resources WHERE id=?',(rid,)))['archived']:raise HTTPException(409,'Restore the assigned team members before restoring this booking.')
                    no_conflict(db,before,booking_id)
                db.execute('UPDATE calendar_bookings SET archived=?,version=version+1,updated_at=? WHERE id=?',(target,now(),booking_id))
                row=decode(one(db,'SELECT * FROM calendar_bookings WHERE id=?',(booking_id,)));log(db,s,action,before,row);return row
            return once(db,request,payload.model_dump(),change)
